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 trait FieldType: Send + Sync + 'static {
fn view_key(&self) -> &'static str;
fn resolve_options(&self, _raw: &serde_json::Value) -> 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,
raw: Option<&str>,
opts: &ResolvedOptions,
mode: Mode,
) -> Result<Option<AttrValue>, String> {
let _ = (opts, mode);
Ok(raw.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) 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(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) -> 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) -> 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) -> 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) -> 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)?;
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) -> 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,
raw: Option<&str>,
_opts: &ResolvedOptions,
_mode: Mode,
) -> Result<Option<AttrValue>, String> {
Ok(Some(AttrValue::Bool(stored_is_on(raw))))
}
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,
raw: Option<&str>,
_opts: &ResolvedOptions,
_mode: Mode,
) -> Result<Option<AttrValue>, String> {
Ok(raw.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,
raw: Option<&str>,
_opts: &ResolvedOptions,
_mode: Mode,
) -> Result<Option<AttrValue>, String> {
match raw.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) -> 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) -> 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" }))
.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).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" }))
.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" }))
.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" }))
.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 }))
.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 }),
)
.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" })) 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).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).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" })) 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" }))
.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" }))
.unwrap();
assert_eq!(
field.assets(&opts),
vec!["fields/ref-picker.js", "fields/ref-picker.css"]
);
}
}
#[cfg(test)]
mod save_contract_tests {
use super::*;
fn none() -> ResolvedOptions {
ResolvedOptions::none()
}
#[test]
fn a_switch_stores_a_bool_and_absence_means_off() {
let f = SwitchField;
assert_eq!(
f.to_attr(None, &none(), Mode::Update).unwrap(),
Some(AttrValue::Bool(false))
);
assert_eq!(
f.to_attr(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(Some(""), &none(), Mode::Update).unwrap(),
Some(AttrValue::Null)
);
assert_eq!(
f.to_attr(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(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(Some(""), &none(), Mode::Update).unwrap(), None);
assert_eq!(f.to_attr(Some(" "), &none(), Mode::Update).unwrap(), None);
assert_eq!(f.to_attr(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(Some("hello"), &none(), Mode::Create).unwrap(),
Some(AttrValue::Text("hello".into()))
);
assert_eq!(f.to_attr(None, &none(), Mode::Update).unwrap(), None);
assert_eq!(f.to_control(Some("hello"), &none()), "hello");
}
}