use std::collections::HashMap;
use std::fmt::Write as _;
use crate::core::{inventory, FieldSchema, Join, ModelEntry, ModelSchema, Relation};
use crate::sql::sqlx;
use super::render;
#[allow(unused_imports)]
use super::templates::render_template;
use super::urls::AppState;
pub(crate) type FkMap = HashMap<(String, String), String>;
pub(crate) fn chrome_context(state: &AppState, active_table: Option<&str>) -> serde_json::Value {
serde_json::json!({
"sidebar_groups": sidebar_context(state, active_table),
"active_table": active_table.unwrap_or(""),
"admin_title": state.config.title.as_deref().unwrap_or("rustango admin"),
"admin_subtitle": state.config.subtitle.as_deref(),
})
}
pub(crate) fn sidebar_context(
state: &AppState,
active_table: Option<&str>,
) -> Vec<serde_json::Value> {
let mut entries: Vec<&'static ModelEntry> = inventory::iter::<ModelEntry>
.into_iter()
.filter(|e| state.is_visible(e.schema.table))
.collect();
entries.sort_by_key(|e| e.schema.name);
let mut by_app: indexmap::IndexMap<String, Vec<&'static ModelEntry>> =
indexmap::IndexMap::new();
for e in entries {
let label = e
.resolved_app_label()
.map_or_else(|| "Project".to_owned(), str::to_owned);
by_app.entry(label).or_default().push(e);
}
let mut groups: Vec<(String, Vec<&'static ModelEntry>)> = by_app.into_iter().collect();
groups.sort_by(|a, b| match (a.0.as_str(), b.0.as_str()) {
("Project", _) => std::cmp::Ordering::Greater,
(_, "Project") => std::cmp::Ordering::Less,
_ => a.0.cmp(&b.0),
});
groups
.into_iter()
.map(|(label, items)| {
let models: Vec<serde_json::Value> = items
.into_iter()
.map(|e| {
serde_json::json!({
"name": e.schema.name,
"table": e.schema.table,
"active": active_table == Some(e.schema.table),
})
})
.collect();
serde_json::json!({ "app": label, "models": models })
})
.collect()
}
pub(crate) fn lookup_model(state: &AppState, table: &str) -> Option<&'static ModelSchema> {
if !state.is_visible(table) {
return None;
}
inventory::iter::<ModelEntry>
.into_iter()
.find(|e| e.schema.table == table)
.map(|e| e.schema)
}
pub(crate) fn build_fk_joins(state: &AppState, model: &'static ModelSchema) -> Vec<Join> {
let mut joins = Vec::new();
for field in model.scalar_fields() {
let Some(rel) = field.relation else { continue };
let (to, on) = match rel {
Relation::Fk { to, on } | Relation::O2O { to, on } => (to, on),
Relation::M2M { .. } => continue,
};
let Some(target) = lookup_model(state, to) else {
continue;
};
let Some(display_field) = target.display_field() else {
continue;
};
joins.push(Join {
target,
on_local: field.column,
on_remote: on,
alias: field.name,
project: vec![display_field.column],
});
}
joins
}
pub(crate) fn fk_map_from_joined_rows(
state: &AppState,
model: &'static ModelSchema,
rows: &[sqlx::postgres::PgRow],
) -> FkMap {
let mut map: FkMap = HashMap::new();
for field in model.scalar_fields() {
let Some(rel) = field.relation else { continue };
let to = match rel {
Relation::Fk { to, .. } | Relation::O2O { to, .. } => to,
Relation::M2M { .. } => continue,
};
let Some(target) = lookup_model(state, to) else {
continue;
};
let Some(display_field) = target.display_field() else {
continue;
};
for row in rows {
let Some(source) = render::read_value_as_string(row, field) else {
continue;
};
let Some(display) = render::read_joined_value_as_html(row, field.name, display_field)
else {
continue;
};
map.insert((to.to_owned(), source), display);
}
}
map
}
pub(crate) fn pager_suffix(q: Option<&str>, filters: &[(&'static str, String)]) -> String {
let mut out = String::new();
if let Some(qs) = q {
out.push_str("&q=");
out.push_str(&url_encode(qs));
}
for (k, v) in filters {
out.push('&');
out.push_str(k);
out.push('=');
out.push_str(&url_encode(v));
}
out
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
let safe = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
if safe {
out.push(byte as char);
} else {
let _ = write!(out, "%{byte:02X}");
}
}
out
}
pub(crate) fn render_cell(
row: &sqlx::postgres::PgRow,
field: &FieldSchema,
fk_map: &FkMap,
) -> String {
if let Some(rel) = field.relation {
let to = match rel {
Relation::Fk { to, .. } | Relation::O2O { to, .. } => Some(to),
Relation::M2M { .. } => None,
};
if let Some(to) = to {
let Some(raw_value) = render::read_value_as_string(row, field) else {
return "<em>NULL</em>".to_owned();
};
let raw_esc = render::escape(&raw_value);
let to_esc = render::escape(to);
return match fk_map.get(&(to.to_owned(), raw_value)) {
Some(display) => format!(r#"<a href="/{to_esc}/{raw_esc}">{display}</a>"#),
None => raw_esc,
};
}
}
render::render_value(row, field)
}
pub(crate) fn render_form(
state: &AppState,
model: &'static ModelSchema,
prefill: Option<&HashMap<String, String>>,
pk_locked: bool,
error_msg: Option<&str>,
) -> String {
let (action, edit_pk) = if pk_locked {
let pk_field = model.primary_key().expect("pk_locked requires a PK");
let pk_value = prefill
.and_then(|m| m.get(pk_field.name).cloned())
.unwrap_or_default();
(
format!("/__admin/{}/{}", model.table, render::escape(&pk_value)),
Some(pk_value),
)
} else {
(format!("/__admin/{}", model.table), None)
};
let title = if pk_locked {
format!("Edit {}", model.name)
} else {
format!("New {}", model.name)
};
let admin_cfg = model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT);
let row_for_field = |f: &'static FieldSchema| -> serde_json::Value {
let value = prefill
.and_then(|m| m.get(f.name))
.map_or("", String::as_str);
let is_readonly_field =
admin_cfg.readonly_fields.iter().any(|n| *n == f.name);
let extra = if f.primary_key {
" <small>(pk)</small>"
} else if is_readonly_field {
" <small>read-only</small>"
} else if !f.nullable {
" <small>required</small>"
} else {
""
};
let lock_input = pk_locked && (f.primary_key || is_readonly_field);
serde_json::json!({
"label": f.name,
"extra": extra,
"input": render::render_input(f, value, lock_input),
})
};
let visible = |f: &&'static FieldSchema| -> bool {
!(f.auto && f.primary_key && !pk_locked)
};
let fieldsets_ctx: Vec<serde_json::Value> = if admin_cfg.fieldsets.is_empty() {
let rows: Vec<serde_json::Value> = model
.scalar_fields()
.filter(visible)
.map(row_for_field)
.collect();
vec![serde_json::json!({ "title": "", "rows": rows })]
} else {
admin_cfg
.fieldsets
.iter()
.map(|set| {
let rows: Vec<serde_json::Value> = set
.fields
.iter()
.filter_map(|name| model.field(name))
.filter(visible)
.map(row_for_field)
.collect();
serde_json::json!({ "title": set.title, "rows": rows })
})
.collect()
};
let mut ctx = serde_json::json!({
"model": { "name": model.name, "table": model.table },
"title": title,
"action": action,
"edit_pk": edit_pk,
"error": error_msg,
"fieldsets": fieldsets_ctx,
});
super::templates::render_with_chrome(
"form.html",
&mut ctx,
chrome_context(state, Some(model.table)),
)
}