use std::collections::HashMap;
use std::sync::Arc;
use askama::Template;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use laterite_core::query::{bind_values, build as to_sql, text_cast};
use laterite_core::validation::{validate, FieldRules, Mode, Rule};
use laterite_core::{
t, Actor, AnyRowExt, Db, ErrorBag, ModelListener, ModelListenerReg, Op, Record, Text,
};
use sea_query::{Alias, Expr, Query};
use serde::{Deserialize, Serialize};
use crate::field::{render_field, FieldCx, FieldValue, OverrideScope, ResolvedOptions, Surface};
use crate::persist::{self, DefaultPersister, Persister, PersisterRegistry, SaveError};
use crate::sql::valid_ident;
use crate::{not_found, render, render_error, AdminState};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FormField {
pub name: String,
pub label: Text,
pub field_type: String,
pub options: serde_json::Value,
pub rules: Vec<Rule>,
pub translatable: bool,
pub help: Option<Text>,
pub enter: FieldEnter,
pub span: Span,
pub break_row: bool,
pub preset: Option<Preset>,
pub hidden: bool,
pub trigger: Option<Trigger>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PresetShape {
Exact,
#[default]
Slug,
Url,
File,
}
impl PresetShape {
pub fn as_str(self) -> &'static str {
match self {
PresetShape::Exact => "exact",
PresetShape::Slug => "slug",
PresetShape::Url => "url",
PresetShape::File => "file",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Preset {
pub field: String,
pub shape: PresetShape,
}
impl Preset {
pub fn new(field: impl Into<String>, shape: PresetShape) -> Self {
Self {
field: field.into(),
shape,
}
}
}
impl<'de> Deserialize<'de> for Preset {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Full {
field: String,
#[serde(rename = "type", default)]
shape: PresetShape,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Field(String),
Full(Full),
}
Ok(match Raw::deserialize(de)? {
Raw::Field(field) => Preset::new(field, PresetShape::Slug),
Raw::Full(full) => Preset::new(full.field, full.shape),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trigger {
pub action: String,
pub field: String,
pub condition: String,
}
impl Trigger {
pub fn new(
action: impl Into<String>,
field: impl Into<String>,
condition: impl Into<String>,
) -> Self {
Self {
action: action.into(),
field: field.into(),
condition: condition.into(),
}
}
pub fn check(&self) -> Result<(), String> {
for action in self.action.split('|') {
let known = matches!(action, "show" | "hide" | "enable" | "disable" | "empty")
|| (action.starts_with("fill[") && action.ends_with(']'));
if !known {
return Err(format!(
"unknown trigger action `{action}`: show, hide, enable, disable, empty or fill[value]"
));
}
}
let known = matches!(self.condition.as_str(), "checked" | "unchecked")
|| (self.condition.starts_with("value[") && self.condition.ends_with(']'));
if !known {
return Err(format!(
"unknown trigger condition `{}`: checked, unchecked or value[..]",
self.condition
));
}
Ok(())
}
pub fn watched(&self) -> &str {
self.field.trim_end_matches("[]")
}
}
pub(crate) fn behaviour_attrs(field: &FormField) -> String {
fn escaped(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
let mut attrs = String::new();
if let Some(preset) = &field.preset {
attrs.push_str(&format!(
r#" data-lat-preset="{}" data-lat-preset-type="{}""#,
escaped(&preset.field),
preset.shape.as_str()
));
}
if let Some(trigger) = &field.trigger {
attrs.push_str(&format!(
r#" data-lat-trigger-action="{}" data-lat-trigger-field="{}" data-lat-trigger-condition="{}""#,
escaped(&trigger.action),
escaped(&trigger.field),
escaped(&trigger.condition)
));
}
attrs
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span(u8);
impl Span {
pub const FULL: Span = Span(12);
pub fn columns(columns: u8) -> Self {
Span(columns.clamp(1, 12))
}
pub fn width(self) -> u8 {
self.0
}
pub fn parse(text: &str) -> Result<Self, String> {
let columns = match text.trim() {
"full" | "12/12" => 12,
"1/2" | "half" | "left" | "right" | "auto" => 6,
"1/3" | "third" => 4,
"2/3" => 8,
"1/4" | "quarter" => 3,
"3/4" => 9,
other => match other.parse::<u8>() {
Ok(n) if (1..=12).contains(&n) => n,
_ => {
return Err(format!(
"unknown span `{other}`, expected full, 1/2, 1/3, 2/3, 1/4, 3/4 or 1 to 12"
))
}
},
};
Ok(Span(columns))
}
fn class(self) -> String {
if self.0 == 12 {
String::new()
} else {
format!(" lat-field--{}", self.0)
}
}
}
impl Default for Span {
fn default() -> Self {
Span::FULL
}
}
impl Serialize for Span {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self.0 {
12 => serializer.serialize_str("full"),
6 => serializer.serialize_str("1/2"),
4 => serializer.serialize_str("1/3"),
8 => serializer.serialize_str("2/3"),
3 => serializer.serialize_str("1/4"),
9 => serializer.serialize_str("3/4"),
n => serializer.serialize_u8(n),
}
}
}
impl<'de> Deserialize<'de> for Span {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Columns(u8),
Text(String),
}
match Raw::deserialize(deserializer)? {
Raw::Columns(n) if (1..=12).contains(&n) => Ok(Span(n)),
Raw::Columns(n) => Err(serde::de::Error::custom(format!(
"unknown span `{n}`, expected 1 to 12"
))),
Raw::Text(text) => Span::parse(&text).map_err(serde::de::Error::custom),
}
}
}
pub(crate) fn layout_classes(span: Span, break_row: bool) -> String {
let mut classes = span.class();
if break_row {
classes.push_str(" lat-field--break");
}
classes
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EnterPolicy {
#[default]
Submit,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FormFocus {
#[default]
Auto,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FieldEnter {
#[default]
Submit,
Off,
Next,
}
impl FieldEnter {
fn attribute(self) -> &'static str {
match self {
FieldEnter::Submit => "",
FieldEnter::Off => "off",
FieldEnter::Next => "next",
}
}
}
impl Default for FormConfig {
fn default() -> Self {
Self {
entity: String::new(),
title: Text::new(""),
base_path: String::new(),
id_field: "id".to_string(),
fields: Vec::new(),
enter: EnterPolicy::default(),
confirm_leave: true,
focus: FormFocus::default(),
persist: None,
timestamps: false,
done_path: None,
}
}
}
impl Default for FormField {
fn default() -> Self {
Self::of("", Text::new(""), "text")
}
}
impl<'de> Deserialize<'de> for FormField {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct Entry;
impl<'de> serde::de::Visitor<'de> for Entry {
type Value = FormField;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a field: its own keys and its type's")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> Result<FormField, A::Error> {
let mut field = FormField::default();
let theirs = crate::keyed::split_entry(map, |key, map| {
match key {
"name" => field.name = map.next_value()?,
"label" => field.label = map.next_value()?,
"type" => field.field_type = map.next_value()?,
"rules" => field.rules = map.next_value()?,
"translatable" => field.translatable = map.next_value()?,
"help" => field.help = map.next_value()?,
"enter" => field.enter = map.next_value()?,
"span" => field.span = map.next_value()?,
"break" => field.break_row = map.next_value()?,
"preset" => field.preset = map.next_value()?,
"trigger" => field.trigger = map.next_value()?,
"hidden" => field.hidden = map.next_value()?,
_ => return Ok(false),
}
Ok(true)
})?;
field.options = theirs;
if let Some(trigger) = &field.trigger {
trigger.check().map_err(serde::de::Error::custom)?;
}
Ok(field)
}
fn visit_unit<E: serde::de::Error>(self) -> Result<FormField, E> {
Ok(FormField::default())
}
}
de.deserialize_map(Entry)
}
}
impl Serialize for FormField {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut map = ser.serialize_map(None)?;
map.serialize_entry("name", &self.name)?;
map.serialize_entry("label", &self.label)?;
map.serialize_entry("type", &self.field_type)?;
map.serialize_entry("rules", &self.rules)?;
map.serialize_entry("translatable", &self.translatable)?;
map.serialize_entry("help", &self.help)?;
map.serialize_entry("enter", &self.enter)?;
map.serialize_entry("span", &self.span)?;
map.serialize_entry("break", &self.break_row)?;
if let Some(preset) = &self.preset {
map.serialize_entry("preset", preset)?;
}
if self.hidden {
map.serialize_entry("hidden", &true)?;
}
if let Some(trigger) = &self.trigger {
map.serialize_entry("trigger", trigger)?;
}
crate::keyed::serialize_theirs(&mut map, &self.options)?;
map.end()
}
}
impl crate::keyed::Keyed for FormField {
fn key(&self) -> &str {
&self.name
}
fn set_key(&mut self, key: String) {
self.name = key;
}
}
impl FormField {
pub fn of(name: &str, label: impl Into<Text>, field_type: &str) -> Self {
Self {
name: name.to_string(),
label: label.into(),
field_type: field_type.to_string(),
options: serde_json::Value::Null,
rules: Vec::new(),
translatable: false,
help: None,
enter: FieldEnter::default(),
span: Span::default(),
break_row: false,
preset: None,
trigger: None,
hidden: false,
}
}
pub fn preset(mut self, field: &str, shape: PresetShape) -> Self {
self.preset = Some(Preset::new(field, shape));
self
}
pub fn trigger(mut self, trigger: Trigger) -> Self {
self.trigger = Some(trigger);
self
}
pub fn text(name: &str, label: impl Into<Text>) -> Self {
Self::of(name, label, "text")
}
pub fn textarea(name: &str, label: impl Into<Text>) -> Self {
Self::of(name, label, "textarea")
}
pub fn reference(name: &str, label: impl Into<Text>, source: &str) -> Self {
Self::of(name, label, "reference").options(serde_json::json!({ "source": source }))
}
pub fn select(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
Self::of(name, label, "select").options(Self::choices(options))
}
pub fn radio(name: &str, label: impl Into<Text>, options: Vec<(&str, &str)>) -> Self {
Self::of(name, label, "radio").options(Self::choices(options))
}
pub fn switch(name: &str, label: impl Into<Text>) -> Self {
Self::of(name, label, "switch")
}
pub fn date(name: &str, label: impl Into<Text>) -> Self {
Self::of(name, label, "date")
}
pub fn password(name: &str, label: impl Into<Text>) -> Self {
Self::of(name, label, "password")
}
pub fn repeater(name: &str, label: impl Into<Text>, fields: Vec<FormField>) -> Self {
Self::of(name, label, "repeater").options(serde_json::json!({ "fields": fields }))
}
pub fn repeater_list(
name: &str,
label: impl Into<Text>,
fields: Vec<FormField>,
summary_field: Option<&str>,
) -> Self {
Self::of(name, label, "repeater").options(serde_json::json!({
"fields": fields,
"display": "list",
"summary_field": summary_field,
}))
}
fn choices(options: Vec<(&str, &str)>) -> serde_json::Value {
let options: Vec<serde_json::Value> = options
.into_iter()
.map(|(value, label)| serde_json::json!({ "value": value, "label": label }))
.collect();
serde_json::json!({ "options": options })
}
pub fn options(mut self, options: serde_json::Value) -> Self {
self.options = options;
self
}
pub fn required(mut self) -> Self {
self.rules.push(Rule::Required);
self
}
pub fn required_on(mut self, mode: Mode) -> Self {
self.rules.push(Rule::RequiredOn(mode));
self
}
pub fn unique(mut self) -> Self {
self.rules.push(Rule::Unique);
self
}
pub fn max_length(mut self, n: usize) -> Self {
self.rules.push(Rule::MaxLength(n));
self
}
pub fn min_length(mut self, n: usize) -> Self {
self.rules.push(Rule::MinLength(n));
self
}
pub fn email(mut self) -> Self {
self.rules.push(Rule::Email);
self
}
pub fn translatable(mut self) -> Self {
self.translatable = true;
self
}
pub fn help(mut self, text: impl Into<Text>) -> Self {
self.help = Some(text.into());
self
}
pub fn span(mut self, span: Span) -> Self {
self.span = span;
self
}
pub fn break_row(mut self) -> Self {
self.break_row = true;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
#[non_exhaustive]
pub struct FormConfig {
pub entity: String,
pub title: Text,
pub base_path: String,
pub id_field: String,
#[serde(
deserialize_with = "crate::keyed::deserialize",
serialize_with = "crate::keyed::serialize"
)]
pub fields: Vec<FormField>,
#[serde(default)]
pub enter: EnterPolicy,
pub confirm_leave: bool,
pub focus: FormFocus,
pub persist: Option<String>,
#[serde(default)]
pub timestamps: bool,
#[serde(skip)]
pub(crate) done_path: Option<String>,
}
impl FormConfig {
pub fn new(
entity: impl Into<String>,
title: impl Into<Text>,
base_path: impl Into<String>,
id_field: impl Into<String>,
fields: Vec<FormField>,
) -> Self {
Self {
enter: Default::default(),
confirm_leave: true,
focus: FormFocus::default(),
entity: entity.into(),
title: title.into(),
base_path: base_path.into(),
id_field: id_field.into(),
fields,
persist: None,
timestamps: false,
done_path: None,
}
}
pub fn persist(mut self, name: impl Into<String>) -> Self {
self.persist = Some(name.into());
self
}
pub fn confirm_leave(mut self, ask: bool) -> Self {
self.confirm_leave = ask;
self
}
pub fn focus(mut self, focus: FormFocus) -> Self {
self.focus = focus;
self
}
pub fn timestamps(mut self) -> Self {
self.timestamps = true;
self
}
fn idents_valid(&self) -> bool {
valid_ident(&self.entity)
&& valid_ident(&self.id_field)
&& self.fields.iter().all(|f| valid_ident(&f.name))
}
}
#[derive(Clone)]
pub(crate) struct PreparedForm {
pub(crate) config: FormConfig,
fields: Vec<PreparedField>,
persister: Arc<dyn Persister>,
listeners: Vec<Arc<dyn ModelListener>>,
pub(crate) stay: bool,
}
#[derive(Clone)]
struct PreparedField {
field_type: Arc<dyn crate::field::FieldType>,
opts: ResolvedOptions,
rules: Vec<Rule>,
}
impl PreparedForm {
pub(crate) fn persister(&self) -> Arc<dyn Persister> {
self.persister.clone()
}
pub(crate) fn has_field(&self, field: &str) -> bool {
self.config.fields.iter().any(|f| f.name == field)
}
pub(crate) fn field_assets(&self, field: &str) -> Vec<&'static str> {
self.config
.fields
.iter()
.zip(&self.fields)
.find(|(f, _)| f.name == field)
.map(|(_, pf)| pf.field_type.assets(&pf.opts))
.unwrap_or_default()
}
pub(crate) fn control(
&self,
state: &AdminState,
field: &str,
dom_id: &str,
value: &str,
shell: &crate::Shell,
) -> Option<String> {
let (f, pf) = self
.config
.fields
.iter()
.zip(&self.fields)
.find(|(f, _)| f.name == field)?;
let value = FieldValue::Text(value.to_string());
let label = shell.tt(&f.label);
let cx = FieldCx {
name: &f.name,
id: dom_id,
label: &label,
value: &value,
required: has_required(&pf.rules),
max_length: max_length(&pf.rules),
opts: &pf.opts,
base: &shell.base,
i18n: shell.i18n(),
};
let ft = state.field_types.get(&f.field_type)?;
let scope = OverrideScope {
surface: Surface::Field,
view_key: &f.field_type,
resource: Some(&self.config.base_path),
field: Some(&f.name),
};
Some(render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx).into_string())
}
pub(crate) fn prepare(
config: FormConfig,
field_types: &crate::field::FieldRegistry,
persisters: &PersisterRegistry,
listeners: &[ModelListenerReg],
) -> Result<Self, String> {
let mut fields = Vec::with_capacity(config.fields.len());
for f in &config.fields {
let names = || config.fields.iter().map(|other| other.name.as_str());
if let Some(preset) = &f.preset {
if !names().any(|n| n == preset.field) {
return Err(format!(
"field `{}` presets from `{}`, which is not in this form",
f.name, preset.field
));
}
}
if let Some(trigger) = &f.trigger {
trigger
.check()
.map_err(|e| format!("field `{}`: {e}", f.name))?;
if !names().any(|n| n == trigger.watched()) {
return Err(format!(
"field `{}` watches `{}`, which is not in this form",
f.name,
trigger.watched()
));
}
}
let ft = field_types.get(&f.field_type).ok_or_else(|| {
format!(
"field `{}` uses unregistered type `{}`",
f.name, f.field_type
)
})?;
crate::field::check_field_keys(f, ft.as_ref())
.map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
let opts = ft
.resolve_options(&f.options, field_types)
.map_err(|e| format!("field `{}` (`{}`): {e}", f.name, f.field_type))?;
let mut rules = ft.intrinsic_rules(&opts);
rules.extend(f.rules.clone());
fields.push(PreparedField {
field_type: ft.clone(),
opts,
rules,
});
}
let persister: Arc<dyn Persister> = match &config.persist {
Some(key) => persisters
.get(key)
.cloned()
.ok_or_else(|| format!("form names unregistered persister `{key}`"))?,
None => Arc::new(DefaultPersister::from_config(&config)),
};
let mut resolved: Vec<Arc<dyn ModelListener>> = Vec::new();
if config.timestamps {
resolved.push(Arc::new(laterite_core::Timestamps));
}
resolved.extend(
listeners
.iter()
.filter(|reg| reg.matches(&config.entity))
.map(|reg| reg.listener.clone()),
);
let listeners = resolved;
Ok(Self {
config,
fields,
persister,
listeners,
stay: false,
})
}
}
fn declared_record(
form: &PreparedForm,
data: &HashMap<String, String>,
mode: Mode,
) -> Result<Record, String> {
let mut rec = Record::new(&form.config.entity);
for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
let submitted = crate::field::SubmittedField::new(&field.name, data);
if let Some(value) = prepared
.field_type
.to_attr(&submitted, &prepared.opts, mode)?
{
rec.set(field.name.clone(), value);
}
}
Ok(rec)
}
pub(crate) fn gathered(
form: &PreparedForm,
mut data: HashMap<String, String>,
) -> HashMap<String, String> {
for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
if data.contains_key(&field.name) {
continue;
}
let value = prepared.field_type.submitted(
&crate::field::SubmittedField::new(&field.name, &data),
&prepared.opts,
);
if let Some(value) = value {
data.insert(field.name.clone(), value);
}
}
data
}
fn merged_field_rules(form: &PreparedForm) -> Vec<FieldRules> {
form.config
.fields
.iter()
.zip(&form.fields)
.map(|(f, pf)| FieldRules::new(f.name.clone(), f.label.source(), pf.rules.clone()))
.collect()
}
pub(crate) async fn write(
db: &Db,
form: &PreparedForm,
data: &HashMap<String, String>,
id: Option<&str>,
actor: &Actor,
) -> Result<Record, SaveError> {
let (mut rec, op) = prepare(db, form, data, id).await?;
persist::save(
persist::SaveRequest {
db,
listeners: &form.listeners,
persister: form.persister.as_ref(),
actor,
op,
id,
},
&mut rec,
)
.await?;
link_media(
db,
form,
data,
rec.id()
.map(|i| i.to_string())
.or_else(|| id.map(str::to_string)),
)
.await;
Ok(rec)
}
async fn link_media(
db: &Db,
form: &PreparedForm,
data: &HashMap<String, String>,
owner_id: Option<String>,
) {
let Some(owner_id) = owner_id else {
return;
};
for (field, prepared) in form.config.fields.iter().zip(&form.fields) {
let submitted = data.get(&field.name).map(String::as_str);
let Some(ids) = prepared.field_type.linked_media(submitted, &prepared.opts) else {
continue;
};
let owner = laterite_media::Owner {
owner_type: &form.config.entity,
owner_id: &owner_id,
field: &field.name,
};
if let Err(e) = laterite_media::link::set(db, owner, &ids).await {
tracing::warn!(error = %e, entity = %form.config.entity, field = %field.name, "media links could not be written");
}
}
}
async fn prepare(
db: &Db,
form: &PreparedForm,
data: &HashMap<String, String>,
id: Option<&str>,
) -> Result<(Record, Op), SaveError> {
if !form.config.idents_valid() {
return Err(SaveError::Failed(format!(
"form `{}` names an invalid entity or field identifier",
form.config.entity
)));
}
let (mode, op) = match id {
None => (Mode::Create, Op::Create),
Some(_) => (Mode::Update, Op::Update),
};
let bag = validate(
db,
&form.config.entity,
&form.config.id_field,
&merged_field_rules(form),
data,
mode,
id,
)
.await
.map_err(|e| SaveError::Failed(e.to_string()))?;
if !bag.is_empty() {
return Err(SaveError::Invalid(bag));
}
let mut rec = declared_record(form, data, mode).map_err(|e| {
tracing::error!(entity = %form.config.entity, error = %e, "preparing the write failed");
SaveError::Failed(e)
})?;
if let Some(n) = id.and_then(|id| id.parse::<i64>().ok()) {
rec.set_id(n);
}
Ok((rec, op))
}
pub(crate) type Forms = Arc<HashMap<String, Arc<PreparedForm>>>;
#[derive(Clone)]
pub struct FormHandle {
db: Db,
form: Arc<PreparedForm>,
}
impl FormHandle {
pub(crate) fn new(db: Db, form: Arc<PreparedForm>) -> Self {
Self { db, form }
}
pub fn entity(&self) -> &str {
&self.form.config.entity
}
pub fn id_field(&self) -> &str {
&self.form.config.id_field
}
pub fn fields(&self) -> &[FormField] {
&self.form.config.fields
}
pub async fn create(
&self,
data: HashMap<String, String>,
actor: &Actor,
) -> Result<i64, SaveError> {
let data = gathered(&self.form, data);
let rec = write(&self.db, &self.form, &data, None, actor).await?;
rec.id()
.ok_or_else(|| SaveError::Failed("the persister returned no id".to_string()))
}
pub async fn load(&self, id: i64) -> Result<Option<HashMap<String, String>>, SaveError> {
load_values(&self.db, &self.form, &id.to_string())
.await
.map_err(|e| SaveError::Failed(e.to_string()))
}
pub async fn check(
&self,
id: Option<i64>,
data: HashMap<String, String>,
) -> Result<(), SaveError> {
let data = gathered(&self.form, data);
let id = id.map(|id| id.to_string());
prepare(&self.db, &self.form, &data, id.as_deref())
.await
.map(|_| ())
}
pub async fn update(
&self,
id: i64,
data: HashMap<String, String>,
actor: &Actor,
) -> Result<(), SaveError> {
let data = gathered(&self.form, data);
write(&self.db, &self.form, &data, Some(&id.to_string()), actor)
.await
.map(|_| ())
}
}
pub(crate) fn max_length(rules: &[Rule]) -> Option<usize> {
rules
.iter()
.filter_map(|r| match r {
Rule::MaxLength(n) => Some(*n),
_ => None,
})
.min()
}
fn has_required(rules: &[Rule]) -> bool {
rules
.iter()
.any(|r| matches!(r, Rule::Required | Rule::RequiredOn(_)))
}
pub(crate) fn new_form(
state: &AdminState,
form: &PreparedForm,
shell: crate::Shell,
pending: Vec<String>,
) -> Response {
let mut page = build(
state,
form,
&format!("{}/new", form.config.base_path),
None,
&HashMap::new(),
&ErrorBag::default(),
&shell,
);
page.pending = pending;
render(page)
}
fn after_save(form: &PreparedForm, data: &HashMap<String, String>, id: Option<i64>) -> String {
let stay = data.get("then").map(String::as_str) == Some("stay");
match (stay, id) {
(true, Some(id)) => format!("{}/{}/edit", form.config.base_path, id),
_ => form
.config
.done_path
.clone()
.unwrap_or_else(|| form.config.base_path.clone()),
}
}
pub(crate) async fn create(
state: &AdminState,
form: &PreparedForm,
data: HashMap<String, String>,
shell: crate::Shell,
user: &laterite_auth::AuthenticatedUser,
session: &crate::session::SessionHandle,
headers: &axum::http::HeaderMap,
) -> Response {
if !form.config.idents_valid() {
return render_error();
}
let htmx = is_htmx(headers);
let action = format!("{}/new", form.config.base_path);
let mut data = gathered(form, data);
let then = data.remove("then");
let actor = Actor::from(user);
match write(&state.db, form, &data, None, &actor).await {
Ok(rec) => {
session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
let mut choice = HashMap::new();
if let Some(then) = then {
choice.insert("then".to_string(), then);
}
saved_response(htmx, &after_save(form, &choice, rec.id()))
}
Err(SaveError::Invalid(bag)) => {
invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
}
Err(SaveError::Failed(msg)) => {
tracing::error!(error = %msg, "admin create failed");
render(build(
state,
form,
&action,
Some(t!("Could not save. Check the values and try again.")),
&data,
&ErrorBag::default(),
&shell,
))
}
}
}
pub(crate) async fn edit_form(
state: &AdminState,
form: &PreparedForm,
id: String,
shell: crate::Shell,
sections: Vec<RelationSection>,
) -> Response {
if !form.config.idents_valid() {
return render_error();
}
let values = match load_values(&state.db, form, &id).await {
Ok(Some(values)) => values,
Ok(None) => return not_found(),
Err(_) => return render_error(),
};
let mut page = build(
state,
form,
&format!("{}/{}/edit", form.config.base_path, id),
None,
&values,
&ErrorBag::default(),
&shell,
);
page.sections = sections;
render(page)
}
pub(crate) async fn load_values(
db: &Db,
form: &PreparedForm,
id: &str,
) -> Result<Option<HashMap<String, String>>, sqlx::Error> {
let (sql, values) = {
let cast = text_cast(db.backend);
let mut select = Query::select();
for field in &form.config.fields {
select.expr_as(
Expr::col(Alias::new(&field.name)).cast_as(Alias::new(cast)),
Alias::new(&field.name),
);
}
select.from(Alias::new(&form.config.entity)).and_where(
Expr::col(Alias::new(&form.config.id_field))
.cast_as(Alias::new(cast))
.eq(id.to_string()),
);
to_sql(db.backend, select)
};
let Some(row) = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?
else {
return Ok(None);
};
Ok(Some(
form.config
.fields
.iter()
.zip(&form.fields)
.map(|(f, prepared)| {
let stored = row.get_text_opt(f.name.as_str()).ok().flatten();
let value = prepared
.field_type
.to_control(stored.as_deref(), &prepared.opts);
(f.name.clone(), value)
})
.collect(),
))
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn update(
state: &AdminState,
form: &PreparedForm,
id: String,
data: HashMap<String, String>,
shell: crate::Shell,
user: &laterite_auth::AuthenticatedUser,
session: &crate::session::SessionHandle,
headers: &axum::http::HeaderMap,
) -> Response {
if !form.config.idents_valid() {
return render_error();
}
let htmx = is_htmx(headers);
let action = format!("{}/{}/edit", form.config.base_path, id);
let mut data = gathered(form, data);
let then = data.remove("then");
let actor = Actor::from(user);
match write(&state.db, form, &data, Some(&id), &actor).await {
Ok(_) => {
session.push_flash(crate::session::FlashLevel::Success, t!("Saved."));
let mut choice = HashMap::new();
if let Some(then) = then {
choice.insert("then".to_string(), then);
}
saved_response(htmx, &after_save(form, &choice, id.parse().ok()))
}
Err(SaveError::Invalid(bag)) => {
invalid_response(htmx, state, form, &action, None, &data, &bag, &shell)
}
Err(SaveError::Failed(msg)) => {
tracing::error!(error = %msg, "admin update failed");
render(build(
state,
form,
&action,
Some(t!("Could not save. Check the values and try again.")),
&data,
&ErrorBag::default(),
&shell,
))
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn page(
state: &AdminState,
form: &PreparedForm,
action: &str,
values: &HashMap<String, String>,
shell: &crate::Shell,
) -> Response {
render(build(
state,
form,
action,
None,
values,
&ErrorBag::default(),
shell,
))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn refused(
htmx: bool,
state: &AdminState,
form: &PreparedForm,
action: &str,
error: Option<Text>,
values: &HashMap<String, String>,
bag: &ErrorBag,
shell: &crate::Shell,
) -> Response {
invalid_response(htmx, state, form, action, error, values, bag, shell)
}
#[allow(clippy::too_many_arguments)]
fn invalid_response(
htmx: bool,
state: &AdminState,
form: &PreparedForm,
action: &str,
error: Option<Text>,
values: &HashMap<String, String>,
bag: &ErrorBag,
shell: &crate::Shell,
) -> Response {
let page = build(state, form, action, error, values, bag, shell);
let body = if htmx {
render(FormFragment {
shell: page.shell,
action: page.action,
cancel_path: page.cancel_path,
error: page.error,
fields: page.fields,
stay: page.stay,
enter_off: page.enter_off,
refused: page.refused,
leave_off: page.leave_off,
focus: page.focus,
})
} else {
render(page)
};
(StatusCode::UNPROCESSABLE_ENTITY, body).into_response()
}
pub(crate) fn saved_response(htmx: bool, to: &str) -> Response {
if htmx {
([("HX-Redirect", to)], StatusCode::NO_CONTENT).into_response()
} else {
Redirect::to(to).into_response()
}
}
pub(crate) fn is_htmx(headers: &axum::http::HeaderMap) -> bool {
headers.contains_key("hx-request")
}
fn build(
state: &AdminState,
form: &PreparedForm,
action: &str,
error: Option<Text>,
values: &HashMap<String, String>,
bag: &ErrorBag,
shell: &crate::Shell,
) -> FormTemplate {
let fields = form
.config
.fields
.iter()
.zip(&form.fields)
.map(|(f, pf)| {
let value = FieldValue::Text(values.get(&f.name).cloned().unwrap_or_default());
let required = has_required(&pf.rules);
let label = shell.tt(&f.label);
let cx = FieldCx {
name: &f.name,
id: &f.name,
label: &label,
value: &value,
required,
max_length: max_length(&pf.rules),
opts: &pf.opts,
base: &shell.base,
i18n: shell.i18n(),
};
let control = if f.hidden {
format!(
r#"<input type="hidden" name="{}" value="{}">"#,
escape_attr(&f.name),
escape_attr(values.get(&f.name).map(String::as_str).unwrap_or_default())
)
} else {
match state.field_types.get(&f.field_type) {
Some(ft) => {
let scope = OverrideScope {
surface: Surface::Field,
view_key: &f.field_type,
resource: Some(&form.config.base_path),
field: Some(&f.name),
};
render_field(ft.as_ref(), state.overrides.as_ref(), &scope, &cx)
.into_string()
}
None => String::new(),
}
};
FieldView {
id: f.name.clone(),
label,
control,
required,
enter: f.enter.attribute(),
layout: layout_classes(f.span, f.break_row),
attrs: behaviour_attrs(f),
hidden: f.hidden,
errors: bag.messages(&f.name).iter().map(|m| shell.tt(m)).collect(),
}
})
.collect();
let keys: Vec<&str> = form
.config
.fields
.iter()
.zip(&form.fields)
.filter_map(|(f, pf)| {
state
.field_types
.get(&f.field_type)
.map(|ft| ft.assets(&pf.opts))
})
.flatten()
.collect();
let shell_title = shell.tt(&form.config.title);
let refused = error.is_some() || !bag.is_empty();
let error = error.map(|m| shell.tt(&m));
let mut shell = shell.clone();
shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
FormTemplate {
shell,
title: shell_title,
action: action.to_string(),
cancel_path: form
.config
.done_path
.clone()
.unwrap_or_else(|| form.config.base_path.clone()),
error,
fields,
stay: form.stay,
sections: Vec::new(),
pending: Vec::new(),
enter_off: form.config.enter == EnterPolicy::Off,
refused,
leave_off: !form.config.confirm_leave,
focus: match form.config.focus {
FormFocus::Off => "off",
FormFocus::Auto if action.ends_with("/new") => "first",
FormFocus::Auto => "",
},
}
}
struct FieldView {
id: String,
label: String,
control: String,
required: bool,
errors: Vec<String>,
enter: &'static str,
layout: String,
attrs: String,
hidden: bool,
}
pub(crate) struct RelationSection {
pub toolbar: crate::html::Markup,
pub html: crate::html::Markup,
}
pub(crate) fn escape_attr(s: &str) -> String {
s.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
#[derive(Template)]
#[template(path = "_form_fields.html")]
struct FormFragment {
shell: crate::Shell,
action: String,
cancel_path: String,
error: Option<String>,
fields: Vec<FieldView>,
stay: bool,
enter_off: bool,
refused: bool,
leave_off: bool,
focus: &'static str,
}
#[derive(Template)]
#[template(path = "form.html")]
struct FormTemplate {
shell: crate::Shell,
title: String,
action: String,
cancel_path: String,
error: Option<String>,
fields: Vec<FieldView>,
stay: bool,
sections: Vec<RelationSection>,
pending: Vec<String>,
enter_off: bool,
refused: bool,
leave_off: bool,
focus: &'static str,
}
pub struct ValueFieldView {
pub label: String,
pub help: Option<String>,
pub control: String,
pub layout: String,
pub attrs: String,
}
#[derive(Template)]
#[template(path = "_value_fields.html")]
struct ValueFieldsTemplate {
fields: Vec<ValueFieldView>,
}
pub struct ValueForm {
fields: Vec<FormField>,
types: Arc<crate::field::FieldRegistry>,
overrides: Arc<dyn crate::field::OverrideResolver>,
scope: String,
}
impl ValueForm {
pub(crate) fn new(
fields: &[FormField],
types: Arc<crate::field::FieldRegistry>,
overrides: Arc<dyn crate::field::OverrideResolver>,
scope: &str,
) -> Result<Self, Text> {
for field in fields {
let Some(ft) = types.get(&field.field_type) else {
return Err(Text::dynamic(format!(
"field {:?} uses unregistered type {:?}",
field.name, field.field_type
)));
};
if ft.resolve_options(&field.options, &types).is_err() {
return Err(Text::dynamic(format!(
"field {:?} has malformed options",
field.name
)));
}
}
Ok(Self {
fields: fields.to_vec(),
types,
overrides,
scope: scope.to_string(),
})
}
pub fn fields(&self) -> &[FormField] {
&self.fields
}
pub fn views(&self, stored: &serde_json::Value, shell: &crate::Shell) -> Vec<ValueFieldView> {
value_views(
&self.fields,
&self.types,
self.overrides.as_ref(),
stored,
shell,
&self.scope,
)
}
pub fn render(&self, stored: &serde_json::Value, shell: &crate::Shell) -> crate::html::Markup {
let template = ValueFieldsTemplate {
fields: self.views(stored, shell),
};
crate::html::Markup::from_template(&template).unwrap_or_default()
}
pub fn collect(&self, data: &HashMap<String, String>) -> Result<serde_json::Value, Text> {
value_collect(&self.fields, &self.types, data)
}
}
pub(crate) fn value_views(
fields: &[FormField],
types: &crate::field::FieldRegistry,
overrides: &dyn crate::field::OverrideResolver,
stored: &serde_json::Value,
shell: &crate::Shell,
scope: &str,
) -> Vec<ValueFieldView> {
fields
.iter()
.map(|f| {
let label = shell.tt(&f.label);
let control = match types.get(&f.field_type) {
Some(ft) => {
let opts = ft
.resolve_options(&f.options, types)
.unwrap_or_else(|_| ResolvedOptions::none());
let value = match stored.get(&f.name) {
Some(serde_json::Value::Array(rows)) => {
FieldValue::Json(serde_json::Value::Array(rows.clone()))
}
Some(serde_json::Value::String(text)) => {
FieldValue::Text(ft.to_control(Some(text), &opts))
}
Some(serde_json::Value::Null) | None => {
FieldValue::Text(ft.to_control(None, &opts))
}
Some(other) => {
FieldValue::Text(ft.to_control(Some(&other.to_string()), &opts))
}
};
let cx = FieldCx {
name: &f.name,
id: &f.name,
label: &label,
value: &value,
required: false,
max_length: max_length(&f.rules),
opts: &opts,
base: &shell.base,
i18n: shell.i18n(),
};
let scope = OverrideScope {
surface: Surface::Field,
view_key: &f.field_type,
resource: Some(scope),
field: Some(&f.name),
};
render_field(ft.as_ref(), overrides, &scope, &cx).into_string()
}
None => String::new(),
};
ValueFieldView {
label,
help: f.help.as_ref().map(|h| shell.tt(h)),
control,
layout: layout_classes(f.span, f.break_row),
attrs: behaviour_attrs(f),
}
})
.collect()
}
pub(crate) fn value_collect(
fields: &[FormField],
types: &crate::field::FieldRegistry,
data: &HashMap<String, String>,
) -> Result<serde_json::Value, Text> {
let mut object = serde_json::Map::new();
for field in fields {
let Some(field_type) = types.get(&field.field_type) else {
return Err(t!("This screen uses a field type that is not registered."));
};
let opts = field_type
.resolve_options(&field.options, types)
.map_err(|_| t!("This screen has a malformed field."))?;
let submitted = crate::field::SubmittedField::new(&field.name, data);
let value = match field_type.to_attr(&submitted, &opts, Mode::Update) {
Ok(Some(value)) => attr_to_json(value),
Ok(None) => serde_json::Value::String(String::new()),
Err(_) => return Err(t!("Check the values and try again.")),
};
object.insert(field.name.clone(), value);
}
Ok(serde_json::Value::Object(object))
}
pub(crate) fn attr_to_json(value: laterite_core::AttrValue) -> serde_json::Value {
use laterite_core::AttrValue;
match value {
AttrValue::Null => serde_json::Value::String(String::new()),
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()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use laterite_core::strata::{
async_trait, ColumnDef, CoreResult, Migration, MigrationSet, Schema, Table,
};
use laterite_core::testing::{connect_test, TestGuard};
use laterite_core::Db;
use laterite_core::SaveCx;
struct CreateSamples;
#[async_trait(?Send)]
impl Migration for CreateSamples {
fn name(&self) -> &str {
"0001_create_samples"
}
async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {
s.exec(
Table::create()
.table(Alias::new("samples"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.big_integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Alias::new("code")).text().not_null())
.col(ColumnDef::new(Alias::new("name")).text().not_null())
.col(ColumnDef::new(Alias::new("created_at")).text())
.col(ColumnDef::new(Alias::new("updated_at")).text())
.to_owned(),
)
.await
}
}
fn config() -> PreparedForm {
let config = FormConfig {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required().unique(),
FormField::text("name", "Name").required(),
],
persist: None,
timestamps: false,
done_path: None,
};
PreparedForm::prepare(
config,
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
)
.unwrap()
}
#[tokio::test]
async fn a_delete_removes_the_row_through_the_pipeline() {
use crate::persist::{delete, DefaultPersister, DeleteRequest};
let (db, _guard) = test_db().await;
let st = state(db.clone());
let form = config();
let mut data = HashMap::new();
data.insert("code".to_string(), "c1".to_string());
data.insert("name".to_string(), "Chair".to_string());
create(
&st,
&form,
data,
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(
fetch_text(&db, "name", "c1").await.as_deref(),
Some("Chair")
);
let id = row_id(&db, "c1").await;
let persister = DefaultPersister::from_config(&form.config);
let rec = delete(DeleteRequest {
db: &db,
listeners: &[],
persister: &persister,
actor: &laterite_core::Actor::system("test"),
entity: "samples",
id: &id,
})
.await
.expect("deleted");
assert_eq!(fetch_text(&db, "name", "c1").await, None);
assert_eq!(rec.text("code"), Some("c1"));
}
async fn row_id(db: &Db, code: &str) -> String {
let stmt = Query::select()
.expr_as(
Expr::col(Alias::new("id")).cast_as(Alias::new(text_cast(db.backend))),
Alias::new("v"),
)
.from(Alias::new("samples"))
.and_where(Expr::col(Alias::new("code")).eq(code))
.to_owned();
let (sql, values) = laterite_core::query::build(db.backend, stmt);
let row = bind_values(sqlx::query(&sql), values)
.fetch_one(&db.pool)
.await
.unwrap();
row.get_text("v").unwrap()
}
pub(super) fn state(db: Db) -> AdminState {
AdminState::new(
laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
db,
)
}
pub(super) async fn test_db() -> (Db, TestGuard) {
let samples = MigrationSet::new("test.samples", vec![Box::new(CreateSamples)]);
connect_test(&[samples]).await
}
pub(super) async fn fetch_text(db: &Db, column: &str, code: &str) -> Option<String> {
let stmt = Query::select()
.expr_as(
Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(db.backend))),
Alias::new("v"),
)
.from(Alias::new("samples"))
.and_where(Expr::col(Alias::new("code")).eq(code))
.to_owned();
let (sql, values) = to_sql(db.backend, stmt);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await
.unwrap()?;
row.get_text_opt("v").ok().flatten()
}
async fn body_of(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn count(db: &Db) -> i64 {
sqlx::query_scalar("select count(*) from samples")
.fetch_one(&db.pool)
.await
.unwrap()
}
fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
pub(super) fn htmx_headers() -> axum::http::HeaderMap {
let mut h = axum::http::HeaderMap::new();
h.insert("hx-request", "true".parse().unwrap());
h
}
#[tokio::test]
async fn create_then_fetch() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
let resp = create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Content Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
fetch_text(&db, "name", "editor").await.as_deref(),
Some("Content Editor")
);
}
#[tokio::test]
async fn update_changes_the_row() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
let id = fetch_text(&db, "id", "editor")
.await
.expect("row should exist after create");
let resp = update(
&st,
&cfg,
id,
data(&[("code", "editor"), ("name", "Senior Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
fetch_text(&db, "name", "editor").await.as_deref(),
Some("Senior Editor")
);
}
#[tokio::test]
async fn create_re_renders_with_a_required_message_and_inserts_nothing() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
let resp = create(
&st,
&cfg,
data(&[("code", ""), ("name", "No Code")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(count(&db).await, 0);
assert!(body_of(resp).await.contains("Code is required."));
}
#[tokio::test]
async fn create_rejects_a_duplicate_unique_value() {
let (db, _guard) = test_db().await;
let cfg = config();
let st = state(db.clone());
create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
let resp = create(
&st,
&cfg,
data(&[("code", "editor"), ("name", "Other")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(count(&db).await, 1);
assert!(body_of(resp).await.contains("already taken"));
}
#[tokio::test]
async fn text_email_input_rejects_a_bad_address_via_its_intrinsic_rule() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let cfg = PreparedForm::prepare(
FormConfig {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required(),
FormField::of("name", "Email", "text")
.options(serde_json::json!({ "input": "email" }))
.required(),
],
persist: None,
timestamps: false,
done_path: None,
},
&st.field_types,
&PersisterRegistry::new(),
&[],
)
.unwrap();
let resp = create(
&st,
&cfg,
data(&[("code", "c1"), ("name", "nope")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(count(&db).await, 0);
let resp = create(
&st,
&cfg,
data(&[("code", "c1"), ("name", "a@b.test")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(count(&db).await, 1);
}
#[test]
fn prepare_rejects_an_unregistered_field_type() {
let config = FormConfig {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![FormField::of("place", "Place", "no.such.type")],
persist: None,
timestamps: false,
done_path: None,
};
let Err(err) = PreparedForm::prepare(
config,
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
) else {
panic!("expected prepare to reject an unregistered type");
};
assert!(err.contains("no.such.type"), "{err}");
}
#[test]
fn prepare_rejects_a_malformed_option() {
let config = FormConfig {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::of("email", "Email", "text").options(serde_json::json!({ "input": 7 }))
],
persist: None,
timestamps: false,
done_path: None,
};
let Err(err) = PreparedForm::prepare(
config,
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
) else {
panic!("expected prepare to reject a malformed option");
};
assert!(err.contains("`email`"), "{err}");
}
struct RollbackPersister;
#[laterite_core::strata::async_trait]
impl Persister for RollbackPersister {
async fn create(&self, cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
let backend = cx.backend();
let insert = Query::insert()
.into_table(Alias::new("samples"))
.columns([Alias::new("code"), Alias::new("name")])
.values_panic(["rolled".into(), "back".into()])
.to_owned();
let (sql, values) = to_sql(backend, insert);
bind_values(sqlx::query(&sql), values)
.execute(cx.conn())
.await
.map_err(|e| SaveError::Failed(e.to_string()))?;
Err(SaveError::Failed("deliberate".to_string()))
}
async fn update(
&self,
_cx: &mut SaveCx<'_>,
_id: &str,
_rec: &Record,
) -> Result<(), SaveError> {
Ok(())
}
}
struct RejectingPersister;
#[laterite_core::strata::async_trait]
impl Persister for RejectingPersister {
async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
let mut bag = ErrorBag::default();
bag.add("code", t!("Code is not allowed here."));
Err(SaveError::Invalid(bag))
}
async fn update(
&self,
_cx: &mut SaveCx<'_>,
_id: &str,
_rec: &Record,
) -> Result<(), SaveError> {
Ok(())
}
}
fn config_with(persister: &str) -> FormConfig {
FormConfig {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required(),
FormField::text("name", "Name").required(),
],
persist: Some(persister.to_string()),
timestamps: false,
done_path: None,
}
}
#[test]
fn prepare_rejects_an_unregistered_persister() {
let Err(err) = PreparedForm::prepare(
config_with("no.such.persister"),
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
) else {
panic!("expected prepare to reject an unregistered persister");
};
assert!(err.contains("no.such.persister"), "{err}");
}
#[tokio::test]
async fn a_persister_transaction_rolls_back_on_error() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let mut persisters = PersisterRegistry::new();
persisters.insert("test.rollback".to_string(), Arc::new(RollbackPersister));
let form = PreparedForm::prepare(
config_with("test.rollback"),
&st.field_types,
&persisters,
&[],
)
.unwrap();
let resp = create(
&st,
&form,
data(&[("code", "rolled"), ("name", "back")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(count(&db).await, 0);
}
#[tokio::test]
async fn a_persister_invalid_error_re_renders_422() {
let (db, _guard) = test_db().await;
let st = state(db.clone());
let mut persisters = PersisterRegistry::new();
persisters.insert("test.reject".to_string(), Arc::new(RejectingPersister));
let form = PreparedForm::prepare(
config_with("test.reject"),
&st.field_types,
&persisters,
&[],
)
.unwrap();
let resp = create(
&st,
&form,
data(&[("code", "editor"), ("name", "Editor")]),
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&axum::http::HeaderMap::new(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert!(body_of(resp).await.contains("Code is not allowed here."));
}
}
#[cfg(test)]
mod timestamp_tests {
use super::tests::*;
use super::*;
use laterite_core::listeners::{CREATED_AT, UPDATED_AT};
use laterite_core::Actor;
fn timestamped(on: bool) -> FormConfig {
FormConfig {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required(),
FormField::text("name", "Name").required(),
],
persist: None,
timestamps: on,
done_path: None,
}
}
async fn create_through_pipeline(db: &laterite_core::Db, on: bool) -> Record {
let form = PreparedForm::prepare(
timestamped(on),
&crate::field::builtin_registry(),
&PersisterRegistry::new(),
&[],
)
.unwrap();
let mut data = HashMap::new();
data.insert("code".to_string(), "c1".to_string());
data.insert("name".to_string(), "Chair".to_string());
let mut rec = declared_record(&form, &data, Mode::Create).unwrap();
let actor = Actor::system("test");
persist::save(
persist::SaveRequest {
db,
listeners: &form.listeners,
persister: form.persister.as_ref(),
actor: &actor,
op: Op::Create,
id: None,
},
&mut rec,
)
.await
.expect("the insert should succeed");
rec
}
#[tokio::test]
async fn opting_in_stamps_the_row() {
let (db, _guard) = test_db().await;
let rec = create_through_pipeline(&db, true).await;
assert!(rec.datetime(CREATED_AT).is_some());
assert!(rec.datetime(UPDATED_AT).is_some());
assert!(fetch_text(&db, CREATED_AT, "c1").await.is_some());
}
#[tokio::test]
async fn leaving_it_off_writes_no_timestamps() {
let (db, _guard) = test_db().await;
let rec = create_through_pipeline(&db, false).await;
assert!(rec.get(CREATED_AT).is_none());
assert_eq!(fetch_text(&db, CREATED_AT, "c1").await, None);
}
}
#[cfg(test)]
mod htmx_tests {
use super::tests::*;
use super::*;
use axum::http::StatusCode;
fn invalid() -> HashMap<String, String> {
let mut d = HashMap::new();
d.insert("code".to_string(), String::new());
d.insert("name".to_string(), "No code".to_string());
d
}
fn valid() -> HashMap<String, String> {
let mut d = HashMap::new();
d.insert("code".to_string(), "c1".to_string());
d.insert("name".to_string(), "Chair".to_string());
d
}
async fn post(data: HashMap<String, String>, headers: axum::http::HeaderMap) -> Response {
let (db, _guard) = test_db().await;
let st = state(db);
let form = PreparedForm::prepare(
FormConfig {
enter: Default::default(),
confirm_leave: true,
focus: Default::default(),
entity: "samples".to_string(),
title: "Sample".into(),
base_path: "/admin/samples".to_string(),
id_field: "id".to_string(),
fields: vec![
FormField::text("code", "Code").required(),
FormField::text("name", "Name").required(),
],
persist: None,
timestamps: false,
done_path: None,
},
&st.field_types,
&PersisterRegistry::new(),
&[],
)
.unwrap();
create(
&st,
&form,
data,
crate::Shell::test(),
&crate::audit::test_actor(),
&crate::session::SessionHandle::from_blob(None),
&headers,
)
.await
}
async fn body_of(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn an_htmx_submit_that_fails_returns_the_form_alone() {
let resp = post(invalid(), htmx_headers()).await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let html = body_of(resp).await;
assert!(html.contains("<form"), "the form comes back");
assert!(html.contains("lat-field__error"), "with its errors");
assert!(!html.contains("<body"), "no page chrome");
}
#[tokio::test]
async fn a_plain_submit_that_fails_returns_the_whole_page() {
let resp = post(invalid(), axum::http::HeaderMap::new()).await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
let html = body_of(resp).await;
assert!(html.contains("<body"), "the admin still works without htmx");
assert!(html.contains("lat-field__error"));
}
#[tokio::test]
async fn an_htmx_submit_that_succeeds_redirects_by_header() {
let resp = post(valid(), htmx_headers()).await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
assert_eq!(resp.headers().get("HX-Redirect").unwrap(), "/admin/samples");
}
#[tokio::test]
async fn a_plain_submit_that_succeeds_still_redirects() {
let resp = post(valid(), axum::http::HeaderMap::new()).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
}
}