use std::any::Any;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use askama::Template;
use laterite_core::validation::{Mode, Rule};
use laterite_core::AttrValue;
use serde::{Deserialize, Serialize};
use crate::html::Markup;
use crate::picker::PickerRegistry;
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum FieldValue {
Null,
Text(String),
Json(serde_json::Value),
}
impl FieldValue {
pub fn as_text(&self) -> &str {
match self {
FieldValue::Text(s) => s,
_ => "",
}
}
}
#[derive(Default)]
pub struct ResolvedOptions(Option<Arc<dyn Any + Send + Sync>>);
impl ResolvedOptions {
pub fn none() -> Self {
Self(None)
}
pub fn new<T: Any + Send + Sync>(value: T) -> Self {
Self(Some(Arc::new(value)))
}
pub fn get<T: Any + Send + Sync>(&self) -> Option<&T> {
self.0.as_deref().and_then(|a| a.downcast_ref::<T>())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Chrome {
Wrapped,
Bare,
}
#[non_exhaustive]
pub struct FieldCx<'a> {
pub name: &'a str,
pub id: &'a str,
pub label: &'a str,
pub value: &'a FieldValue,
pub required: bool,
pub opts: &'a ResolvedOptions,
pub base: &'a str,
}
#[derive(Debug, Clone, Serialize)]
pub struct FieldVm {
pub view_key: String,
pub name: String,
pub id: String,
pub label: String,
pub required: bool,
pub value: FieldValue,
pub data: serde_json::Value,
}
pub struct SubmittedField<'a> {
name: &'a str,
data: &'a HashMap<String, String>,
}
impl<'a> SubmittedField<'a> {
pub fn new(name: &'a str, data: &'a HashMap<String, String>) -> Self {
Self { name, data }
}
pub fn name(&self) -> &str {
self.name
}
pub fn value(&self) -> Option<&str> {
self.data.get(self.name).map(String::as_str)
}
pub fn nested(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
let prefix = format!("{}[", self.name);
self.data.iter().filter_map(move |(key, value)| {
key.strip_prefix(&prefix).map(|rest| (rest, value.as_str()))
})
}
pub fn all(&self) -> &'a HashMap<String, String> {
self.data
}
}
pub trait FieldType: Send + Sync + 'static {
fn view_key(&self) -> &'static str;
fn resolve_options(
&self,
_raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
Ok(ResolvedOptions::none())
}
fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
Vec::new()
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm;
fn render_default(&self, vm: &FieldVm) -> Markup;
fn to_attr(
&self,
field: &SubmittedField<'_>,
opts: &ResolvedOptions,
mode: Mode,
) -> Result<Option<AttrValue>, String> {
let _ = (opts, mode);
Ok(field.value().map(|v| AttrValue::Text(v.to_string())))
}
fn to_control(&self, stored: Option<&str>, opts: &ResolvedOptions) -> String {
let _ = opts;
stored.unwrap_or_default().to_string()
}
fn chrome(&self) -> Chrome {
Chrome::Wrapped
}
fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
Vec::new()
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid field options: {0}")]
pub struct OptionsError(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
Field,
Column,
}
pub struct OverrideScope<'a> {
pub surface: Surface,
pub view_key: &'a str,
pub resource: Option<&'a str>,
pub field: Option<&'a str>,
}
#[derive(Debug, thiserror::Error)]
#[error("override render failed: {0}")]
pub struct OverrideError(pub String);
pub trait OverrideResolver: Send + Sync {
fn render_override(
&self,
scope: &OverrideScope<'_>,
vm: &serde_json::Value,
) -> Option<Result<String, OverrideError>>;
}
pub struct NoOverrides;
impl OverrideResolver for NoOverrides {
fn render_override(
&self,
_scope: &OverrideScope<'_>,
_vm: &serde_json::Value,
) -> Option<Result<String, OverrideError>> {
None
}
}
pub type FieldRegistry = HashMap<String, Arc<dyn FieldType>>;
pub(crate) fn render_field(
ft: &dyn FieldType,
resolver: &dyn OverrideResolver,
scope: &OverrideScope<'_>,
cx: &FieldCx<'_>,
) -> Markup {
let vm = ft.view_model(cx);
match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
Some(Ok(html)) => Markup::from_override(html),
Some(Err(_)) | None => ft.render_default(&vm),
}
}
pub(crate) struct RepeaterOptions {
rows: Vec<RepeaterSub>,
min_items: usize,
max_items: Option<usize>,
}
pub(crate) struct RepeaterSub {
field: crate::form::FormField,
field_type: Arc<dyn FieldType>,
opts: ResolvedOptions,
}
#[derive(Deserialize)]
struct RepeaterOptionsRaw {
fields: Vec<crate::form::FormField>,
#[serde(default)]
min_items: usize,
#[serde(default)]
max_items: Option<usize>,
}
pub(crate) struct RepeaterField;
impl RepeaterField {
const BLANK: &'static str = "__index__";
}
impl FieldType for RepeaterField {
fn view_key(&self) -> &'static str {
"repeater"
}
fn resolve_options(
&self,
raw: &serde_json::Value,
types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let parsed: RepeaterOptionsRaw =
serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
let mut rows = Vec::new();
for field in parsed.fields {
if field.field_type == "repeater" {
return Err(OptionsError(
"a repeater cannot hold a repeater".to_string(),
));
}
let field_type = types
.get(&field.field_type)
.cloned()
.ok_or_else(|| OptionsError(format!("unregistered type `{}`", field.field_type)))?;
let opts = field_type.resolve_options(&field.options, types)?;
rows.push(RepeaterSub {
field,
field_type,
opts,
});
}
Ok(ResolvedOptions::new(RepeaterOptions {
rows,
min_items: parsed.min_items,
max_items: parsed.max_items,
}))
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
let opts = cx.opts.get::<RepeaterOptions>();
let stored = match cx.value {
FieldValue::Json(serde_json::Value::Array(rows)) => rows.clone(),
_ => Vec::new(),
};
let data = RepeaterData {
rows: opts
.map(|o| {
stored
.iter()
.enumerate()
.map(|(index, row)| render_row(o, cx, &index.to_string(), Some(row)))
.collect()
})
.unwrap_or_default(),
blank: opts
.map(|o| render_row(o, cx, Self::BLANK, None))
.unwrap_or_default(),
};
FieldVm {
view_key: self.view_key().to_string(),
name: cx.name.to_string(),
id: cx.id.to_string(),
label: cx.label.to_string(),
required: cx.required,
value: cx.value.clone(),
data: serde_json::to_value(data).unwrap_or_default(),
}
}
fn render_default(&self, vm: &FieldVm) -> Markup {
let data: RepeaterData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
Markup::from_template(&RepeaterTmpl { data }).unwrap_or_default()
}
fn to_attr(
&self,
field: &SubmittedField<'_>,
opts: &ResolvedOptions,
mode: Mode,
) -> Result<Option<AttrValue>, String> {
let Some(options) = opts.get::<RepeaterOptions>() else {
return Ok(Some(AttrValue::Json(serde_json::Value::Array(Vec::new()))));
};
let mut indices: Vec<usize> = field
.nested()
.filter_map(|(rest, _)| rest.split_once(']').and_then(|(i, _)| i.parse().ok()))
.collect();
indices.sort_unstable();
indices.dedup();
let mut rows = Vec::new();
for index in indices {
let mut object = serde_json::Map::new();
for sub in &options.rows {
let key = format!("{}[{index}][{}]", field.name(), sub.field.name);
let scoped = SubmittedField::new(&key, field.all());
if let Some(value) = sub.field_type.to_attr(&scoped, &sub.opts, mode)? {
object.insert(sub.field.name.clone(), attr_to_json(value));
}
}
let row = serde_json::Value::Object(object);
if !is_blank_row(&row) {
rows.push(row);
}
}
if rows.len() < options.min_items {
return Err(format!("at least {} required", options.min_items));
}
if let Some(max) = options.max_items {
if rows.len() > max {
return Err(format!("at most {max} allowed"));
}
}
Ok(Some(AttrValue::Json(serde_json::Value::Array(rows))))
}
fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
stored.unwrap_or("[]").to_string()
}
}
fn render_row(
options: &RepeaterOptions,
cx: &FieldCx<'_>,
index: &str,
row: Option<&serde_json::Value>,
) -> Vec<RepeaterCell> {
options
.rows
.iter()
.map(|sub| {
let name = format!("{}[{index}][{}]", cx.name, sub.field.name);
let stored = row.and_then(|r| r.get(&sub.field.name));
let value = match stored {
Some(serde_json::Value::String(s)) => FieldValue::Text(s.clone()),
Some(serde_json::Value::Null) | None => FieldValue::Text(String::new()),
Some(other) => FieldValue::Text(
sub.field_type
.to_control(Some(&other.to_string()), &sub.opts),
),
};
let sub_cx = FieldCx {
name: &name,
id: &name,
label: cx.label,
value: &value,
required: false,
opts: &sub.opts,
base: cx.base,
};
let vm = sub.field_type.view_model(&sub_cx);
RepeaterCell {
label: sub.field.label.source().to_string(),
control: sub.field_type.render_default(&vm).into_string(),
}
})
.collect()
}
fn attr_to_json(value: AttrValue) -> serde_json::Value {
match value {
AttrValue::Null => serde_json::Value::Null,
AttrValue::Bool(b) => serde_json::Value::Bool(b),
AttrValue::Int(i) => serde_json::Value::from(i),
AttrValue::Float(f) => serde_json::Value::from(f),
AttrValue::Json(j) => j,
other => serde_json::Value::String(other.to_text().unwrap_or_default()),
}
}
fn is_blank_row(row: &serde_json::Value) -> bool {
row.as_object().is_none_or(|object| {
object.values().all(|v| match v {
serde_json::Value::String(s) => s.trim().is_empty(),
serde_json::Value::Bool(b) => !b,
serde_json::Value::Array(items) => items.is_empty(),
serde_json::Value::Null => true,
_ => false,
})
})
}
#[derive(Serialize, Deserialize)]
struct RepeaterCell {
label: String,
control: String,
}
#[derive(Serialize, Deserialize, Default)]
struct RepeaterData {
rows: Vec<Vec<RepeaterCell>>,
blank: Vec<RepeaterCell>,
}
#[derive(Template)]
#[template(path = "fields/repeater.html")]
struct RepeaterTmpl {
data: RepeaterData,
}
pub(crate) fn builtin_field_types() -> Vec<Arc<dyn FieldType>> {
let inputs = Arc::new(builtin_input_registry());
vec![
Arc::new(TextField::new(inputs)),
Arc::new(TextareaField),
Arc::new(RepeaterField),
Arc::new(SelectField),
Arc::new(SwitchField),
Arc::new(DateField),
Arc::new(PasswordField),
Arc::new(RadioField),
]
}
pub(crate) fn builtin_registry() -> FieldRegistry {
builtin_field_types()
.into_iter()
.map(|ft| (ft.view_key().to_string(), ft))
.collect()
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Placement {
Leading,
Trailing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Adornment {
pub placement: Placement,
pub button: bool,
pub label: String,
pub widget: Option<String>,
}
pub trait InputType: Send + Sync + 'static {
fn key(&self) -> &'static str;
fn html_type(&self) -> &'static str {
"text"
}
fn resolve_options(
&self,
_raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
Ok(ResolvedOptions::none())
}
fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
Vec::new()
}
fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
BTreeMap::new()
}
fn adornments(&self, _opts: &ResolvedOptions) -> Vec<Adornment> {
Vec::new()
}
fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
Vec::new()
}
}
pub type InputRegistry = HashMap<String, Arc<dyn InputType>>;
struct TextInput;
impl InputType for TextInput {
fn key(&self) -> &'static str {
"text"
}
}
struct EmailInput;
impl InputType for EmailInput {
fn key(&self) -> &'static str {
"email"
}
fn html_type(&self) -> &'static str {
"email"
}
fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
vec![Rule::Email]
}
}
struct TelInput;
impl InputType for TelInput {
fn key(&self) -> &'static str {
"tel"
}
fn html_type(&self) -> &'static str {
"tel"
}
}
#[derive(Default, Deserialize)]
struct NumberOptions {
#[serde(default)]
min: Option<f64>,
#[serde(default)]
max: Option<f64>,
#[serde(default)]
step: Option<f64>,
}
struct NumberInput;
impl InputType for NumberInput {
fn key(&self) -> &'static str {
"number"
}
fn html_type(&self) -> &'static str {
"number"
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: NumberOptions = if raw.is_null() {
NumberOptions::default()
} else {
serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
};
Ok(ResolvedOptions::new(opts))
}
fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
vec![Rule::Numeric]
}
fn attributes(&self, opts: &ResolvedOptions) -> BTreeMap<String, String> {
let mut attrs = BTreeMap::new();
if let Some(o) = opts.get::<NumberOptions>() {
if let Some(min) = o.min {
attrs.insert("min".to_string(), min.to_string());
}
if let Some(max) = o.max {
attrs.insert("max".to_string(), max.to_string());
}
if let Some(step) = o.step {
attrs.insert("step".to_string(), step.to_string());
}
}
attrs
}
}
#[derive(Deserialize)]
struct UrlOptions {
#[serde(default = "default_true")]
copy: bool,
}
impl Default for UrlOptions {
fn default() -> Self {
Self { copy: true }
}
}
fn default_true() -> bool {
true
}
struct UrlInput;
impl InputType for UrlInput {
fn key(&self) -> &'static str {
"url"
}
fn html_type(&self) -> &'static str {
"url"
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: UrlOptions = if raw.is_null() {
UrlOptions::default()
} else {
serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
};
Ok(ResolvedOptions::new(opts))
}
fn rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
vec![Rule::Url]
}
fn adornments(&self, opts: &ResolvedOptions) -> Vec<Adornment> {
if opts.get::<UrlOptions>().is_none_or(|o| o.copy) {
vec![Adornment {
placement: Placement::Trailing,
button: true,
label: "Copy".to_string(),
widget: Some("copy".to_string()),
}]
} else {
Vec::new()
}
}
}
pub(crate) fn builtin_input_types() -> Vec<Arc<dyn InputType>> {
vec![
Arc::new(TextInput),
Arc::new(EmailInput),
Arc::new(TelInput),
Arc::new(NumberInput),
Arc::new(UrlInput),
]
}
pub(crate) fn builtin_input_registry() -> InputRegistry {
builtin_input_types()
.into_iter()
.map(|i| (i.key().to_string(), i))
.collect()
}
const RESERVED_ATTRS: [&str; 6] = ["type", "id", "name", "value", "required", "class"];
fn validate_contributions(
attrs: &BTreeMap<String, String>,
adornments: &[Adornment],
) -> Result<(), OptionsError> {
for name in attrs.keys() {
if !is_name(name, false) {
return Err(OptionsError(format!("invalid attribute name `{name}`")));
}
if RESERVED_ATTRS.contains(&name.as_str()) {
return Err(OptionsError(format!("attribute `{name}` is reserved")));
}
}
for widget in adornments.iter().filter_map(|a| a.widget.as_deref()) {
if !is_name(widget, true) {
return Err(OptionsError(format!("invalid widget name `{widget}`")));
}
}
Ok(())
}
pub(crate) fn is_name(s: &str, dotted: bool) -> bool {
let mut chars = s.chars();
matches!(chars.next(), Some(c) if c.is_ascii_lowercase())
&& chars.all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || (dotted && c == '.')
})
}
#[derive(Deserialize)]
struct TextOptions {
#[serde(default = "default_input")]
input: String,
}
fn default_input() -> String {
"text".to_string()
}
struct TextResolved {
html_type: &'static str,
attrs: BTreeMap<String, String>,
adornments: Vec<Adornment>,
rules: Vec<Rule>,
assets: Vec<&'static str>,
}
#[derive(Default, Serialize, Deserialize)]
struct TextData {
input_type: String,
#[serde(default)]
attrs: BTreeMap<String, String>,
#[serde(default)]
adornments: Vec<Adornment>,
}
#[derive(Template)]
#[template(path = "fields/text.html")]
struct TextTmpl<'a> {
name: &'a str,
id: &'a str,
value: &'a str,
required: bool,
input_type: &'a str,
attrs: &'a BTreeMap<String, String>,
leading: &'a [Adornment],
trailing: &'a [Adornment],
}
pub(crate) struct TextField {
inputs: Arc<InputRegistry>,
}
impl TextField {
pub(crate) fn new(inputs: Arc<InputRegistry>) -> Self {
Self { inputs }
}
}
impl FieldType for TextField {
fn view_key(&self) -> &'static str {
"text"
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: TextOptions = if raw.is_null() {
TextOptions {
input: default_input(),
}
} else {
serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?
};
let input = self
.inputs
.get(&opts.input)
.cloned()
.ok_or_else(|| OptionsError(format!("unknown input type `{}`", opts.input)))?;
let input_opts = input.resolve_options(raw, _types)?;
let attrs = input.attributes(&input_opts);
let adornments = input.adornments(&input_opts);
validate_contributions(&attrs, &adornments)?;
Ok(ResolvedOptions::new(TextResolved {
html_type: input.html_type(),
rules: input.rules(&input_opts),
assets: input.assets(&input_opts),
attrs,
adornments,
}))
}
fn intrinsic_rules(&self, opts: &ResolvedOptions) -> Vec<Rule> {
opts.get::<TextResolved>()
.map(|r| r.rules.clone())
.unwrap_or_default()
}
fn assets(&self, opts: &ResolvedOptions) -> Vec<&'static str> {
opts.get::<TextResolved>()
.map(|r| r.assets.clone())
.unwrap_or_default()
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
let data = match cx.opts.get::<TextResolved>() {
Some(r) => TextData {
input_type: r.html_type.to_string(),
attrs: r.attrs.clone(),
adornments: r.adornments.clone(),
},
None => TextData {
input_type: "text".to_string(),
..TextData::default()
},
};
FieldVm {
view_key: "text".to_string(),
name: cx.name.to_string(),
id: cx.id.to_string(),
label: cx.label.to_string(),
required: cx.required,
value: cx.value.clone(),
data: serde_json::to_value(data).unwrap_or_default(),
}
}
fn render_default(&self, vm: &FieldVm) -> Markup {
let data: TextData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
let (leading, trailing): (Vec<Adornment>, Vec<Adornment>) = data
.adornments
.into_iter()
.partition(|a| matches!(a.placement, Placement::Leading));
Markup::from_template(&TextTmpl {
name: &vm.name,
id: &vm.id,
value: vm.value.as_text(),
required: vm.required,
input_type: &data.input_type,
attrs: &data.attrs,
leading: &leading,
trailing: &trailing,
})
.unwrap_or_default()
}
}
#[derive(Template)]
#[template(path = "fields/textarea.html")]
struct TextareaTmpl<'a> {
name: &'a str,
id: &'a str,
value: &'a str,
required: bool,
}
pub(crate) struct TextareaField;
impl FieldType for TextareaField {
fn view_key(&self) -> &'static str {
"textarea"
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
scalar_vm("textarea", cx)
}
fn render_default(&self, vm: &FieldVm) -> Markup {
Markup::from_template(&TextareaTmpl {
name: &vm.name,
id: &vm.id,
value: vm.value.as_text(),
required: vm.required,
})
.unwrap_or_default()
}
}
#[derive(Debug, Default, Deserialize)]
struct SelectOptions {
#[serde(default)]
options: Vec<SelectOption>,
}
#[derive(Debug, Deserialize)]
struct SelectOption {
value: String,
#[serde(default)]
label: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct OptionView {
value: String,
label: String,
selected: bool,
}
#[derive(Template)]
#[template(path = "fields/select.html")]
struct SelectTmpl<'a> {
name: &'a str,
id: &'a str,
required: bool,
options: &'a [OptionView],
}
pub(crate) struct SelectField;
impl FieldType for SelectField {
fn view_key(&self) -> &'static str {
"select"
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: SelectOptions =
serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
Ok(ResolvedOptions::new(opts))
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
let current = cx.value.as_text();
let views: Vec<OptionView> = cx
.opts
.get::<SelectOptions>()
.map(|o| {
o.options
.iter()
.map(|so| OptionView {
value: so.value.clone(),
label: so.label.clone().unwrap_or_else(|| so.value.clone()),
selected: so.value == current,
})
.collect()
})
.unwrap_or_default();
FieldVm {
view_key: "select".to_string(),
name: cx.name.to_string(),
id: cx.id.to_string(),
label: cx.label.to_string(),
required: cx.required,
value: cx.value.clone(),
data: serde_json::to_value(&views).unwrap_or_default(),
}
}
fn render_default(&self, vm: &FieldVm) -> Markup {
let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
Markup::from_template(&SelectTmpl {
name: &vm.name,
id: &vm.id,
required: vm.required,
options: &options,
})
.unwrap_or_default()
}
}
#[derive(Template)]
#[template(path = "fields/switch.html")]
struct SwitchTmpl<'a> {
name: &'a str,
id: &'a str,
on: bool,
}
pub(crate) struct SwitchField;
fn stored_is_on(stored: Option<&str>) -> bool {
matches!(
stored.map(str::trim).unwrap_or_default(),
"1" | "true" | "TRUE" | "t" | "yes" | "on"
)
}
impl FieldType for SwitchField {
fn view_key(&self) -> &'static str {
"switch"
}
fn to_attr(
&self,
field: &SubmittedField<'_>,
_opts: &ResolvedOptions,
_mode: Mode,
) -> Result<Option<AttrValue>, String> {
Ok(Some(AttrValue::Bool(stored_is_on(field.value()))))
}
fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
if stored_is_on(stored) {
"1".to_string()
} else {
String::new()
}
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
scalar_vm("switch", cx)
}
fn render_default(&self, vm: &FieldVm) -> Markup {
Markup::from_template(&SwitchTmpl {
name: &vm.name,
id: &vm.id,
on: stored_is_on(Some(vm.value.as_text())),
})
.unwrap_or_default()
}
}
#[derive(Template)]
#[template(path = "fields/date.html")]
struct DateTmpl<'a> {
name: &'a str,
id: &'a str,
value: &'a str,
required: bool,
}
pub(crate) struct DateField;
impl FieldType for DateField {
fn view_key(&self) -> &'static str {
"date"
}
fn intrinsic_rules(&self, _opts: &ResolvedOptions) -> Vec<Rule> {
vec![Rule::Date]
}
fn to_attr(
&self,
field: &SubmittedField<'_>,
_opts: &ResolvedOptions,
_mode: Mode,
) -> Result<Option<AttrValue>, String> {
Ok(field.value().map(|v| match v.trim() {
"" => AttrValue::Null,
date => AttrValue::Text(date.to_string()),
}))
}
fn to_control(&self, stored: Option<&str>, _opts: &ResolvedOptions) -> String {
let stored = stored.unwrap_or_default().trim();
stored
.split(['T', ' '])
.next()
.unwrap_or_default()
.to_string()
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
scalar_vm("date", cx)
}
fn render_default(&self, vm: &FieldVm) -> Markup {
Markup::from_template(&DateTmpl {
name: &vm.name,
id: &vm.id,
value: vm.value.as_text(),
required: vm.required,
})
.unwrap_or_default()
}
}
#[derive(Template)]
#[template(path = "fields/password.html")]
struct PasswordTmpl<'a> {
name: &'a str,
id: &'a str,
required: bool,
}
pub(crate) struct PasswordField;
impl FieldType for PasswordField {
fn view_key(&self) -> &'static str {
"password"
}
fn to_attr(
&self,
field: &SubmittedField<'_>,
_opts: &ResolvedOptions,
_mode: Mode,
) -> Result<Option<AttrValue>, String> {
match field.value().map(str::trim).unwrap_or_default() {
"" => Ok(None),
plain => laterite_auth::password::hash_password(plain)
.map(|hash| Some(AttrValue::Text(hash)))
.map_err(|e| e.to_string()),
}
}
fn to_control(&self, _stored: Option<&str>, _opts: &ResolvedOptions) -> String {
String::new()
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
scalar_vm("password", cx)
}
fn render_default(&self, vm: &FieldVm) -> Markup {
Markup::from_template(&PasswordTmpl {
name: &vm.name,
id: &vm.id,
required: vm.required,
})
.unwrap_or_default()
}
}
#[derive(Template)]
#[template(path = "fields/radio.html")]
struct RadioTmpl<'a> {
name: &'a str,
options: &'a [OptionView],
required: bool,
}
pub(crate) struct RadioField;
impl FieldType for RadioField {
fn view_key(&self) -> &'static str {
"radio"
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: SelectOptions =
serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
Ok(ResolvedOptions::new(opts))
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
let current = cx.value.as_text();
let views: Vec<OptionView> = cx
.opts
.get::<SelectOptions>()
.map(|o| {
o.options
.iter()
.map(|so| OptionView {
value: so.value.clone(),
label: so.label.clone().unwrap_or_else(|| so.value.clone()),
selected: so.value == current,
})
.collect()
})
.unwrap_or_default();
FieldVm {
view_key: "radio".to_string(),
name: cx.name.to_string(),
id: cx.id.to_string(),
label: cx.label.to_string(),
required: cx.required,
value: cx.value.clone(),
data: serde_json::to_value(&views).unwrap_or_default(),
}
}
fn render_default(&self, vm: &FieldVm) -> Markup {
let options: Vec<OptionView> = serde_json::from_value(vm.data.clone()).unwrap_or_default();
Markup::from_template(&RadioTmpl {
name: &vm.name,
options: &options,
required: vm.required,
})
.unwrap_or_default()
}
}
fn scalar_vm(view_key: &str, cx: &FieldCx<'_>) -> FieldVm {
FieldVm {
view_key: view_key.to_string(),
name: cx.name.to_string(),
id: cx.id.to_string(),
label: cx.label.to_string(),
required: cx.required,
value: cx.value.clone(),
data: serde_json::Value::Null,
}
}
#[derive(Deserialize)]
struct RefOptions {
source: String,
}
struct RefResolved {
source: String,
}
#[derive(Default, Serialize, Deserialize)]
struct RefData {
source: String,
search_url: String,
resolve_url: String,
value: String,
placeholder: String,
}
#[derive(Template)]
#[template(path = "fields/ref_picker.html")]
struct RefPickerTmpl<'a> {
name: &'a str,
id: &'a str,
value: &'a str,
search_url: &'a str,
resolve_url: &'a str,
placeholder: &'a str,
}
pub(crate) struct RefPickerField {
pickers: Arc<PickerRegistry>,
}
impl RefPickerField {
pub(crate) fn new(pickers: Arc<PickerRegistry>) -> Self {
Self { pickers }
}
}
impl FieldType for RefPickerField {
fn view_key(&self) -> &'static str {
"reference"
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: RefOptions =
serde_json::from_value(raw.clone()).map_err(|e| OptionsError(e.to_string()))?;
if !self.pickers.contains_key(&opts.source) {
return Err(OptionsError(format!(
"unknown picker source `{}`",
opts.source
)));
}
Ok(ResolvedOptions::new(RefResolved {
source: opts.source,
}))
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
let source = cx
.opts
.get::<RefResolved>()
.map(|r| r.source.as_str())
.unwrap_or_default();
let data = RefData {
search_url: format!("{}/pickers/{}/search", cx.base, source),
resolve_url: format!("{}/pickers/{}/resolve", cx.base, source),
source: source.to_string(),
value: cx.value.as_text().to_string(),
placeholder: "Search…".to_string(),
};
FieldVm {
view_key: "reference".to_string(),
name: cx.name.to_string(),
id: cx.id.to_string(),
label: cx.label.to_string(),
required: cx.required,
value: cx.value.clone(),
data: serde_json::to_value(data).unwrap_or_default(),
}
}
fn render_default(&self, vm: &FieldVm) -> Markup {
let data: RefData = serde_json::from_value(vm.data.clone()).unwrap_or_default();
Markup::from_template(&RefPickerTmpl {
name: &vm.name,
id: &vm.id,
value: vm.value.as_text(),
search_url: &data.search_url,
resolve_url: &data.resolve_url,
placeholder: &data.placeholder,
})
.unwrap_or_default()
}
fn assets(&self, _opts: &ResolvedOptions) -> Vec<&'static str> {
vec!["fields/ref-picker.js", "fields/ref-picker.css"]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
FieldCx {
name,
id: name,
label: "Label",
value,
required: true,
opts,
base: "/admin",
}
}
fn text_field() -> TextField {
TextField::new(Arc::new(builtin_input_registry()))
}
struct BadAttrInput;
impl InputType for BadAttrInput {
fn key(&self) -> &'static str {
"bad"
}
fn attributes(&self, _opts: &ResolvedOptions) -> BTreeMap<String, String> {
BTreeMap::from([("class".to_string(), "x".to_string())])
}
}
#[test]
fn text_renders_its_value_escaped() {
let opts = ResolvedOptions::none();
let value = FieldValue::Text("a<b>&\"c".to_string());
let markup = render_field(
&text_field(),
&NoOverrides,
&scope(),
&cx("title", &value, &opts),
);
let html = markup.as_str();
assert!(html.contains(r#"name="title""#));
assert!(html.contains("required"));
assert!(!html.contains("<b>"), "no raw tag injected: {html}");
assert!(
html.contains("<") || html.contains("<"),
"angle escaped: {html}"
);
assert!(
html.contains("&") || html.contains("&"),
"ampersand escaped: {html}"
);
}
#[test]
fn textarea_uses_its_own_control() {
let opts = ResolvedOptions::none();
let value = FieldValue::Text("body".to_string());
let markup = render_field(
&TextareaField,
&NoOverrides,
&scope(),
&cx("body", &value, &opts),
);
assert!(markup.as_str().contains("<textarea"));
}
#[test]
fn no_overrides_uses_the_compiled_default() {
let opts = ResolvedOptions::none();
let value = FieldValue::Text("x".to_string());
let ft = text_field();
let vm = ft.view_model(&cx("t", &value, &opts));
let direct = ft.render_default(&vm);
let routed = render_field(&ft, &NoOverrides, &scope(), &cx("t", &value, &opts));
assert_eq!(direct.as_str(), routed.as_str());
}
#[test]
fn text_input_email_sets_the_type_and_contributes_the_email_rule() {
let field = text_field();
let opts = field
.resolve_options(
&serde_json::json!({ "input": "email" }),
&builtin_registry(),
)
.unwrap();
assert!(matches!(
field.intrinsic_rules(&opts).as_slice(),
[Rule::Email]
));
let value = FieldValue::Text("a@b.test".to_string());
let markup = render_field(&field, &NoOverrides, &scope(), &cx("email", &value, &opts));
assert!(markup.as_str().contains(r#"type="email""#));
}
#[test]
fn text_default_input_is_plain_text_with_no_extra_rule() {
let field = text_field();
let opts = field
.resolve_options(&serde_json::Value::Null, &builtin_registry())
.unwrap();
assert!(field.intrinsic_rules(&opts).is_empty());
let value = FieldValue::Text("hi".to_string());
let markup = render_field(&field, &NoOverrides, &scope(), &cx("name", &value, &opts));
assert!(markup.as_str().contains(r#"type="text""#));
}
#[test]
fn text_number_input_sets_the_type_and_numeric_rule() {
let field = text_field();
let opts = field
.resolve_options(
&serde_json::json!({ "input": "number" }),
&builtin_registry(),
)
.unwrap();
assert!(matches!(
field.intrinsic_rules(&opts).as_slice(),
[Rule::Numeric]
));
let value = FieldValue::Text("42".to_string());
let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
assert!(markup.as_str().contains(r#"type="number""#));
}
#[test]
fn text_tel_input_sets_the_type_with_no_rule() {
let field = text_field();
let opts = field
.resolve_options(&serde_json::json!({ "input": "tel" }), &builtin_registry())
.unwrap();
assert!(field.intrinsic_rules(&opts).is_empty());
let value = FieldValue::Text(String::new());
let markup = render_field(&field, &NoOverrides, &scope(), &cx("phone", &value, &opts));
assert!(markup.as_str().contains(r#"type="tel""#));
}
#[test]
fn text_url_input_sets_the_type_url_rule_and_copy_button() {
let field = text_field();
let opts = field
.resolve_options(&serde_json::json!({ "input": "url" }), &builtin_registry())
.unwrap();
assert!(matches!(
field.intrinsic_rules(&opts).as_slice(),
[Rule::Url]
));
let value = FieldValue::Text("https://example.com".to_string());
let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
let html = markup.as_str();
assert!(html.contains(r#"type="url""#), "{html}");
assert!(html.contains("lat-input-group"), "{html}");
assert!(html.contains(r#"data-lat-widget="copy""#), "{html}");
}
#[test]
fn text_url_input_copy_false_omits_the_button() {
let field = text_field();
let opts = field
.resolve_options(
&serde_json::json!({ "input": "url", "copy": false }),
&builtin_registry(),
)
.unwrap();
let value = FieldValue::Text("https://example.com".to_string());
let markup = render_field(&field, &NoOverrides, &scope(), &cx("site", &value, &opts));
let html = markup.as_str();
assert!(html.contains(r#"type="url""#), "{html}");
assert!(!html.contains("lat-input-group"), "{html}");
assert!(!html.contains("data-lat-widget"), "{html}");
}
#[test]
fn text_number_input_emits_min_max_step_attributes() {
let field = text_field();
let opts = field
.resolve_options(
&serde_json::json!({ "input": "number", "min": 0, "max": 10, "step": 2 }),
&builtin_registry(),
)
.unwrap();
let value = FieldValue::Text("5".to_string());
let markup = render_field(&field, &NoOverrides, &scope(), &cx("qty", &value, &opts));
let html = markup.as_str();
assert!(html.contains(r#"min="0""#), "{html}");
assert!(html.contains(r#"max="10""#), "{html}");
assert!(html.contains(r#"step="2""#), "{html}");
}
#[test]
fn text_input_reserved_attribute_name_aborts_resolve() {
let mut inputs = builtin_input_registry();
inputs.insert("bad".to_string(), Arc::new(BadAttrInput));
let field = TextField::new(Arc::new(inputs));
let Err(e) =
field.resolve_options(&serde_json::json!({ "input": "bad" }), &builtin_registry())
else {
panic!("expected a reserved-attribute rejection");
};
assert!(e.0.contains("class"), "{e}");
}
#[test]
fn attribute_and_widget_name_rules() {
assert!(is_name("min", false));
assert!(is_name("data-x", false));
assert!(!is_name("Min", false));
assert!(!is_name("min.x", false)); assert!(is_name("vendor.copy", true)); assert!(!is_name("", false));
}
#[test]
fn select_renders_options_with_the_current_value_selected() {
let raw = serde_json::json!({
"options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
});
let opts = SelectField
.resolve_options(&raw, &builtin_registry())
.unwrap();
let value = FieldValue::Text("closed".to_string());
let markup = render_field(
&SelectField,
&NoOverrides,
&scope(),
&cx("status", &value, &opts),
);
let html = markup.as_str();
assert!(html.contains(r#"<option value="open">Open</option>"#));
assert!(html.contains(r#"<option value="closed" selected>closed</option>"#));
}
#[test]
fn radio_renders_the_same_options_with_the_current_one_checked() {
let raw = serde_json::json!({
"options": [{"value": "open", "label": "Open"}, {"value": "closed"}]
});
let opts = RadioField
.resolve_options(&raw, &builtin_registry())
.unwrap();
let value = FieldValue::Text("closed".to_string());
let markup = render_field(
&RadioField,
&NoOverrides,
&scope(),
&cx("status", &value, &opts),
);
let html = markup.as_str();
assert!(html.contains(r#"value="open""#));
assert!(
html.contains(r#"value="closed" checked"#),
"current is checked"
);
assert_eq!(html.matches(r#"name="status""#).count(), 2);
assert!(html.contains("closed</label>"));
}
fn scope<'a>() -> OverrideScope<'a> {
OverrideScope {
surface: Surface::Field,
view_key: "text",
resource: None,
field: None,
}
}
fn ref_field() -> RefPickerField {
let mut pickers = crate::picker::PickerRegistry::new();
let source = Arc::new(crate::picker::TableSource::new("places", "id", "name"));
pickers.insert(
"acme.place".to_string(),
crate::picker::PickerSourceReg::new("acme.place", source),
);
RefPickerField::new(Arc::new(pickers))
}
#[test]
fn reference_rejects_an_unknown_source() {
let field = ref_field();
let Err(e) = field.resolve_options(
&serde_json::json!({ "source": "no.such" }),
&builtin_registry(),
) else {
panic!("expected an unknown-source rejection");
};
assert!(e.0.contains("no.such"), "{e}");
}
#[test]
fn reference_renders_a_preserved_hidden_id_and_the_combobox() {
let field = ref_field();
let opts = field
.resolve_options(
&serde_json::json!({ "source": "acme.place" }),
&builtin_registry(),
)
.unwrap();
let value = FieldValue::Text("42".to_string());
let markup = render_field(
&field,
&NoOverrides,
&scope(),
&cx("place_id", &value, &opts),
);
let html = markup.as_str();
assert!(html.contains(r#"type="hidden""#), "{html}");
assert!(html.contains(r#"name="place_id""#), "{html}");
assert!(html.contains(r#"value="42""#), "{html}");
assert!(html.contains(r#"data-lat-widget="ref-picker""#), "{html}");
assert!(html.contains("/admin/pickers/acme.place/search"), "{html}");
assert!(html.contains("/admin/pickers/acme.place/resolve"), "{html}");
}
#[test]
fn reference_declares_its_widget_assets() {
let field = ref_field();
let opts = field
.resolve_options(
&serde_json::json!({ "source": "acme.place" }),
&builtin_registry(),
)
.unwrap();
assert_eq!(
field.assets(&opts),
vec!["fields/ref-picker.js", "fields/ref-picker.css"]
);
}
}
#[cfg(test)]
mod repeater_tests {
use super::*;
fn options() -> ResolvedOptions {
RepeaterField
.resolve_options(
&serde_json::json!({
"fields": [
{ "name": "path", "label": "Path", "type": "text" },
{ "name": "allow", "label": "Allow", "type": "switch" },
]
}),
&builtin_registry(),
)
.unwrap()
}
fn submitted(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn stored(data: &HashMap<String, String>, opts: &ResolvedOptions) -> serde_json::Value {
let field = SubmittedField::new("rules", data);
match RepeaterField.to_attr(&field, opts, Mode::Create).unwrap() {
Some(AttrValue::Json(value)) => value,
other => panic!("expected a JSON array, got {other:?}"),
}
}
#[test]
fn a_row_is_typed_by_its_sub_field_types() {
let rows = stored(
&submitted(&[
("rules[0][path]", "/private"),
("rules[1][path]", "/public"),
("rules[1][allow]", "on"),
]),
&options(),
);
assert_eq!(rows[0]["path"], "/private");
assert_eq!(rows[0]["allow"], serde_json::json!(false));
assert_eq!(rows[1]["allow"], serde_json::json!(true));
}
#[test]
fn rows_are_ordered_by_index_not_submission_order() {
let rows = stored(
&submitted(&[("rules[7][path]", "/last"), ("rules[2][path]", "/first")]),
&options(),
);
assert_eq!(rows.as_array().unwrap().len(), 2);
assert_eq!(rows[0]["path"], "/first");
assert_eq!(rows[1]["path"], "/last");
}
#[test]
fn a_row_left_blank_is_dropped() {
let rows = stored(
&submitted(&[("rules[0][path]", "/kept"), ("rules[1][path]", " ")]),
&options(),
);
assert_eq!(rows.as_array().unwrap().len(), 1);
}
#[test]
fn nothing_submitted_stores_an_empty_list() {
assert_eq!(stored(&submitted(&[]), &options()), serde_json::json!([]));
}
#[test]
fn a_repeater_refuses_to_hold_a_repeater() {
let err = RepeaterField
.resolve_options(
&serde_json::json!({
"fields": [{ "name": "inner", "label": "Inner", "type": "repeater" }]
}),
&builtin_registry(),
)
.err()
.expect("a nested repeater is refused");
assert!(err.0.contains("cannot hold a repeater"), "{}", err.0);
}
#[test]
fn an_unregistered_sub_field_type_is_refused_at_boot() {
let err = RepeaterField
.resolve_options(
&serde_json::json!({
"fields": [{ "name": "x", "label": "X", "type": "acme.nope" }]
}),
&builtin_registry(),
)
.err()
.expect("an unregistered type is refused");
assert!(err.0.contains("acme.nope"), "{}", err.0);
}
#[test]
fn row_counts_are_bounded_when_asked() {
let opts = RepeaterField
.resolve_options(
&serde_json::json!({
"fields": [{ "name": "path", "label": "Path", "type": "text" }],
"min_items": 1,
"max_items": 2
}),
&builtin_registry(),
)
.unwrap();
let empty = HashMap::new();
let too_few = SubmittedField::new("rules", &empty);
assert!(RepeaterField
.to_attr(&too_few, &opts, Mode::Create)
.is_err());
let three = submitted(&[
("rules[0][path]", "/a"),
("rules[1][path]", "/b"),
("rules[2][path]", "/c"),
]);
let field = SubmittedField::new("rules", &three);
assert!(RepeaterField.to_attr(&field, &opts, Mode::Create).is_err());
}
#[test]
fn it_renders_a_row_per_stored_entry_plus_a_blank_to_clone() {
let value = FieldValue::Json(serde_json::json!([
{ "path": "/private", "allow": false },
]));
let cx = FieldCx {
name: "rules",
id: "rules",
label: "Rules",
value: &value,
required: false,
opts: &options(),
base: "/admin",
};
let html = RepeaterField
.render_default(&RepeaterField.view_model(&cx))
.into_string();
assert!(html.contains(r#"name="rules[0][path]""#));
assert!(html.contains(r#"value="/private""#));
assert!(html.contains("lat-repeater__blank"));
assert!(html.contains(r#"name="rules[__index__][path]""#));
}
}
#[cfg(test)]
mod save_contract_tests {
use super::*;
fn one(raw: Option<&str>) -> HashMap<String, String> {
raw.into_iter()
.map(|v| ("f".to_string(), v.to_string()))
.collect()
}
fn none() -> ResolvedOptions {
ResolvedOptions::none()
}
#[test]
fn a_switch_stores_a_bool_and_absence_means_off() {
let f = SwitchField;
assert_eq!(
f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
.unwrap(),
Some(AttrValue::Bool(false))
);
assert_eq!(
f.to_attr(
&SubmittedField::new("f", &one(Some("on"))),
&none(),
Mode::Create
)
.unwrap(),
Some(AttrValue::Bool(true))
);
}
#[test]
fn a_switch_reads_back_whatever_the_backend_gave() {
let f = SwitchField;
for on in ["1", "true", "t", "on"] {
assert_eq!(f.to_control(Some(on), &none()), "1", "{on} reads as on");
}
for off in ["0", "false", "f", ""] {
assert!(f.to_control(Some(off), &none()).is_empty(), "{off} is off");
}
assert!(f.to_control(None, &none()).is_empty());
}
#[test]
fn a_date_shows_only_the_date_part_of_a_stored_instant() {
let f = DateField;
assert_eq!(
f.to_control(Some("2026-09-09T12:30:45.000000Z"), &none()),
"2026-09-09"
);
assert_eq!(
f.to_control(Some("2026-09-09 12:30:45"), &none()),
"2026-09-09"
);
assert_eq!(f.to_control(Some("2026-09-09"), &none()), "2026-09-09");
assert_eq!(f.to_control(None, &none()), "");
}
#[test]
fn a_cleared_date_stores_null_not_an_empty_string() {
let f = DateField;
assert_eq!(
f.to_attr(
&SubmittedField::new("f", &one(Some(""))),
&none(),
Mode::Update
)
.unwrap(),
Some(AttrValue::Null)
);
assert_eq!(
f.to_attr(
&SubmittedField::new("f", &one(Some("2026-09-09"))),
&none(),
Mode::Create
)
.unwrap(),
Some(AttrValue::Text("2026-09-09".into()))
);
assert!(matches!(f.intrinsic_rules(&none())[..], [Rule::Date]));
}
#[test]
fn a_password_hashes_and_never_echoes() {
let f = PasswordField;
let stored = f
.to_attr(
&SubmittedField::new("f", &one(Some("hunter2hunter2"))),
&none(),
Mode::Create,
)
.unwrap()
.unwrap();
let hash = stored.as_str().unwrap();
assert!(
hash.starts_with("$argon2"),
"stored as a hash, not plaintext"
);
assert_ne!(hash, "hunter2hunter2");
assert_eq!(f.to_control(Some(hash), &none()), "");
}
#[test]
fn a_blank_password_on_an_edit_leaves_the_stored_one_alone() {
let f = PasswordField;
assert_eq!(
f.to_attr(
&SubmittedField::new("f", &one(Some(""))),
&none(),
Mode::Update
)
.unwrap(),
None
);
assert_eq!(
f.to_attr(
&SubmittedField::new("f", &one(Some(" "))),
&none(),
Mode::Update
)
.unwrap(),
None
);
assert_eq!(
f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
.unwrap(),
None
);
}
#[test]
fn a_plain_field_stores_and_shows_the_text_unchanged() {
let f = TextareaField;
assert_eq!(
f.to_attr(
&SubmittedField::new("f", &one(Some("hello"))),
&none(),
Mode::Create
)
.unwrap(),
Some(AttrValue::Text("hello".into()))
);
assert_eq!(
f.to_attr(&SubmittedField::new("f", &one(None)), &none(), Mode::Update)
.unwrap(),
None
);
assert_eq!(f.to_control(Some("hello"), &none()), "hello");
}
}