use std::collections::HashMap;
use crate::admin::ui::html_escape;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
Text,
Email,
Number,
DateTime,
Boolean,
Select,
ForeignKey,
TextArea,
}
#[derive(Debug, Clone)]
pub struct FieldConfig {
pub name: String,
pub label: String,
pub field_type: FieldType,
pub required: bool,
pub readonly: bool,
pub placeholder: Option<String>,
pub help: Option<String>,
pub value: Option<String>,
pub options: Vec<(String, String)>,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct FormConfig {
pub title: String,
pub subtitle: String,
pub fields: Vec<FieldConfig>,
pub submitted: bool,
pub save_failed: bool,
pub hidden_fields: Vec<(String, String)>,
}
pub fn infer_field_type(name: &str) -> FieldType {
let lower = name.to_ascii_lowercase();
if lower.ends_with("_id") {
return FieldType::ForeignKey;
}
if lower.starts_with("is_") || lower.starts_with("has_") {
return FieldType::Boolean;
}
if lower.contains("email") {
return FieldType::Email;
}
if lower.contains("amount") || lower.contains("price") {
return FieldType::Number;
}
FieldType::Text
}
fn effective_type(f: &FieldConfig) -> FieldType {
match f.field_type {
FieldType::ForeignKey | FieldType::Boolean => f.field_type,
other if !f.options.is_empty() && other != FieldType::Select => FieldType::Select,
other => other,
}
}
pub fn validate_form(form: &mut FormConfig) {
for field in form.fields.iter_mut() {
let value = field.value.as_deref().unwrap_or("").trim();
if field.required && value.is_empty() {
field.error = Some("This field is required".into());
continue;
}
if value.is_empty() {
continue;
}
match field.field_type {
FieldType::Email if !value.contains('@') => {
field.error = Some("Invalid email address".into());
}
FieldType::Number if value.parse::<f64>().is_err() => {
field.error = Some("Must be a valid number".into());
}
_ => {}
}
}
}
pub fn render_error(msg: &str) -> String {
if msg.is_empty() {
return String::new();
}
format!(r#"<div class="field-error">{}</div>"#, html_escape(msg))
}
pub fn bind_form(form: &mut FormConfig, params: &HashMap<String, String>) {
form.submitted = true;
form.save_failed = false;
for field in form.fields.iter_mut() {
if field.field_type == FieldType::Boolean {
field.value = params
.get(&field.name)
.cloned()
.or(Some("false".to_string()));
} else if let Some(v) = params.get(&field.name) {
field.value = Some(v.clone());
}
field.error = None;
}
}
pub fn render_field(f: &FieldConfig) -> String {
render_field_inner(f, false)
}
fn render_field_inner(f: &FieldConfig, autofocus: bool) -> String {
match effective_type(f) {
FieldType::Boolean => render_boolean_field(f),
FieldType::Text => wrap_field(f, &render_input(f, "text", false, autofocus)),
FieldType::Email => wrap_field(f, &render_input(f, "email", false, autofocus)),
FieldType::Number => wrap_field(f, &render_input(f, "number", true, autofocus)),
FieldType::DateTime => wrap_field(f, &render_input(f, "datetime-local", false, autofocus)),
FieldType::TextArea => wrap_field(f, &render_textarea(f, autofocus)),
FieldType::Select | FieldType::ForeignKey => wrap_field(f, &render_select(f, autofocus)),
}
}
fn wrap_field(f: &FieldConfig, input_html: &str) -> String {
let req = if f.required {
r#"<span class="field-required">*</span>"#
} else {
""
};
let help = match &f.help {
Some(h) if !h.is_empty() => {
format!(r#"<div class="field-help">{}</div>"#, html_escape(h))
}
_ => String::new(),
};
let error = match &f.error {
Some(e) if !e.is_empty() => render_error(e),
_ => String::new(),
};
format!(
r#"<div class="field">
<label class="field-label" for="{id}">{label}{req}</label>
{input}
{help}
{error}
</div>"#,
id = html_escape(&f.name),
label = html_escape(&f.label),
req = req,
input = input_html,
help = help,
error = error,
)
}
fn render_input(f: &FieldConfig, input_type: &str, mono: bool, autofocus: bool) -> String {
let class_attr = match (mono, f.error.is_some()) {
(true, true) => r#" class="mono invalid""#.to_string(),
(true, false) => r#" class="mono""#.to_string(),
(false, true) => r#" class="invalid""#.to_string(),
(false, false) => String::new(),
};
let value_attr = match &f.value {
Some(v) if !v.is_empty() => format!(r#" value="{}""#, html_escape(v)),
_ => String::new(),
};
let placeholder_attr = match &f.placeholder {
Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, html_escape(p)),
_ => String::new(),
};
let required_attr = if f.required { " required" } else { "" };
let readonly_attr = if f.readonly { " readonly" } else { "" };
let autofocus_attr = if autofocus { " autofocus" } else { "" };
format!(
r#"<input type="{ty}" id="{id}" name="{name}"{cls}{val}{ph}{req}{ro}{af}>"#,
ty = input_type,
id = html_escape(&f.name),
name = html_escape(&f.name),
cls = class_attr,
val = value_attr,
ph = placeholder_attr,
req = required_attr,
ro = readonly_attr,
af = autofocus_attr,
)
}
fn render_textarea(f: &FieldConfig, autofocus: bool) -> String {
let value = f.value.as_deref().unwrap_or("");
let class_attr = if f.error.is_some() {
r#" class="mono invalid""#
} else {
r#" class="mono""#
};
let placeholder_attr = match &f.placeholder {
Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, html_escape(p)),
_ => String::new(),
};
let required_attr = if f.required { " required" } else { "" };
let readonly_attr = if f.readonly { " readonly" } else { "" };
let autofocus_attr = if autofocus { " autofocus" } else { "" };
format!(
r#"<textarea id="{id}" name="{name}"{cls}{ph}{req}{ro}{af}>{value}</textarea>"#,
id = html_escape(&f.name),
name = html_escape(&f.name),
cls = class_attr,
ph = placeholder_attr,
req = required_attr,
ro = readonly_attr,
af = autofocus_attr,
value = html_escape(value),
)
}
fn render_select(f: &FieldConfig, autofocus: bool) -> String {
let selected_value = f.value.as_deref().unwrap_or("");
let class_attr = if f.error.is_some() {
r#" class="invalid""#
} else {
""
};
let required_attr = if f.required { " required" } else { "" };
let disabled_attr = if f.readonly { " disabled" } else { "" };
let autofocus_attr = if autofocus { " autofocus" } else { "" };
let mut s = format!(
r#"<select id="{id}" name="{name}"{cls}{req}{dis}{af}>"#,
id = html_escape(&f.name),
name = html_escape(&f.name),
cls = class_attr,
req = required_attr,
dis = disabled_attr,
af = autofocus_attr,
);
for (value, label) in &f.options {
let selected = if value == selected_value {
" selected"
} else {
""
};
s.push_str(&format!(
r#"<option value="{}"{}>{}</option>"#,
html_escape(value),
selected,
html_escape(label),
));
}
s.push_str("</select>");
s
}
fn render_boolean_field(f: &FieldConfig) -> String {
let on = matches!(f.value.as_deref(), Some("1" | "true" | "on" | "yes"));
let on_cls = if on { " on" } else { "" };
let checked_attr = if on { " checked" } else { "" };
let help = match &f.help {
Some(h) if !h.is_empty() => {
format!(r#"<div class="field-help">{}</div>"#, html_escape(h))
}
_ => String::new(),
};
let error = match &f.error {
Some(e) if !e.is_empty() => render_error(e),
_ => String::new(),
};
format!(
r#"<div class="field">
<label class="switch{cls}">
<input type="hidden" name="{name}" value="false">
<input type="checkbox" name="{name}" value="true" hidden{checked}>
<span class="switch-track"></span>
<span class="switch-label">{label}</span>
</label>
{help}
{error}
</div>"#,
cls = on_cls,
name = html_escape(&f.name),
checked = checked_attr,
label = html_escape(&f.label),
help = help,
error = error,
)
}
pub fn render_form(form: &FormConfig) -> String {
let first_invalid = form.fields.iter().position(|f| f.error.is_some());
let has_errors = first_invalid.is_some();
let summary = render_form_banner(form, has_errors);
let mut body = String::new();
body.push_str(&summary);
for (i, field) in form.fields.iter().enumerate() {
let autofocus = Some(i) == first_invalid;
body.push_str(&render_field_inner(field, autofocus));
}
let mut hidden = String::new();
for (k, v) in &form.hidden_fields {
hidden.push_str(&format!(
r#"<input type="hidden" name="{}" value="{}">"#,
html_escape(k),
html_escape(v),
));
}
let save_disabled_attr = if has_errors { " disabled" } else { "" };
format!(
r#"<a class="drawer-backdrop open" href="?" aria-label="Close drawer"></a>
<form data-admin-form class="drawer open" action="" method="post">
{hidden}
<header class="drawer-header">
<div class="drawer-title-group">
<h2 class="drawer-title">{title}</h2>
<div class="drawer-subtitle">{subtitle}</div>
</div>
<a class="drawer-close" href="?" aria-label="Close">×</a>
</header>
<div class="drawer-body">
{body}
</div>
<footer class="drawer-footer">
<a class="btn btn-ghost" href="?">Cancel</a>
<button type="submit" class="btn btn-primary"{save_disabled}>Save changes</button>
</footer>
</form>"#,
title = html_escape(&form.title),
subtitle = html_escape(&form.subtitle),
body = body,
hidden = hidden,
save_disabled = save_disabled_attr,
)
}
fn render_form_banner(form: &FormConfig, has_errors: bool) -> String {
if has_errors {
return String::from(
r#"<div class="form-banner form-banner-error" role="alert">Please fix the errors below.</div>"#,
);
}
if form.save_failed {
return String::from(
r#"<div class="form-banner form-banner-error" role="alert">Could not save the record.</div>"#,
);
}
if form.submitted {
return String::from(
r#"<div class="form-banner form-banner-success" role="status">Changes saved.</div>"#,
);
}
String::new()
}