use std::collections::HashMap;
use serde::Serialize;
use super::audit::AdminAction;
use super::types::{Admin, AdminEntry, AdminField, EditRow, ListRow};
use crate::auth::Identity;
use crate::error::Result;
use crate::http::FormData;
use crate::orm::Db;
#[derive(Serialize)]
pub(crate) struct IdentityCtx {
pub email: String,
pub is_admin: bool,
pub is_developer: bool,
}
impl From<&Identity> for IdentityCtx {
fn from(i: &Identity) -> Self {
Self {
email: i.email.clone(),
is_admin: i.is_admin(),
is_developer: i.is_active && i.role.includes(crate::auth::Role::Developer),
}
}
}
#[derive(Serialize)]
pub(crate) struct BaseContext {
pub identity: Option<IdentityCtx>,
pub csrf_token: String,
pub site_title: String,
pub site_header: String,
pub index_title: String,
pub footer_copyright: String,
pub is_demo_session: bool,
pub demo_label: Option<String>,
}
impl BaseContext {
pub fn new(identity: Option<&Identity>, csrf_token: String, admin: &Admin) -> Self {
let b = admin.branding();
let (is_demo_session, demo_label) = match identity {
Some(i) => (i.is_demo, i.demo_label.clone()),
None => (false, None),
};
Self {
identity: identity.map(IdentityCtx::from),
csrf_token,
site_title: b.site_title.clone(),
site_header: b.site_header.clone(),
index_title: b.index_title.clone(),
footer_copyright: b.footer_copyright.clone(),
is_demo_session,
demo_label,
}
}
}
#[derive(Serialize)]
pub(crate) struct SidebarEntry {
pub admin_name: &'static str,
pub display_name: &'static str,
}
impl From<&AdminEntry> for SidebarEntry {
fn from(e: &AdminEntry) -> Self {
Self {
admin_name: e.admin_name,
display_name: e.display_name,
}
}
}
#[derive(Serialize)]
pub(crate) struct FlashCtx {
pub kind: &'static str,
pub message: String,
}
#[derive(Serialize)]
pub(crate) struct LoginCtx {
#[serde(flatten)]
pub base: BaseContext,
pub error: Option<String>,
pub sections: Vec<FormSection>,
pub flash: Option<FlashCtx>,
}
pub(crate) fn login_form_sections() -> Vec<FormSection> {
vec![FormSection {
title: None,
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 1,
autocomplete: Some("username"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "password",
label: "Password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 1,
autocomplete: Some("current-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
#[derive(Serialize)]
pub(crate) struct DashboardCtx {
#[serde(flatten)]
pub base: BaseContext,
pub entries: Vec<SidebarEntry>,
pub apps: Vec<DashboardApp>,
pub recent_actions: Vec<RecentActionCtx>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct DashboardApp {
pub label: String,
pub models: Vec<DashboardModel>,
}
#[derive(Serialize)]
pub(crate) struct DashboardModel {
pub admin_name: &'static str,
pub display_name: &'static str,
pub field_count: usize,
}
#[derive(Serialize)]
pub(crate) struct RecentActionCtx {
pub action_type: String,
pub label: &'static str,
pub pill_class: &'static str,
pub model_name: String,
pub object_id: i64,
pub user_email: String,
pub summary: String,
pub when_relative: String,
}
pub(crate) fn group_entries_by_app(entries: &[AdminEntry]) -> Vec<DashboardApp> {
let mut apps: Vec<DashboardApp> = Vec::new();
for entry in entries {
if entry.core {
continue;
}
let label = app_label_for(entry.admin_name);
let app = match apps.iter_mut().find(|a| a.label == label) {
Some(a) => a,
None => {
apps.push(DashboardApp {
label: label.clone(),
models: Vec::new(),
});
apps.last_mut().unwrap()
}
};
app.models.push(DashboardModel {
admin_name: entry.admin_name,
display_name: entry.display_name,
field_count: entry.fields.len(),
});
}
apps
}
pub(crate) fn app_label_for(admin_name: &str) -> String {
let prefix = admin_name.split('.').next().unwrap_or(admin_name);
capitalise(prefix)
}
fn capitalise(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
pub(crate) fn dashboard_ctx(
identity: &Identity,
admin: &Admin,
recent_actions: Vec<AdminAction>,
csrf_token: String,
) -> DashboardCtx {
let recent = recent_actions
.into_iter()
.map(|a| RecentActionCtx {
action_type: a.action_type.clone(),
label: action_label(&a.action_type),
pill_class: action_pill_class(&a.action_type),
model_name: a.model_name,
object_id: a.object_id,
user_email: a.user_email.unwrap_or_else(|| "—".to_string()),
summary: a.summary,
when_relative: relative_time(a.timestamp),
})
.collect();
DashboardCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
apps: group_entries_by_app(admin.entries()),
recent_actions: recent,
flash: None,
}
}
fn action_label(action_type: &str) -> &'static str {
match action_type {
"create" => "Created",
"update" => "Changed",
"delete" => "Deleted",
_ => "Action",
}
}
fn action_pill_class(action_type: &str) -> &'static str {
match action_type {
"create" => "badge-success",
"update" => "badge-neutral",
"delete" => "badge-danger",
_ => "badge-neutral",
}
}
pub(crate) fn relative_time(ts: chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let delta = now - ts;
if delta.num_seconds() < 60 {
"just now".to_string()
} else if delta.num_minutes() < 60 {
format!("{}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("{}h ago", delta.num_hours())
} else if delta.num_days() < 30 {
format!("{}d ago", delta.num_days())
} else {
ts.format("%Y-%m-%d").to_string()
}
}
#[derive(Serialize)]
pub(crate) struct ListField {
pub name: String,
pub label: String,
pub kind: &'static str,
}
#[derive(Serialize)]
pub(crate) struct ListCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub fields: Vec<ListField>,
pub rows: Vec<ListRowCtx>,
pub search_query: String,
pub filters: Vec<FilterGroupCtx>,
pub page: usize,
pub total_pages: usize,
pub per_page: usize,
pub total_rows: usize,
pub bulk_actions_enabled: bool,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct ListRowCtx {
pub id: i64,
#[serde(flatten)]
pub values: HashMap<String, serde_json::Value>,
}
#[derive(Serialize)]
pub(crate) struct FilterGroupCtx {
pub field: String,
pub label: String,
pub options: Vec<FilterOptionCtx>,
pub current: Option<String>,
}
#[derive(Serialize)]
pub(crate) struct FilterOptionCtx {
pub value: String,
pub label: String,
pub selected: bool,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn list_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
rows: Vec<ListRow>,
search_query: String,
filters: Vec<FilterGroupCtx>,
page: usize,
per_page: usize,
total_rows: usize,
csrf_token: String,
) -> ListCtx {
let total_pages = total_rows.div_ceil(per_page.max(1)).max(1);
let fields: Vec<ListField> = entry
.fields
.iter()
.map(|f| ListField {
name: f.name.to_string(),
label: f.label.to_string(),
kind: f.field_type.widget(),
})
.collect();
let field_names: Vec<&'static str> = entry.fields.iter().map(|f| f.name).collect();
let field_types: Vec<crate::admin::FieldType> =
entry.fields.iter().map(|f| f.field_type).collect();
ListCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: entry.display_name.to_string(),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
fields,
rows: rows
.into_iter()
.map(|r| {
let mut values: HashMap<String, serde_json::Value> =
HashMap::with_capacity(field_names.len().saturating_sub(1));
for (i, cell) in r.cells.into_iter().enumerate() {
if let Some(name) = field_names.get(i) {
if *name == "id" {
continue;
}
let typed = match field_types.get(i) {
Some(crate::admin::FieldType::Bool) => {
serde_json::Value::Bool(cell == "true")
}
_ => serde_json::Value::String(cell),
};
values.insert((*name).to_string(), typed);
}
}
ListRowCtx { id: r.id, values }
})
.collect(),
search_query,
filters,
page,
total_pages,
per_page,
total_rows,
bulk_actions_enabled: false,
flash: None,
}
}
#[derive(Serialize)]
pub(crate) struct FormCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub mode: &'static str, pub object_id: Option<i64>,
pub sections: Vec<FormSection>,
pub errors: Vec<String>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize, Clone)]
pub(crate) struct SelectOption {
pub value: String,
pub label: String,
}
#[derive(Serialize)]
pub(crate) struct FormField {
pub name: &'static str,
pub label: String,
pub widget: &'static str,
pub input_type: &'static str,
pub value: String,
pub hint: Option<String>,
pub placeholder: Option<String>,
pub required: bool,
pub options: Option<Vec<SelectOption>>,
pub multiple: bool,
pub span: u8,
pub autocomplete: Option<&'static str>,
pub autofocus: bool,
pub disabled: bool,
pub maxlength: Option<u16>,
pub searchable: bool,
pub has_more: bool,
pub search_url: Option<String>,
pub errors: Vec<String>,
pub target_model: Option<String>,
pub checked: bool,
}
#[derive(Serialize)]
pub(crate) struct FormSection {
pub title: Option<&'static str>,
pub fields: Vec<FormField>,
}
fn humanise_field(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut next_upper = true;
for ch in s.chars() {
if ch == '_' {
out.push(' ');
next_upper = true;
} else if next_upper {
out.push(ch.to_ascii_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
pub(crate) fn bucket_errors_by_label(
entry: &AdminEntry,
errors: Vec<String>,
) -> (Vec<String>, HashMap<String, Vec<String>>) {
let labels: Vec<(&'static str, String)> = entry
.fields
.iter()
.filter(|f| f.editable)
.map(|f| (f.name, format!("{} ", humanise_field(f.name))))
.collect();
let mut global: Vec<String> = Vec::new();
let mut per_field: HashMap<String, Vec<String>> = HashMap::new();
'outer: for err in errors {
for (name, prefix) in &labels {
if err.starts_with(prefix.as_str()) {
per_field.entry((*name).to_string()).or_default().push(err);
continue 'outer;
}
}
global.push(err);
}
(global, per_field)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn form_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
mode: &'static str,
object_id: Option<i64>,
existing: Option<&EditRow>,
errors: Vec<String>,
csrf_token: String,
relation_options: HashMap<&'static str, (Vec<SelectOption>, bool)>,
field_errors: HashMap<String, Vec<String>>,
submitted: Option<&FormData>,
) -> FormCtx {
let fields = entry
.fields
.iter()
.filter(|f| f.editable)
.map(|f| {
let value = if let Some(form) = submitted {
form.get(f.name).map(str::to_string).unwrap_or_default()
} else {
existing
.and_then(|row| {
row.values
.iter()
.find(|(col, _)| col == f.name)
.map(|(_, v)| v.clone())
})
.unwrap_or_default()
};
let ui = super::intelligence::field_ui_metadata(f, None);
let (base_widget, input_type) = map_field_to_ui(f);
let widget = if base_widget == "input"
&& matches!(
f.field_type,
super::types::FieldType::String | super::types::FieldType::OptionalString
)
&& is_long_text_name(f.name)
{
"textarea"
} else {
base_widget
};
let required =
!f.field_type.nullable() && !matches!(f.field_type, super::types::FieldType::Bool);
let (mut options, multiple, mut searchable, mut has_more) =
if let Some(values) = f.choices {
let mut opts: Vec<SelectOption> = Vec::with_capacity(values.len() + 1);
if f.field_type.nullable() {
opts.push(SelectOption {
value: String::new(),
label: "—".to_string(),
});
}
opts.extend(values.iter().map(|v| SelectOption {
value: (*v).to_string(),
label: (*v).to_string(),
}));
(Some(opts), false, false, false)
} else if let Some(rel) = &f.relation {
let (opts, has_more) =
relation_options.get(f.name).cloned().unwrap_or_default();
(Some(opts), rel.multi, true, has_more)
} else {
(None, false, false, false)
};
let mut widget = widget;
if f.name == "status" && options.is_none() {
options = Some(vec![
SelectOption {
value: "draft".to_string(),
label: "draft".to_string(),
},
SelectOption {
value: "published".to_string(),
label: "published".to_string(),
},
]);
searchable = false;
has_more = false;
widget = "select";
}
let span: u8 = if widget == "textarea" { 2 } else { 1 };
let search_url = f
.relation
.as_ref()
.map(|rel| format!("/admin/search/{}", rel.target_model));
let target_model = f.relation.as_ref().map(|rel| rel.target_model.to_string());
let checked = matches!(value.as_str(), "on" | "true" | "1" | "yes");
let placeholder = if let Some(rel) = &f.relation {
Some(format!("Select {}…", rel.target_model))
} else {
ui.placeholder
};
FormField {
name: f.name,
label: ui.label,
widget,
input_type,
value,
hint: ui.hint,
placeholder,
required,
options,
multiple,
span,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable,
has_more,
search_url,
errors: field_errors.get(f.name).cloned().unwrap_or_default(),
target_model,
checked,
}
})
.collect::<Vec<FormField>>();
let sections = group_fields_into_sections(fields);
FormCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: match mode {
"new" => format!("Add {}", entry.singular_name),
_ => format!("Change {}", entry.singular_name),
},
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
mode,
object_id,
sections,
errors,
flash: None,
}
}
pub(crate) fn apply_field_errors(
sections: &mut [FormSection],
field_errors: &HashMap<String, Vec<String>>,
) {
for section in sections.iter_mut() {
for field in section.fields.iter_mut() {
if let Some(errs) = field_errors.get(field.name) {
field.errors = errs.clone();
}
}
}
}
fn group_fields_into_sections(fields: Vec<FormField>) -> Vec<FormSection> {
let mut default_fields = Vec::new();
let mut metadata_fields = Vec::new();
let mut advanced_fields = Vec::new();
for field in fields {
match classify_field_section(field.name) {
FieldSection::Default => default_fields.push(field),
FieldSection::Metadata => metadata_fields.push(field),
FieldSection::Advanced => advanced_fields.push(field),
}
}
let mut sections: Vec<FormSection> = Vec::with_capacity(3);
if !default_fields.is_empty() {
sections.push(FormSection {
title: None,
fields: default_fields,
});
}
if !metadata_fields.is_empty() {
sections.push(FormSection {
title: Some("System"),
fields: metadata_fields,
});
}
if !advanced_fields.is_empty() {
sections.push(FormSection {
title: Some("Advanced"),
fields: advanced_fields,
});
}
sections
}
enum FieldSection {
Default,
Metadata,
Advanced,
}
fn classify_field_section(name: &str) -> FieldSection {
if name.contains("created") || name.contains("updated") || name.contains("timestamp") {
FieldSection::Metadata
} else if matches!(name, "id" | "uuid" | "slug") {
FieldSection::Advanced
} else {
FieldSection::Default
}
}
fn is_long_text_name(name: &str) -> bool {
matches!(
name,
"body" | "description" | "notes" | "content" | "summary" | "bio" | "details"
)
}
pub(crate) const FK_OPTIONS_LIMIT: usize = 50;
pub(crate) async fn resolve_relation_options(
admin: &Admin,
entry: &AdminEntry,
db: &Db,
) -> Result<HashMap<&'static str, (Vec<SelectOption>, bool)>> {
let mut out: HashMap<&'static str, (Vec<SelectOption>, bool)> = HashMap::new();
for f in entry.fields.iter() {
let Some(rel) = &f.relation else {
continue;
};
let target = admin.entries().iter().find(|e| {
e.singular_name == rel.target_model
|| e.admin_name == rel.target_model
|| e.display_name == rel.target_model
});
let Some(target) = target else {
out.insert(f.name, (Vec::new(), false));
continue;
};
let rows = target.ops.list(db).await?;
let total = rows.len();
let display_idx = pick_display_index(target.fields, rel.display_field);
let mut opts: Vec<SelectOption> = rows
.into_iter()
.map(|r| {
let label = display_idx
.and_then(|i| r.cells.get(i).cloned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| r.id.to_string());
SelectOption {
value: r.id.to_string(),
label,
}
})
.collect();
let has_more = total > FK_OPTIONS_LIMIT;
opts.truncate(FK_OPTIONS_LIMIT);
out.insert(f.name, (opts, has_more));
}
Ok(out)
}
pub(crate) fn filter_options(
opts: Vec<SelectOption>,
query: &str,
limit: usize,
) -> Vec<SelectOption> {
let needle = query.to_lowercase();
opts.into_iter()
.filter(|o| o.label.to_lowercase().contains(&needle))
.take(limit)
.collect()
}
pub(crate) const SEARCH_RESULT_LIMIT: usize = 20;
pub(crate) async fn search_options(
admin: &Admin,
db: &Db,
model: &str,
query: &str,
) -> Result<Vec<SelectOption>> {
let target = admin
.entries()
.iter()
.find(|e| e.singular_name == model || e.admin_name == model || e.display_name == model);
let Some(target) = target else {
return Ok(Vec::new());
};
let rows = match target.ops.list(db).await {
Ok(r) => r,
Err(e) => {
log::warn!(
"search_options: list({model}) failed, returning empty result: {e}"
);
return Ok(Vec::new());
}
};
let display_idx = pick_display_index(target.fields, None);
let opts: Vec<SelectOption> = rows
.into_iter()
.map(|r| {
let label = display_idx
.and_then(|i| r.cells.get(i).cloned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| r.id.to_string());
SelectOption {
value: r.id.to_string(),
label,
}
})
.collect();
Ok(filter_options(opts, query, SEARCH_RESULT_LIMIT))
}
pub(crate) const MAX_SEARCH_QUERY_CHARS: usize = 200;
pub(crate) fn truncate_query(raw: &str) -> String {
raw.chars().take(MAX_SEARCH_QUERY_CHARS).collect()
}
fn pick_display_index(fields: &[AdminField], display_field: Option<&str>) -> Option<usize> {
if let Some(preferred) = display_field {
if let Some(i) = fields.iter().position(|f| f.name == preferred) {
return Some(i);
}
}
for fallback in ["name", "title"] {
if let Some(i) = fields.iter().position(|f| f.name == fallback) {
return Some(i);
}
}
None
}
fn map_field_to_ui(field: &super::types::AdminField) -> (&'static str, &'static str) {
if field.choices.is_some() {
return ("select", "select");
}
if let Some(rel) = &field.relation {
if rel.multi {
return ("select", "select-multiple");
}
return ("select", "select");
}
use super::types::FieldType::*;
match field.field_type {
Bool => ("checkbox", "checkbox"),
I32 | I64 | OptionalI64 => ("input", "number"),
DateTime | OptionalDateTime => ("input", "datetime-local"),
String | OptionalString => ("input", "text"),
}
}
#[derive(Serialize)]
pub(crate) struct ConfirmDeleteCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub singular_name: &'static str,
pub object_id: i64,
pub object_label: String,
pub cascading: Vec<CascadeItem>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct CascadeItem {
pub source_display_name: String,
pub source_admin_name: String,
pub source_field: String,
}
pub(crate) fn confirm_delete_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
object_id: i64,
object_label: String,
cascading: Vec<CascadeItem>,
csrf_token: String,
) -> ConfirmDeleteCtx {
ConfirmDeleteCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("Delete {}", entry.singular_name),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
singular_name: entry.singular_name,
object_id,
object_label,
cascading,
flash: None,
}
}
#[derive(Serialize)]
pub(crate) struct HistoryEntryCtx {
pub timestamp_iso: String,
pub when_relative: String,
pub user_email: String,
pub action_type: String,
pub label: &'static str,
pub pill_class: &'static str,
pub model_name: String,
pub model_admin_name: String,
pub object_id: i64,
pub summary: String,
pub ip_address: String,
}
#[derive(Serialize)]
pub(crate) struct ObjectHistoryCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub admin_name: String,
pub display_name: String,
pub singular_name: String,
pub object_id: i64,
pub object_label: String,
pub entries: Vec<HistoryEntryCtx>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct LogEntriesCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<HistoryEntryCtx>,
pub flash: Option<FlashCtx>,
}
pub(crate) fn map_audit_actions(actions: Vec<super::audit::AdminAction>) -> Vec<HistoryEntryCtx> {
actions
.into_iter()
.map(|a| HistoryEntryCtx {
timestamp_iso: a.timestamp.to_rfc3339(),
when_relative: relative_time(a.timestamp),
user_email: a.user_email.unwrap_or_else(|| "—".to_string()),
label: action_label(&a.action_type),
pill_class: action_pill_class(&a.action_type),
model_name: a.model_name.clone(),
model_admin_name: a.model_name,
action_type: a.action_type,
object_id: a.object_id,
summary: a.summary,
ip_address: a.ip_address.unwrap_or_default(),
})
.collect()
}
#[derive(Serialize)]
pub(crate) struct ComingSoonCtx {
#[serde(flatten)]
pub base: BaseContext,
pub entries: Vec<SidebarEntry>,
pub page_title: String,
pub feature_name: String,
pub description: String,
}
#[derive(Serialize)]
pub(crate) struct ForbiddenCtx {
#[serde(flatten)]
pub base: BaseContext,
pub entries: Vec<SidebarEntry>,
pub page_title: &'static str,
pub attempted: Option<String>,
pub required_role: Option<&'static str>,
}
#[derive(Serialize)]
pub(crate) struct ErrorCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub status: u16,
pub heading: String,
pub message: String,
}
pub(crate) fn admin_error_heading(status: u16) -> &'static str {
match status {
400 => "Bad request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not found",
405 => "Method not allowed",
409 => "Conflict",
500 => "Server error",
_ => "Error",
}
}
pub(crate) fn render_admin_error_response(
admin: &Admin,
templates: &crate::templates::Templates,
identity: Option<&Identity>,
status: u16,
message: String,
) -> crate::http::Response {
let heading = admin_error_heading(status).to_string();
let view = ErrorCtx {
base: BaseContext::new(identity, String::new(), admin),
page_title: format!("{status} {heading}"),
status,
heading: heading.clone(),
message,
};
let html_status = hyper::StatusCode::from_u16(status)
.unwrap_or(hyper::StatusCode::INTERNAL_SERVER_ERROR);
match templates.render("admin/error.html", &view) {
Ok(body) => crate::http::Response::html(body).with_status(html_status),
Err(e) => {
log::error!("admin/error.html render failed: {e}");
crate::http::Response::text(format!("{status} {heading}: {}", view.message))
.with_status(html_status)
}
}
}
pub(crate) fn render_forbidden_body(
admin: &Admin,
templates: &crate::templates::Templates,
identity: &Identity,
csrf_token: String,
attempted: Option<String>,
required_role: Option<&'static str>,
) -> crate::error::Result<String> {
let view = ForbiddenCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
page_title: "Permission denied",
attempted,
required_role,
};
templates.render("admin/forbidden.html", &view)
}
#[derive(Serialize)]
pub(crate) struct PasswordChangeCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub errors: Vec<String>,
pub success: bool,
pub sections: Vec<FormSection>,
}
pub(crate) fn role_select_options() -> Vec<SelectOption> {
vec![
SelectOption {
value: "user".to_string(),
label: "User (no admin access)".to_string(),
},
SelectOption {
value: "staff".to_string(),
label: "Staff (admin access; per-model group permissions)".to_string(),
},
SelectOption {
value: "supervisor".to_string(),
label: "Supervisor (view + edit; no destructive ops)".to_string(),
},
SelectOption {
value: "administrator".to_string(),
label: "Administrator (full coverage; bypasses group checks)".to_string(),
},
SelectOption {
value: "developer".to_string(),
label: "Developer (schema browser + execution logs + SQL console)".to_string(),
},
]
}
pub(crate) fn user_new_form_sections(email: &str, role: &str) -> Vec<FormSection> {
vec![
FormSection {
title: Some("Identity"),
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: email.to_string(),
hint: Some("Must be unique across all users.".to_string()),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("off"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "password",
label: "Password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some(
"At least 8 characters. The user can change it later via Change password."
.to_string(),
),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
},
FormSection {
title: Some("Role"),
fields: vec![FormField {
name: "role",
label: "Role".to_string(),
widget: "select",
input_type: "select",
value: role.to_string(),
hint: Some(
"Higher roles include all lower-role capabilities. Group memberships are assigned on the next page after save."
.to_string(),
),
placeholder: None,
required: true,
options: Some(role_select_options()),
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
}],
},
]
}
pub(crate) fn group_form_sections(name: &str, description: &str) -> Vec<FormSection> {
vec![FormSection {
title: Some("General"),
fields: vec![
FormField {
name: "name",
label: "Name".to_string(),
widget: "input",
input_type: "text",
value: name.to_string(),
hint: Some(
"A short identifier — letters, digits, dots and dashes only. Example: editors."
.to_string(),
),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("off"),
autofocus: true,
disabled: false,
maxlength: Some(150),
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "description",
label: "Description".to_string(),
widget: "textarea",
input_type: "text",
value: description.to_string(),
hint: Some("Optional. What this group is for.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
pub(crate) fn user_edit_identity_sections(
email: &str,
role: &str,
is_active: bool,
) -> Vec<FormSection> {
vec![FormSection {
title: Some("Identity"),
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: email.to_string(),
hint: Some(
"Email changes aren't exposed here — they require a full user update."
.to_string(),
),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: true,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "role",
label: "Role".to_string(),
widget: "select",
input_type: "select",
value: role.to_string(),
hint: None,
placeholder: None,
required: true,
options: Some(role_select_options()),
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "is_active",
label: "Active".to_string(),
widget: "checkbox",
input_type: "checkbox",
value: if is_active {
"true".to_string()
} else {
"false".to_string()
},
hint: Some("Inactive users cannot sign in or hold sessions.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: is_active,
},
],
}]
}
pub(crate) fn user_edit_password_sections() -> Vec<FormSection> {
vec![FormSection {
title: Some("Reset password (optional)"),
fields: vec![FormField {
name: "new_password",
label: "New password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some("Leave blank to keep the current password unchanged.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
}],
}]
}
pub(crate) fn password_change_form_sections() -> Vec<FormSection> {
vec![FormSection {
title: None,
fields: vec![
FormField {
name: "old_password",
label: "Old password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("current-password"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "new_password1",
label: "New password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some("Your password must contain at least 8 characters.".to_string()),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "new_password2",
label: "Confirm".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admin::FieldType;
use crate::auth::Role;
use crate::templates::Templates;
fn af(field_type: FieldType) -> crate::admin::AdminField {
crate::admin::AdminField {
name: "x",
label: "x",
field_type,
editable: true,
relation: None,
choices: None,
}
}
#[test]
fn maps_field_types_to_expected_widgets() {
assert_eq!(
map_field_to_ui(&af(FieldType::Bool)),
("checkbox", "checkbox")
);
assert_eq!(map_field_to_ui(&af(FieldType::String)), ("input", "text"));
assert_eq!(
map_field_to_ui(&af(FieldType::OptionalString)),
("input", "text")
);
assert_eq!(map_field_to_ui(&af(FieldType::I32)), ("input", "number"));
assert_eq!(map_field_to_ui(&af(FieldType::I64)), ("input", "number"));
assert_eq!(
map_field_to_ui(&af(FieldType::OptionalI64)),
("input", "number")
);
assert_eq!(
map_field_to_ui(&af(FieldType::DateTime)),
("input", "datetime-local")
);
assert_eq!(
map_field_to_ui(&af(FieldType::OptionalDateTime)),
("input", "datetime-local")
);
}
#[test]
fn enum_field_renders_select() {
const VALUES: &[&str] = &["draft", "published", "archived"];
let mut field = af(FieldType::String);
field.choices = Some(VALUES);
assert_eq!(map_field_to_ui(&field), ("select", "select"));
let mut numeric = af(FieldType::I64);
numeric.choices = Some(VALUES);
assert_eq!(map_field_to_ui(&numeric), ("select", "select"));
}
#[test]
fn fk_field_renders_real_options() {
static FK_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "author_id",
label: "author_id",
field_type: FieldType::I64,
editable: true,
relation: Some(crate::admin::AdminRelation {
target_model: "User",
display_field: Some("email"),
multi: false,
}),
choices: None,
},
];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FK_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let mut relation_options: HashMap<&'static str, (Vec<SelectOption>, bool)> = HashMap::new();
relation_options.insert(
"author_id",
(
vec![
SelectOption {
value: "1".to_string(),
label: "alice@example.com".to_string(),
},
SelectOption {
value: "2".to_string(),
label: "bob@example.com".to_string(),
},
SelectOption {
value: "3".to_string(),
label: "charlie@example.com".to_string(),
},
],
false,
),
);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
relation_options,
HashMap::new(),
None,
);
let author_field = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "author_id")
.expect("author_id field present");
assert_eq!(author_field.widget, "select");
let opts = author_field
.options
.as_ref()
.expect("author_id field has options");
assert_eq!(opts.len(), 3, "real options length should reflect input");
assert_eq!(opts[0].value, "1");
assert_eq!(opts[0].label, "alice@example.com");
assert_eq!(opts[2].label, "charlie@example.com");
assert!(
!opts
.iter()
.any(|o| o.label == "Item 1" || o.label == "Item 2"),
"Phase 7 mock pair must be gone; got: {opts:?}",
opts = opts.iter().map(|o| &o.label).collect::<Vec<_>>()
);
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &ctx)
.expect("form renders");
assert!(
body.contains("alice@example.com"),
"alice option missing in HTML"
);
assert!(
body.contains("bob@example.com"),
"bob option missing in HTML"
);
assert!(
body.contains("charlie@example.com"),
"charlie option missing in HTML"
);
assert!(
!body.contains("Item 1"),
"rendered HTML must not contain the Phase 7 mock label"
);
}
#[test]
fn remote_search_returns_results() {
let opts = vec![
SelectOption {
value: "1".to_string(),
label: "alice@example.com".to_string(),
},
SelectOption {
value: "2".to_string(),
label: "bob@example.com".to_string(),
},
SelectOption {
value: "3".to_string(),
label: "Alice Cooper".to_string(),
},
SelectOption {
value: "4".to_string(),
label: "carol@acme.io".to_string(),
},
];
let r = filter_options(opts.clone(), "alice", 20);
assert_eq!(r.len(), 2);
assert_eq!(r[0].value, "1");
assert_eq!(r[1].value, "3");
let r = filter_options(opts.clone(), "BoB", 20);
assert_eq!(r.len(), 1);
assert_eq!(r[0].value, "2");
let r = filter_options(opts.clone(), "a", 2);
assert_eq!(r.len(), 2, "limit=2 caps the result vec");
let r = filter_options(opts.clone(), "zzznoexist", 20);
assert!(r.is_empty());
let r = filter_options(opts, "Item", 20);
assert!(r.is_empty(), "no row labelled 'Item' — legacy mock is gone");
}
#[test]
fn fk_field_carries_search_url() {
static FK_FIELDS: &[crate::admin::AdminField] = &[crate::admin::AdminField {
name: "author_id",
label: "author_id",
field_type: FieldType::I64,
editable: true,
relation: Some(crate::admin::AdminRelation {
target_model: "User",
display_field: Some("email"),
multi: false,
}),
choices: None,
}];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FK_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let mut relation_options: HashMap<&'static str, (Vec<SelectOption>, bool)> = HashMap::new();
relation_options.insert(
"author_id",
(
vec![SelectOption {
value: "1".into(),
label: "alice@example.com".into(),
}],
false,
),
);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
relation_options,
HashMap::new(),
None,
);
let author = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "author_id")
.expect("author_id field");
assert_eq!(
author.search_url.as_deref(),
Some("/admin/search/User"),
"FK fields must carry the JSON search endpoint URL"
);
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &ctx)
.expect("form renders");
assert!(
body.contains("data-search-url=\"/admin/search/User\""),
"search_url must surface as data-search-url on the search input"
);
}
#[test]
fn searchable_select_filters_options() {
static FK_FIELDS: &[crate::admin::AdminField] = &[crate::admin::AdminField {
name: "author_id",
label: "author_id",
field_type: FieldType::I64,
editable: true,
relation: Some(crate::admin::AdminRelation {
target_model: "User",
display_field: Some("email"),
multi: false,
}),
choices: None,
}];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FK_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let mut relation_options: HashMap<&'static str, (Vec<SelectOption>, bool)> = HashMap::new();
relation_options.insert(
"author_id",
(
vec![
SelectOption {
value: "1".to_string(),
label: "alice@example.com".to_string(),
},
SelectOption {
value: "2".to_string(),
label: "bob@example.com".to_string(),
},
SelectOption {
value: "3".to_string(),
label: "charlie@example.com".to_string(),
},
],
false, ),
);
let existing = EditRow {
id: 7,
values: vec![("author_id".to_string(), "2".to_string())],
};
let ctx = form_ctx(
&ident,
&admin,
&entry,
"edit",
Some(7),
Some(&existing),
vec![],
"csrf".into(),
relation_options,
HashMap::new(),
None,
);
let author_field = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "author_id")
.expect("author_id field present");
assert_eq!(author_field.widget, "select");
assert!(
author_field.searchable,
"FK fields must default to searchable=true"
);
assert!(
!author_field.has_more,
"3 options is below the 50-row truncation threshold"
);
assert_eq!(
author_field.value, "2",
"edit mode must surface the existing author_id value"
);
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &ctx)
.expect("form renders");
assert!(
body.contains("data-search-input"),
"search input marker missing"
);
assert!(
body.contains("data-target=\"id_author_id\""),
"search input must wire to the select via data-target"
);
assert!(
body.contains("aria-controls=\"id_author_id\""),
"search input must announce its target via aria-controls"
);
assert!(
body.contains("placeholder=\"Search…\""),
"search input must carry the placeholder copy"
);
let bob_idx = body
.find("value=\"2\"")
.expect("option with value=2 must render");
let after_bob = &body[bob_idx..bob_idx.saturating_add(120)];
assert!(
after_bob.contains("selected"),
"selected option must carry `selected`; got: {after_bob:?}"
);
assert!(
!body.contains("Showing first 50 results"),
"has_more hint must not appear when has_more=false"
);
}
#[test]
fn searchable_select_renders_has_more_hint() {
static FK_FIELDS: &[crate::admin::AdminField] = &[crate::admin::AdminField {
name: "author_id",
label: "author_id",
field_type: FieldType::I64,
editable: true,
relation: Some(crate::admin::AdminRelation {
target_model: "User",
display_field: None,
multi: false,
}),
choices: None,
}];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FK_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let mut relation_options: HashMap<&'static str, (Vec<SelectOption>, bool)> = HashMap::new();
relation_options.insert(
"author_id",
(
vec![SelectOption {
value: "1".into(),
label: "first".into(),
}],
true,
),
);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
relation_options,
HashMap::new(),
None,
);
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &ctx)
.expect("form renders");
assert!(
body.contains("Showing first 50 results"),
"has_more hint copy missing"
);
}
#[test]
fn fk_field_with_no_options_renders_empty_select() {
static FK_FIELDS: &[crate::admin::AdminField] = &[crate::admin::AdminField {
name: "author_id",
label: "author_id",
field_type: FieldType::I64,
editable: true,
relation: Some(crate::admin::AdminRelation {
target_model: "User",
display_field: None,
multi: false,
}),
choices: None,
}];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FK_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
None,
);
let author_field = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "author_id")
.expect("author_id field present");
assert_eq!(author_field.widget, "select");
let opts = author_field.options.as_ref().expect("options is Some");
assert!(opts.is_empty(), "no relation_options entry → empty select");
}
#[test]
fn relation_multi_sets_multiple() {
let mut single = af(FieldType::I64);
single.relation = Some(crate::admin::AdminRelation {
target_model: "posts",
display_field: None,
multi: false,
});
assert_eq!(map_field_to_ui(&single), ("select", "select"));
let mut many = af(FieldType::I64);
many.relation = Some(crate::admin::AdminRelation {
target_model: "tags",
display_field: None,
multi: true,
});
assert_eq!(map_field_to_ui(&many), ("select", "select-multiple"));
}
fn fake_identity(role: Role) -> Identity {
Identity {
user_id: 1,
email: "test@example.com".into(),
role,
is_active: true,
is_demo: false,
demo_label: None,
}
}
#[test]
fn login_renders_post_logout_banner_when_flash_present() {
let admin = Admin::new();
let templates = Templates::new(None).expect("embedded templates");
let with_flash = LoginCtx {
base: BaseContext::new(None, "fake-csrf".into(), &admin),
error: None,
sections: login_form_sections(),
flash: Some(FlashCtx {
kind: "success",
message: "You've been signed out.".to_string(),
}),
};
let body = templates
.render("admin/login.html", &with_flash)
.expect("login renders with flash");
assert!(
body.contains("been signed out."),
"post-logout message must be in the rendered body — got snippet: {}",
&body[..body.len().min(400)]
);
assert!(
body.contains("message-success"),
"flash must use the success kind class"
);
let bare = LoginCtx {
base: BaseContext::new(None, "fake-csrf".into(), &admin),
error: None,
sections: login_form_sections(),
flash: None,
};
let body = templates
.render("admin/login.html", &bare)
.expect("login renders without flash");
assert!(
!body.contains("been signed out."),
"no flash → no leftover post-logout copy"
);
assert!(
!body.contains("message-success"),
"no flash → no message-success class"
);
}
#[test]
fn render_forbidden_body_with_required_role() {
let admin = Admin::new();
let templates = Templates::new(None).expect("embedded templates");
let ident = fake_identity(Role::Staff);
let body = render_forbidden_body(
&admin,
&templates,
&ident,
"fake-csrf".into(),
None,
Some("Administrator"),
)
.expect("forbidden page renders");
assert!(body.contains("Permission denied"), "page h1 missing");
assert!(
body.contains("Administrator"),
"required_role hint should be in body"
);
assert!(body.contains("Return to dashboard"), "back link missing");
assert!(
body.contains("test@example.com"),
"user-tools email missing"
);
}
#[test]
fn admin_error_page_renders_with_status_and_heading() {
let admin = Admin::new();
let templates = Templates::new(None).expect("embedded templates");
let resp = render_admin_error_response(
&admin,
&templates,
None,
404,
"no admin model: blogs".into(),
);
assert_eq!(resp.status, hyper::StatusCode::NOT_FOUND);
let ct = resp
.headers
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.as_str())
.unwrap_or("");
assert!(
ct.starts_with("text/html"),
"content-type must be HTML, got {ct:?}"
);
let body = std::str::from_utf8(&resp.body).expect("utf8 body");
assert!(body.contains("404"), "status code missing in body");
assert!(body.contains("Not found"), "heading missing in body");
assert!(
body.contains("no admin model: blogs"),
"message missing in body"
);
assert!(
!body.contains("Return to dashboard"),
"dashboard link must not render without an identity"
);
let ident = fake_identity(Role::Staff);
let resp = render_admin_error_response(
&admin,
&templates,
Some(&ident),
500,
"Internal Server Error".into(),
);
let body = std::str::from_utf8(&resp.body).expect("utf8 body");
assert!(body.contains("500"), "status code missing in body");
assert!(body.contains("Server error"), "500 heading missing");
assert!(
body.contains("Return to dashboard"),
"dashboard link must render for signed-in users"
);
}
#[test]
fn user_edit_renders_dynamic_form() {
let templates = Templates::new(None).expect("embedded templates");
let identity_sections = user_edit_identity_sections("alice@example.com", "staff", true);
let password_sections = user_edit_password_sections();
let ctx = serde_json::json!({
"site_title": "RustIO administration",
"site_header": "RustIO administration",
"index_title": "Site administration",
"footer_copyright": "RustIO test",
"csrf_token": "fake",
"is_demo_session": false,
"demo_label": null,
"page_title": "Edit user",
"entries": [],
"user_id": 42,
"email": "alice@example.com",
"role": "staff",
"is_active": true,
"errors": [],
"is_last_developer": false,
"all_groups": [
{ "id": 1, "name": "editors", "description": "Edit posts" },
{ "id": 2, "name": "moderators", "description": "Moderate comments" },
],
"user_groups": [1],
"identity_sections": identity_sections,
"password_sections": password_sections,
"identity": { "email": "admin@example.com", "is_admin": true, "is_developer": false },
});
let body = templates
.render("admin/user_edit.html", &ctx)
.expect("user_edit renders");
assert!(body.contains("name=\"role\""), "role select missing");
for value in ["user", "staff", "supervisor", "administrator", "developer"] {
assert!(
body.contains(&format!("value=\"{value}\"")),
"role option {value:?} missing"
);
}
let staff_idx = body.find("value=\"staff\"").expect("staff option");
let after_staff = &body[staff_idx..staff_idx.saturating_add(80)];
assert!(
after_staff.contains("selected"),
"Staff option should be selected; got: {after_staff:?}"
);
assert!(
body.contains("name=\"is_active\""),
"is_active checkbox missing"
);
let active_idx = body.find("name=\"is_active\"").expect("is_active checkbox");
let after_active = &body[active_idx..active_idx.saturating_add(160)];
assert!(
after_active.contains("checked"),
"is_active should be checked when is_active=true; got: {after_active:?}"
);
assert!(
!body.contains("<select multiple"),
"groups must NOT render as select-multiple — checkbox list is the contract"
);
assert!(
body.contains("name=\"group_1\""),
"group_1 checkbox missing"
);
assert!(
body.contains("name=\"group_2\""),
"group_2 checkbox missing"
);
let g1_idx = body.find("name=\"group_1\"").expect("group_1 checkbox");
let after_g1 = &body[g1_idx..g1_idx.saturating_add(100)];
assert!(
after_g1.contains("checked"),
"group_1 should be checked (1 ∈ user_groups); got: {after_g1:?}"
);
assert!(
body.contains("Edit posts"),
"group description must render alongside the checkbox label"
);
assert!(
body.contains("name=\"email\""),
"email input must still be present"
);
let email_idx = body.find("name=\"email\"").expect("email input");
let after_email = &body[email_idx..email_idx.saturating_add(200)];
assert!(
after_email.contains("disabled"),
"email input must carry HTML disabled attribute; got: {after_email:?}"
);
}
#[test]
fn user_new_form_has_five_role_options() {
let templates = Templates::new(None).expect("embedded templates");
let sections = user_new_form_sections("", "staff");
let ctx = serde_json::json!({
"site_title": "RustIO administration",
"site_header": "RustIO administration",
"index_title": "Site administration",
"footer_copyright": "RustIO test",
"csrf_token": "fake",
"is_demo_session": false,
"demo_label": null,
"page_title": "Add user",
"entries": [],
"email": "",
"role": "staff",
"errors": [],
"sections": sections,
"identity": { "email": "admin@example.com", "is_admin": true },
});
let body = templates
.render("admin/user_new.html", &ctx)
.expect("user_new renders");
for value in ["user", "staff", "supervisor", "administrator", "developer"] {
assert!(
body.contains(&format!("value=\"{value}\"")),
"role option {value:?} missing"
);
}
let staff_idx = body.find("value=\"staff\"").expect("staff option");
let after_staff = &body[staff_idx..staff_idx.saturating_add(80)];
assert!(
after_staff.contains("selected"),
"Staff option should be selected; got: {after_staff:?}"
);
assert!(
!body.contains("name=\"is_staff\""),
"old is_staff checkbox should be gone"
);
assert!(
!body.contains("name=\"is_superuser\""),
"old is_superuser checkbox should be gone"
);
}
#[test]
fn render_base_with_demo_banner() {
let admin = Admin::new();
let templates = Templates::new(None).expect("embedded templates");
let demo_ident = Identity {
user_id: 1,
email: "staff@rustio.local".into(),
role: Role::Staff,
is_active: true,
is_demo: true,
demo_label: Some("Demo Staff".into()),
};
let dash = dashboard_ctx(&demo_ident, &admin, vec![], "fake-csrf".into());
let body = templates
.render("admin/index.html", &dash)
.expect("dashboard renders");
assert!(body.contains("DEMO USER"), "demo banner text missing");
assert!(body.contains("Demo Staff"), "demo_label not in banner");
assert!(
body.contains("RUSTIO_DEMO_MODE"),
"banner should reference the env flag"
);
}
#[test]
fn render_base_without_demo_banner_for_real_user() {
let admin = Admin::new();
let templates = Templates::new(None).expect("embedded templates");
let real_ident = Identity {
user_id: 1,
email: "admin@example.com".into(),
role: Role::Administrator,
is_active: true,
is_demo: false,
demo_label: None,
};
let dash = dashboard_ctx(&real_ident, &admin, vec![], "fake-csrf".into());
let body = templates
.render("admin/index.html", &dash)
.expect("dashboard renders");
assert!(
!body.contains("DEMO USER"),
"demo banner must NOT render for is_demo=false"
);
assert!(
!body.contains("RUSTIO_DEMO_MODE"),
"banner copy must be absent for real users"
);
}
#[test]
fn form_renders_required_marker_humanised_label_and_cancel() {
let templates = Templates::new(None).expect("embedded templates");
let ctx = serde_json::json!({
"site_title": "RustIO administration",
"site_header": "RustIO administration",
"index_title": "Site administration",
"footer_copyright": "RustIO test",
"csrf_token": "fake",
"is_demo_session": false,
"demo_label": null,
"page_title": "Add post",
"entries": [],
"admin_name": "posts",
"display_name": "Posts",
"singular_name": "Post",
"mode": "new",
"object_id": null,
"errors": [],
"identity": { "email": "admin@example.com", "is_admin": true, "is_developer": false },
"sections": [
{
"title": null,
"fields": [
{
"name": "title",
"label": "Title",
"widget": "text",
"input_type": "text",
"value": "",
"hint": null,
"placeholder": null,
"required": true,
"options": null,
"multiple": false,
"span": 1,
"autocomplete": null,
"autofocus": false,
"disabled": false,
"maxlength": null,
"searchable": false,
"has_more": false,
"search_url": null,
},
{
"name": "published",
"label": "Published",
"widget": "checkbox",
"input_type": "checkbox",
"value": "false",
"hint": null,
"placeholder": null,
"required": false,
"options": null,
"multiple": false,
"span": 1,
"autocomplete": null,
"autofocus": false,
"disabled": false,
"maxlength": null,
"searchable": false,
"has_more": false,
"search_url": null,
},
],
},
],
});
let body = templates
.render("admin/form.html", &ctx)
.expect("form renders");
assert!(body.contains(">Title"), "humanised Title label missing");
let title_label_idx = body.find("for=\"id_title\"").expect("title label present");
let title_label_end = title_label_idx
+ body[title_label_idx..]
.find("</label>")
.expect("label closes");
let title_label = &body[title_label_idx..title_label_end];
assert!(
title_label.contains("class=\"required\""),
"title label should carry the required marker, got: {title_label:?}"
);
let published_label_idx = body
.find("for=\"id_published\"")
.expect("published label present");
let published_label_end = published_label_idx
+ body[published_label_idx..]
.find("</label>")
.expect("label closes");
let published_label = &body[published_label_idx..published_label_end];
assert!(
!published_label.contains("class=\"required\""),
"non-required field should not carry the marker, got: {published_label:?}"
);
assert!(
body.contains("href=\"/admin/posts/\"") && body.contains(">\n Cancel"),
"Cancel link to list page missing",
);
assert!(body.contains("name=\"_save\""), "Save button missing");
}
#[test]
fn fields_are_grouped_into_sections() {
static MIXED_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "creation_timestamp",
label: "creation_timestamp",
field_type: FieldType::DateTime,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "uuid",
label: "uuid",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", MIXED_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
None,
);
assert_eq!(
ctx.sections.len(),
3,
"expected three sections, got {ctx_len:?}",
ctx_len = ctx.sections.iter().map(|s| s.title).collect::<Vec<_>>()
);
assert_eq!(
ctx.sections[0].title, None,
"first section is the default bucket — no header"
);
assert_eq!(ctx.sections[0].fields.len(), 1);
assert_eq!(ctx.sections[0].fields[0].name, "title");
assert_eq!(
ctx.sections[1].title,
Some("System"),
"second section is System (formerly Metadata)"
);
assert_eq!(ctx.sections[1].fields.len(), 1);
assert_eq!(ctx.sections[1].fields[0].name, "creation_timestamp");
assert_eq!(
ctx.sections[2].title,
Some("Advanced"),
"third section is Advanced"
);
assert_eq!(ctx.sections[2].fields.len(), 1);
assert_eq!(ctx.sections[2].fields[0].name, "uuid");
static FK_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "user_id",
label: "user_id",
field_type: FieldType::I64,
editable: true,
relation: None,
choices: None,
},
];
let entry2 = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FK_FIELDS, false);
let ctx2 = form_ctx(
&ident,
&admin,
&entry2,
"new",
None,
None,
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
None,
);
assert_eq!(
ctx2.sections.len(),
1,
"FK fields must NOT go to Advanced — they're business-meaningful"
);
assert_eq!(ctx2.sections[0].fields.len(), 2);
}
#[test]
fn textarea_fields_span_full_width() {
static TEXTAREA_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "body",
label: "body",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
];
let admin = Admin::new();
let entry =
AdminEntry::for_testing("posts", "Posts", "Post", "posts", TEXTAREA_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
None,
);
let body_field = ctx.sections[0]
.fields
.iter()
.find(|f| f.name == "body")
.expect("body field present");
assert_eq!(body_field.widget, "textarea");
assert_eq!(body_field.span, 2, "textarea must span both columns");
let title_field = ctx.sections[0]
.fields
.iter()
.find(|f| f.name == "title")
.expect("title field present");
assert_eq!(title_field.widget, "input");
assert_eq!(title_field.span, 1, "plain input takes one column");
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &ctx)
.expect("form renders");
let body_wrapper_idx = body
.find("class=\"span-2\"")
.expect("span-2 wrapper present (the .field-grid-v14 component class)");
let body_after = &body[body_wrapper_idx..];
assert!(
body_after.contains("name=\"body\""),
"span-2 wrapper should contain the body field"
);
}
fn empty_list_ctx_skeleton() -> serde_json::Value {
serde_json::json!({
"site_title": "RustIO administration",
"site_header": "RustIO administration",
"index_title": "Site administration",
"footer_copyright": "RustIO test",
"csrf_token": "fake",
"is_demo_session": false,
"demo_label": null,
"page_title": "Posts",
"entries": [],
"admin_name": "posts",
"display_name": "Posts",
"singular_name": "Post",
"fields": [
{ "name": "title", "label": "title" },
{ "name": "body", "label": "body" },
{ "name": "author", "label": "author" },
],
"rows": [],
"search_query": "",
"filters": [],
"page": 1,
"total_pages": 1,
"per_page": 25,
"total_rows": 0,
"bulk_actions_enabled": false,
"identity": { "email": "admin@example.com", "is_admin": true, "is_developer": false },
})
}
#[test]
fn list_renders_rows_via_field_keyed_lookup() {
let templates = Templates::new(None).expect("embedded templates");
let mut ctx = empty_list_ctx_skeleton();
ctx["rows"] = serde_json::json!([
{
"id": 7,
"title": "Alpha",
"body": "first body",
"author": "alice",
},
{
"id": 9,
"title": "Beta",
"body": "second body",
"author": "bob",
},
]);
ctx["total_rows"] = serde_json::json!(2);
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
assert!(body.contains(">title</th>"), "title header missing");
assert!(body.contains(">body</th>"), "body header missing");
assert!(body.contains(">author</th>"), "author header missing");
assert!(
body.contains("href=\"/admin/posts/7/edit\">Alpha</a>"),
"row 7 first-column edit-link missing"
);
assert!(body.contains("first body"), "row 7 body cell missing");
assert!(body.contains("alice"), "row 7 author cell missing");
assert!(
body.contains("href=\"/admin/posts/9/edit\">Beta</a>"),
"row 9 first-column edit-link missing"
);
assert!(body.contains("second body"), "row 9 body cell missing");
assert!(body.contains("bob"), "row 9 author cell missing");
assert!(
!body.contains("No posts yet"),
"true-empty copy must not render when rows present"
);
assert!(
!body.contains("No results match your search"),
"filtered-empty copy must not render when rows present"
);
}
#[test]
fn list_true_empty_renders_friendly_cta() {
let templates = Templates::new(None).expect("embedded templates");
let ctx = empty_list_ctx_skeleton();
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
assert!(body.contains("No posts yet."), "true-empty heading missing");
assert!(
body.contains("Create your first post"),
"true-empty CTA copy missing",
);
assert!(
body.contains("href=\"/admin/posts/new\""),
"true-empty CTA link missing",
);
assert!(
!body.contains("No results match your search"),
"true-empty branch leaked filtered-empty copy",
);
}
#[test]
fn list_filtered_empty_omits_cta() {
let templates = Templates::new(None).expect("embedded templates");
let mut ctx = empty_list_ctx_skeleton();
ctx["search_query"] = serde_json::Value::String("nonsense".into());
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
assert!(
body.contains("No results match your search"),
"filtered-empty copy missing",
);
assert!(
!body.contains("Create your first post"),
"filtered-empty branch must not show the create CTA",
);
assert!(
!body.contains("No posts yet"),
"filtered-empty branch must not show the true-empty heading",
);
}
#[test]
fn list_dispatches_cell_renderer_by_field_kind() {
let templates = Templates::new(None).expect("embedded templates");
let mut ctx = empty_list_ctx_skeleton();
ctx["fields"] = serde_json::json!([
{ "name": "title", "label": "Title", "kind": "text" },
{ "name": "pages", "label": "Pages", "kind": "number" },
{ "name": "is_available", "label": "Available", "kind": "checkbox" },
{ "name": "published_at", "label": "Published at", "kind": "datetime" },
]);
ctx["rows"] = serde_json::json!([
{
"id": 1,
"title": "Sample",
"pages": "180",
"is_available": true,
"published_at": "1925-04-10T00:00",
},
{
"id": 2,
"title": "Other",
"pages": "311",
"is_available": false,
"published_at": "1932-09-05T00:00",
},
]);
ctx["total_rows"] = serde_json::json!(2);
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
assert!(
body.contains(r#"<th scope="col" class="num">Pages</th>"#),
"numeric column header must carry class=\"num\" via field.kind, got fragment: {}",
&body[..body.len().min(800)]
);
assert!(
body.contains(r#"<span class="badge-v14 badge-yes-v14">Yes</span>"#),
"checkbox column with value \"true\" must render the Yes badge"
);
assert!(
body.contains(r#"<span class="badge-v14 badge-no-v14">No</span>"#),
"checkbox column with value \"false\" must render the No badge"
);
assert!(
body.contains(r#"<div class="rio-time">00:00</div>"#),
"datetime cell must render time in .rio-time"
);
assert!(
body.contains(r#"<div class="rio-date">1925-04-10</div>"#),
"datetime cell must render date in .rio-date"
);
assert!(
body.contains(r#"href="/admin/posts/1/edit">Sample</a>"#),
"first-column row-link convention must still apply for text columns"
);
}
#[test]
fn list_ctx_normalizes_bool_cells_to_json_booleans() {
use crate::admin::types::AdminEntry;
use crate::admin::types::ListRow;
use crate::admin::FieldType;
static FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "title",
label: "Title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "is_available",
label: "Available",
field_type: FieldType::Bool,
editable: true,
relation: None,
choices: None,
},
];
let admin = Admin::new();
let entry = AdminEntry::for_testing("books", "Books", "Book", "books", FIELDS, false);
let ident = fake_identity(Role::Administrator);
let rows = vec![
ListRow { id: 1, cells: vec!["Gatsby".into(), "true".into()] },
ListRow { id: 2, cells: vec!["Brave".into(), "false".into()] },
];
let ctx = list_ctx(
&ident, &admin, &entry, rows,
String::new(), vec![], 1, 25, 2,
"csrf".into(),
);
let row1_avail = ctx.rows[0].values.get("is_available").expect("is_available cell");
assert!(row1_avail.is_boolean(), "Bool cell must be a JSON boolean, got {row1_avail:?}");
assert_eq!(row1_avail.as_bool(), Some(true));
let row2_avail = ctx.rows[1].values.get("is_available").expect("is_available cell");
assert!(row2_avail.is_boolean(), "Bool cell must be a JSON boolean, got {row2_avail:?}");
assert_eq!(row2_avail.as_bool(), Some(false));
let row1_title = ctx.rows[0].values.get("title").expect("title cell");
assert!(row1_title.is_string(), "String cell must remain a JSON string, got {row1_title:?}");
assert_eq!(row1_title.as_str(), Some("Gatsby"));
}
#[test]
fn form_ctx_normalizes_checkbox_value_to_checked_bool() {
use crate::admin::types::AdminEntry;
use crate::admin::FieldType;
use crate::http::FormData;
static FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "is_active",
label: "Active",
field_type: FieldType::Bool,
editable: true,
relation: None,
choices: None,
},
];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FIELDS, false);
let ident = fake_identity(Role::Administrator);
for truthy in ["on", "true", "1", "yes"] {
let body = format!("is_active={truthy}");
let form = FormData::from_urlencoded(&body);
let ctx = form_ctx(
&ident, &admin, &entry, "edit", Some(7), None,
vec![], "csrf".into(), HashMap::new(), HashMap::new(),
Some(&form),
);
let f = ctx.sections.iter().flat_map(|s| s.fields.iter())
.find(|f| f.name == "is_active").expect("is_active");
assert!(
f.checked,
"value {truthy:?} should produce checked=true (FormData::bool_flag contract)"
);
}
let form = FormData::from_urlencoded("");
let ctx = form_ctx(
&ident, &admin, &entry, "edit", Some(7), None,
vec![], "csrf".into(), HashMap::new(), HashMap::new(),
Some(&form),
);
let f = ctx.sections.iter().flat_map(|s| s.fields.iter())
.find(|f| f.name == "is_active").expect("is_active");
assert!(!f.checked, "absent checkbox in submission must produce checked=false");
}
#[test]
fn list_template_has_no_per_page_inline_styles() {
let templates = Templates::new(None).expect("embedded templates");
let mut ctx = empty_list_ctx_skeleton();
ctx["fields"] = serde_json::json!([
{ "name": "title", "label": "Title", "kind": "text" },
]);
ctx["filters"] = serde_json::json!([
{
"field": "published",
"label": "Published",
"options": [
{ "value": "true", "label": "Yes", "selected": false },
{ "value": "false", "label": "No", "selected": false },
],
"current": null,
}
]);
ctx["rows"] = serde_json::json!([{ "id": 1, "title": "x" }]);
ctx["total_rows"] = serde_json::json!(1);
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
for hack in [
r#"style="display:contents""#,
r#"style="margin-top:-4px""#,
r#"style="width:100px;text-align:right""#,
] {
assert!(
!body.contains(hack),
"list template must not carry per-page inline style {hack:?}"
);
}
assert!(
body.contains(r#"class="toolbar-form""#),
"search form must use the .toolbar-form component class"
);
assert!(
body.contains(r#"class="actions-cell""#),
"Actions header must use the .actions-cell component class"
);
}
#[test]
fn list_numeric_dispatch_is_kind_driven_not_name_driven() {
let templates = Templates::new(None).expect("embedded templates");
let mut ctx = empty_list_ctx_skeleton();
ctx["fields"] = serde_json::json!([
{ "name": "title", "label": "Title", "kind": "text" },
{ "name": "score", "label": "Score", "kind": "number" },
]);
ctx["rows"] = serde_json::json!([
{ "id": 1, "title": "x", "score": "42" },
]);
ctx["total_rows"] = serde_json::json!(1);
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
assert!(
body.contains(r#"<th scope="col" class="num">Score</th>"#),
"numeric column with arbitrary name must still get class=\"num\""
);
}
#[test]
fn list_filter_only_empty_omits_cta() {
let templates = Templates::new(None).expect("embedded templates");
let mut ctx = empty_list_ctx_skeleton();
ctx["filters"] = serde_json::json!([
{
"field": "published",
"label": "Published",
"options": [],
"current": "true",
}
]);
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
assert!(
body.contains("No results match your search"),
"filter-only empty should still show 'No results match' copy",
);
assert!(
!body.contains("Create your first post"),
"filter-only empty must not show the create CTA",
);
}
#[test]
fn render_forbidden_body_with_attempted_perm() {
let admin = Admin::new();
let templates = Templates::new(None).expect("embedded templates");
let ident = fake_identity(Role::Staff);
let body = render_forbidden_body(
&admin,
&templates,
&ident,
"fake-csrf".into(),
Some("posts.delete_post".into()),
None,
)
.expect("forbidden page renders");
assert!(
body.contains("posts.delete_post"),
"attempted permission should appear in body"
);
assert!(
!body.contains("This page requires"),
"required_role section must be hidden when None"
);
}
fn form_with_field_errors_ctx() -> serde_json::Value {
serde_json::json!({
"site_title": "RustIO administration",
"site_header": "RustIO administration",
"index_title": "Site administration",
"footer_copyright": "RustIO test",
"csrf_token": "fake",
"is_demo_session": false,
"demo_label": null,
"page_title": "Add user",
"entries": [],
"admin_name": "users",
"display_name": "Users",
"singular_name": "User",
"mode": "new",
"object_id": null,
"errors": ["Email is required."],
"flash": null,
"identity": { "email": "admin@example.com", "is_admin": true, "is_developer": false },
"sections": [
{
"title": null,
"fields": [
{
"name": "email",
"label": "Email",
"widget": "input",
"input_type": "email",
"value": "",
"hint": null,
"placeholder": null,
"required": true,
"options": null,
"multiple": false,
"span": 1,
"autocomplete": null,
"autofocus": false,
"disabled": false,
"maxlength": null,
"searchable": false,
"has_more": false,
"search_url": null,
"errors": ["Email is required."],
},
{
"name": "password",
"label": "Password",
"widget": "input",
"input_type": "password",
"value": "",
"hint": null,
"placeholder": null,
"required": true,
"options": null,
"multiple": false,
"span": 1,
"autocomplete": null,
"autofocus": false,
"disabled": false,
"maxlength": null,
"searchable": false,
"has_more": false,
"search_url": null,
"errors": [],
},
],
},
],
})
}
#[test]
fn field_errors_render_under_inputs() {
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &form_with_field_errors_ctx())
.expect("form renders");
assert!(
body.contains(r#"id="error_email""#),
"expected <p id=\"error_email\"> error block, body fragment: {}",
&body[..body.len().min(500)]
);
assert!(
body.contains("Email is required."),
"error message must surface under the email field"
);
assert!(
!body.contains(r#"id="error_password""#),
"no error for password → no error block expected"
);
}
#[test]
fn input_has_aria_invalid_when_error() {
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &form_with_field_errors_ctx())
.expect("form renders");
let email_idx = body.find(r#"name="email""#).expect("email input present");
let email_start = body[..email_idx].rfind('<').expect("tag start");
let email_end = email_idx + body[email_idx..].find('>').expect("tag end");
let email_tag = &body[email_start..email_end];
assert!(
email_tag.contains(r#"aria-invalid="true""#),
"email input must carry aria-invalid=\"true\", got: {email_tag}"
);
let pw_idx = body
.find(r#"name="password""#)
.expect("password input present");
let pw_start = body[..pw_idx].rfind('<').expect("tag start");
let pw_end = pw_idx + body[pw_idx..].find('>').expect("tag end");
let pw_tag = &body[pw_start..pw_end];
assert!(
pw_tag.contains(r#"aria-invalid="false""#),
"password input must carry aria-invalid=\"false\", got: {pw_tag}"
);
}
#[test]
fn aria_describedby_links_correctly() {
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render("admin/form.html", &form_with_field_errors_ctx())
.expect("form renders");
assert!(
body.contains(r#"aria-describedby="error_email""#),
"email input missing aria-describedby anchor"
);
assert!(
body.contains(r#"id="error_email""#),
"error block id must match aria-describedby target"
);
let pw_idx = body
.find(r#"name="password""#)
.expect("password input present");
let pw_end = pw_idx + body[pw_idx..].find('>').expect("tag close");
let pw_tag = &body[pw_idx..pw_end];
assert!(
!pw_tag.contains("aria-describedby"),
"password (no errors) must NOT carry aria-describedby"
);
}
#[test]
fn search_escape_does_not_trigger_cancel() {
let templates = Templates::new(None).expect("embedded templates");
let body = templates
.render(
"admin/login.html",
&serde_json::json!({
"site_title": "RustIO administration",
"site_header": "RustIO administration",
"index_title": "Site administration",
"footer_copyright": "RustIO test",
"csrf_token": "fake",
"is_demo_session": false,
"demo_label": null,
"page_title": "Sign in",
"error": null,
"identity": null,
"sections": [],
}),
)
.expect("login renders");
assert!(
body.contains("e.stopPropagation()"),
"search-input Esc handler must call stopPropagation()"
);
assert!(
body.contains(r#"querySelector("[data-cancel]")"#),
"global Esc handler must target [data-cancel] anchors"
);
assert!(
body.contains(r#"!e.target.matches("input, textarea")"#),
"global Esc must be guarded against input/textarea targets"
);
}
#[test]
fn empty_state_has_add_button() {
let templates = Templates::new(None).expect("embedded templates");
let ctx = empty_list_ctx_skeleton();
let body = templates
.render("admin/list.html", &ctx)
.expect("list renders");
assert!(
body.contains("btn-primary"),
"empty-state CTA must use btn-primary"
);
assert!(
body.contains(r#"href="/admin/posts/new""#),
"empty-state CTA must link to /admin/<name>/new"
);
}
#[tokio::test]
async fn search_db_failure_safe() {
static AUTHOR_FIELDS: &[crate::admin::AdminField] = &[];
let mut admin = Admin::new();
admin
.entries
.push(crate::admin::types::AdminEntry::for_testing_failing_list(
"authors", "Authors", "Author", "authors", AUTHOR_FIELDS,
));
let db = crate::orm::Db::for_testing_no_connection();
let opts = search_options(&admin, &db, "Author", "alice")
.await
.expect("search_options must NOT bubble the list() Err");
assert!(
opts.is_empty(),
"FailingOps list() should fall through to empty Vec, got {n} options",
n = opts.len()
);
let opts = search_options(&admin, &db, "DoesNotExist", "alice")
.await
.expect("unknown model must NOT error");
assert!(opts.is_empty(), "unknown model should return empty Vec");
}
#[test]
fn search_query_truncated() {
let huge = "a".repeat(100_000);
let truncated = truncate_query(&huge);
assert_eq!(
truncated.chars().count(),
MAX_SEARCH_QUERY_CHARS,
"ASCII query must truncate to MAX_SEARCH_QUERY_CHARS chars"
);
assert_eq!(
truncated.len(),
MAX_SEARCH_QUERY_CHARS,
"ASCII path: byte count == char count"
);
let emoji = "\u{1F600}".repeat(1_000); let truncated = truncate_query(&emoji);
assert_eq!(
truncated.chars().count(),
MAX_SEARCH_QUERY_CHARS,
"multi-byte query must truncate by char count, not bytes"
);
assert_eq!(
truncated.len(),
MAX_SEARCH_QUERY_CHARS * 4,
"byte count = chars * UTF-8 width"
);
assert_eq!(truncate_query("alice"), "alice");
assert_eq!(truncate_query(""), "");
}
#[test]
fn slug_field_has_placeholder_and_hint() {
static SLUG_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "slug",
label: "slug",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", SLUG_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
None,
);
let slug = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "slug")
.expect("slug field present");
assert_eq!(slug.placeholder.as_deref(), Some("my-post-title"));
assert_eq!(slug.hint.as_deref(), Some("URL-friendly identifier"));
}
#[test]
fn status_field_renders_select_with_synthesized_options() {
static STATUS_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "status",
label: "status",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
];
let admin = Admin::new();
let entry =
AdminEntry::for_testing("posts", "Posts", "Post", "posts", STATUS_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
None,
);
let status = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "status")
.expect("status field present");
assert_eq!(status.widget, "select");
let opts = status
.options
.as_ref()
.expect("status field has synthesised options");
let labels: Vec<&str> = opts.iter().map(|o| o.label.as_str()).collect();
assert_eq!(labels, vec!["draft", "published"]);
}
#[test]
fn fk_field_has_select_model_placeholder_and_target_model() {
static FK_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "author_id",
label: "author_id",
field_type: FieldType::I64,
editable: true,
relation: Some(crate::admin::AdminRelation {
target_model: "User",
display_field: Some("email"),
multi: false,
}),
choices: None,
},
];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", FK_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let ctx = form_ctx(
&ident,
&admin,
&entry,
"new",
None,
None,
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
None,
);
let fk = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "author_id")
.expect("author_id field present");
assert_eq!(fk.placeholder.as_deref(), Some("Select User…"));
assert_eq!(fk.target_model.as_deref(), Some("User"));
}
#[test]
fn form_ctx_prefers_submitted_over_existing() {
static POST_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "is_active",
label: "is_active",
field_type: FieldType::Bool,
editable: true,
relation: None,
choices: None,
},
];
let admin = Admin::new();
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", POST_FIELDS, false);
let ident = fake_identity(Role::Administrator);
let existing = EditRow {
id: 7,
values: vec![
("title".into(), "DB title".into()),
("is_active".into(), "true".into()),
],
};
let form = FormData::from_urlencoded("title=Pending+Edit");
let ctx = form_ctx(
&ident,
&admin,
&entry,
"edit",
Some(7),
Some(&existing),
vec![],
"csrf".into(),
HashMap::new(),
HashMap::new(),
Some(&form),
);
let title = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "title")
.expect("title field present");
assert_eq!(
title.value, "Pending Edit",
"submitted value must override `existing` for `title`"
);
let active = ctx
.sections
.iter()
.flat_map(|s| s.fields.iter())
.find(|f| f.name == "is_active")
.expect("is_active field present");
assert_eq!(
active.value, "",
"absent checkbox in submission must render as empty (unchecked), not the DB's `true`"
);
}
#[test]
fn bucket_errors_by_label_routes_by_field() {
static POST_FIELDS: &[crate::admin::AdminField] = &[
crate::admin::AdminField {
name: "title",
label: "title",
field_type: FieldType::String,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "is_active",
label: "is_active",
field_type: FieldType::Bool,
editable: true,
relation: None,
choices: None,
},
crate::admin::AdminField {
name: "priority",
label: "priority",
field_type: FieldType::I32,
editable: true,
relation: None,
choices: None,
},
];
let entry = AdminEntry::for_testing("posts", "Posts", "Post", "posts", POST_FIELDS, false);
let (global, per_field) = bucket_errors_by_label(
&entry,
vec![
"Title is required.".into(),
"Priority must be a number.".into(),
"Some opaque error not tied to a field.".into(),
],
);
assert_eq!(
per_field.get("title").map(Vec::as_slice),
Some(&["Title is required.".to_string()][..]),
);
assert_eq!(
per_field.get("priority").map(Vec::as_slice),
Some(&["Priority must be a number.".to_string()][..]),
);
assert!(
!per_field.contains_key("is_active"),
"is_active had no error, must not appear in per_field map"
);
assert_eq!(
global,
vec!["Some opaque error not tied to a field.".to_string()],
"unparseable errors must fall through to the global banner"
);
}
}