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, Translator};
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,
pub i18n: &'a Translator,
}
#[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 option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
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 struct FieldTypeReg {
pub field_type: Arc<dyn FieldType>,
}
impl FieldTypeReg {
pub fn new(field_type: Arc<dyn FieldType>) -> Self {
Self { field_type }
}
pub fn name(&self) -> &'static str {
self.field_type.view_key()
}
}
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>,
display: RepeaterDisplay,
summary_field: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RepeaterDisplay {
#[default]
Inline,
List,
}
pub(crate) struct RepeaterSub {
field: crate::form::FormField,
field_type: Arc<dyn FieldType>,
opts: ResolvedOptions,
}
#[derive(Deserialize)]
struct RepeaterOptionsRaw {
#[serde(deserialize_with = "named_or_listed")]
fields: Vec<crate::form::FormField>,
#[serde(default)]
min_items: usize,
#[serde(default)]
max_items: Option<usize>,
#[serde(default)]
display: RepeaterDisplay,
#[serde(default)]
summary_field: Option<String>,
}
pub(crate) fn check_option_keys(
raw: &serde_json::Value,
accepted: &[String],
) -> Result<(), OptionsError> {
let serde_json::Value::Object(entries) = raw else {
return Ok(());
};
for key in entries.keys() {
if !accepted.iter().any(|k| k == key) {
return Err(OptionsError(if accepted.is_empty() {
format!("unknown key `{key}`: this type takes no keys of its own")
} else {
format!(
"unknown key `{key}`, expected one of: {}",
accepted.join(", ")
)
}));
}
}
Ok(())
}
pub(crate) fn check_field_keys(
field: &crate::form::FormField,
field_type: &dyn FieldType,
) -> Result<(), OptionsError> {
match field_type.option_keys(&field.options) {
Some(keys) => check_option_keys(&field.options, &keys),
None => Ok(()),
}
}
fn keys(names: &[&str]) -> Option<Vec<String>> {
Some(names.iter().map(|k| k.to_string()).collect())
}
fn named_or_listed<'de, D>(de: D) -> Result<Vec<crate::form::FormField>, D::Error>
where
D: serde::Deserializer<'de>,
{
use crate::keyed::Keyed;
#[derive(Deserialize)]
#[serde(untagged)]
enum Shape {
Listed(Vec<crate::form::FormField>),
Named(serde_json::Map<String, serde_json::Value>),
}
match Shape::deserialize(de)? {
Shape::Listed(fields) => Ok(fields),
Shape::Named(entries) => entries
.into_iter()
.map(|(name, entry)| {
let mut field: crate::form::FormField =
serde_json::from_value(entry).map_err(serde::de::Error::custom)?;
field.set_key(name);
Ok(field)
})
.collect(),
}
}
pub(crate) struct RepeaterField;
impl RepeaterField {
const BLANK: &'static str = "__index__";
}
impl FieldType for RepeaterField {
fn view_key(&self) -> &'static str {
"repeater"
}
fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&[
"fields",
"min_items",
"max_items",
"display",
"summary_field",
])
}
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)))?;
check_field_keys(&field, field_type.as_ref())
.map_err(|e| OptionsError(format!("field `{}`: {}", field.name, e.0)))?;
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,
display: parsed.display,
summary_field: parsed.summary_field,
}))
}
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 summary_index = opts
.and_then(|o| {
o.summary_field
.as_ref()
.and_then(|name| o.rows.iter().position(|sub| &sub.field.name == name))
})
.unwrap_or(0);
let summaries = stored
.iter()
.map(|row| {
opts.and_then(|o| o.rows.get(summary_index))
.and_then(|sub| row.get(&sub.field.name))
.map(summary_text)
.unwrap_or_default()
})
.collect();
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(),
list: opts.is_some_and(|o| o.display == RepeaterDisplay::List),
summary_index,
summaries,
};
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 summary_text(value: &serde_json::Value) -> String {
let raw = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
};
raw.lines().next().unwrap_or_default().trim().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,
i18n: cx.i18n,
};
let vm = sub.field_type.view_model(&sub_cx);
RepeaterCell {
label: cx.i18n.t(&sub.field.label),
control: sub.field_type.render_default(&vm).into_string(),
help: sub
.field
.help
.as_ref()
.map(|h| cx.i18n.t(h))
.unwrap_or_default(),
}
})
.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,
help: String,
}
#[derive(Serialize, Deserialize, Default)]
struct RepeaterData {
rows: Vec<Vec<RepeaterCell>>,
blank: Vec<RepeaterCell>,
list: bool,
summary_index: usize,
summaries: Vec<String>,
}
#[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 option_keys(&self) -> Vec<&'static str> {
Vec::new()
}
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 option_keys(&self) -> Vec<&'static str> {
vec!["min", "max", "step"]
}
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 option_keys(&self) -> Vec<&'static str> {
vec!["copy"]
}
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,
#[serde(default)]
placeholder: Option<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 option_keys(&self, raw: &serde_json::Value) -> Option<Vec<String>> {
let input = raw.get("input").and_then(|v| v.as_str()).unwrap_or("text");
let input = self.inputs.get(input)?;
let mut all = vec!["input".to_string(), "placeholder".to_string()];
all.extend(input.option_keys().into_iter().map(str::to_string));
Some(all)
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: TextOptions = if raw.is_null() {
TextOptions {
input: default_input(),
placeholder: None,
}
} 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 mut attrs = input.attributes(&input_opts);
let adornments = input.adornments(&input_opts);
validate_contributions(&attrs, &adornments)?;
if let Some(placeholder) = opts.placeholder {
attrs.insert("placeholder".to_string(), placeholder);
}
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,
rows: Option<u16>,
placeholder: Option<&'a str>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct TextareaOptions {
#[serde(default)]
rows: Option<u16>,
#[serde(default)]
placeholder: Option<String>,
}
pub(crate) struct TextareaField;
impl FieldType for TextareaField {
fn view_key(&self) -> &'static str {
"textarea"
}
fn option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&["rows", "placeholder"])
}
fn resolve_options(
&self,
raw: &serde_json::Value,
_types: &FieldRegistry,
) -> Result<ResolvedOptions, OptionsError> {
let opts: TextareaOptions = if raw.is_null() {
TextareaOptions::default()
} else {
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 mut vm = scalar_vm("textarea", cx);
if let Some(opts) = cx.opts.get::<TextareaOptions>() {
vm.data = serde_json::to_value(opts).unwrap_or_default();
}
vm
}
fn render_default(&self, vm: &FieldVm) -> Markup {
let opts: TextareaOptions = serde_json::from_value(vm.data.clone()).unwrap_or_default();
Markup::from_template(&TextareaTmpl {
name: &vm.name,
id: &vm.id,
value: vm.value.as_text(),
required: vm.required,
rows: opts.rows,
placeholder: opts.placeholder.as_deref(),
})
.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 option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&["options"])
}
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 option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&[])
}
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 option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&[])
}
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 option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&[])
}
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 option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&["options"])
}
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 option_keys(&self, _raw: &serde_json::Value) -> Option<Vec<String>> {
keys(&["source"])
}
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 untranslated() -> Translator {
Translator::new("en")
}
fn cx_with<'a>(
name: &'a str,
value: &'a FieldValue,
opts: &'a ResolvedOptions,
i18n: &'a Translator,
) -> FieldCx<'a> {
FieldCx {
name,
id: name,
label: "Label",
value,
required: true,
opts,
base: "/admin",
i18n,
}
}
fn cx<'a>(name: &'a str, value: &'a FieldValue, opts: &'a ResolvedOptions) -> FieldCx<'a> {
cx_with(name, value, opts, Box::leak(Box::new(untranslated())))
}
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",
i18n: &Translator::new("en"),
};
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]""#));
}
fn list_options() -> ResolvedOptions {
RepeaterField
.resolve_options(
&serde_json::json!({
"fields": [
{ "name": "agent", "label": "Agent", "type": "text" },
{ "name": "disallow", "label": "Disallow", "type": "textarea" },
],
"display": "list",
"summary_field": "agent",
}),
&builtin_registry(),
)
.unwrap()
}
fn render_with(opts: &ResolvedOptions, value: &FieldValue) -> String {
let cx = FieldCx {
name: "rules",
id: "rules",
label: "Rules",
value,
required: false,
opts,
base: "/admin",
i18n: &Translator::new("en"),
};
RepeaterField
.render_default(&RepeaterField.view_model(&cx))
.into_string()
}
#[test]
fn a_list_repeater_collapses_its_rows_and_names_them() {
let value = FieldValue::Json(serde_json::json!([
{ "agent": "Googlebot", "disallow": "/private\n/tmp" },
]));
let html = render_with(&list_options(), &value);
assert!(html.contains("lat-repeater--list"), "list mode is declared");
assert!(html.contains("<details"), "a row collapses");
assert!(
html.contains("Googlebot"),
"the naming field titles the row"
);
assert!(html.contains(r#"name="rules[0][disallow]""#));
}
#[test]
fn a_row_with_no_name_yet_reads_as_untitled() {
let value = FieldValue::Json(serde_json::json!([{ "agent": "" }]));
let html = render_with(&list_options(), &value);
assert!(html.contains("lat-repeater__untitled"));
}
#[test]
fn the_default_layout_stays_inline() {
let value = FieldValue::Json(serde_json::json!([{ "path": "/private" }]));
let html = render_with(&options(), &value);
assert!(!html.contains("lat-repeater--list"));
assert!(!html.contains("<details"));
}
#[test]
fn a_multi_line_value_titles_a_row_with_its_first_line_only() {
assert_eq!(
summary_text(&serde_json::json!("/private\n/tmp\n/secret")),
"/private"
);
assert_eq!(summary_text(&serde_json::json!("")), "");
}
#[test]
fn a_contributed_field_type_names_itself_and_converts() {
struct Money;
impl FieldType for Money {
fn view_key(&self) -> &'static str {
"acme.money"
}
fn view_model(&self, cx: &FieldCx<'_>) -> FieldVm {
scalar_vm("acme.money", cx)
}
fn render_default(&self, _vm: &FieldVm) -> Markup {
Markup::empty()
}
fn to_attr(
&self,
field: &SubmittedField<'_>,
_opts: &ResolvedOptions,
_mode: Mode,
) -> Result<Option<AttrValue>, String> {
let raw = field.value().unwrap_or("0");
let minor = (raw.parse::<f64>().map_err(|_| "not an amount")? * 100.0).round();
Ok(Some(AttrValue::Int(minor as i64)))
}
}
let reg = FieldTypeReg::new(Arc::new(Money));
assert_eq!(reg.name(), "acme.money");
let mut registry = builtin_registry();
let name = reg.name().to_string();
assert!(
registry.insert(name.clone(), reg.field_type).is_none(),
"a dotted key does not collide with a built-in"
);
let data = std::collections::HashMap::from([("price".to_string(), "12.50".to_string())]);
let submitted = SubmittedField::new("price", &data);
let converted = registry[&name]
.to_attr(&submitted, &ResolvedOptions::default(), Mode::Create)
.unwrap();
assert!(
matches!(converted, Some(AttrValue::Int(1250))),
"{converted:?}"
);
}
#[test]
fn a_sub_field_label_is_localized_like_any_other() {
use laterite_core::i18n::CatalogStore;
use std::sync::Arc;
let po = concat!(
"msgid \"\"\n",
"msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n",
"\n",
"msgid \"Path\"\n",
"msgstr \"Path-kn\"\n",
);
let store = Arc::new(CatalogStore::builder().po("kn", po).unwrap().build());
let i18n = Translator::with_chain(vec!["kn".into(), "en".into()], store);
let value = FieldValue::Text(String::new());
let cx = FieldCx {
name: "rules",
id: "rules",
label: "Rules",
value: &value,
required: false,
opts: &options(),
base: "/admin",
i18n: &i18n,
};
let html = RepeaterField
.render_default(&RepeaterField.view_model(&cx))
.into_string();
assert!(
html.contains("Path-kn"),
"sub-label was not localized: {html}"
);
assert!(
!html.contains(">Path<"),
"the source string still leaked: {html}"
);
}
}
#[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");
}
}