Skip to main content

armature_admin/
render.rs

1//! HTML rendering for admin views.
2//!
3//! These functions turn the view data structures (built from the model
4//! registry + [`crate::data::DataSource`]) into complete HTML documents so the
5//! router handlers can respond with real pages.
6
7use crate::config::AdminConfig;
8use crate::dashboard::DashboardView;
9use crate::ui::generate_admin_css;
10use crate::views::{CreateView, DetailView, EditView, FormField, ListView};
11
12/// Escape a string for safe inclusion in HTML text/attributes.
13pub(crate) fn escape(s: &str) -> String {
14    s.replace('&', "&")
15        .replace('<', "&lt;")
16        .replace('>', "&gt;")
17        .replace('"', "&quot;")
18        .replace('\'', "&#39;")
19}
20
21/// Wrap body content in a full HTML document with the themed admin stylesheet.
22fn page(config: &AdminConfig, title: &str, body: &str) -> String {
23    // Favicon link when configured.
24    let favicon = match &config.favicon_url {
25        Some(url) => format!("<link rel=\"icon\" href=\"{}\">\n", escape(url)),
26        None => String::new(),
27    };
28
29    // Optional operator-supplied CSS, injected after the themed stylesheet so it
30    // can override the defaults.
31    let custom_css = match &config.custom_css {
32        Some(css) => format!("<style>{css}</style>\n"),
33        None => String::new(),
34    };
35
36    // Header logo when configured.
37    let logo = match &config.logo_url {
38        Some(url) => format!(
39            "<img class=\"admin-logo\" src=\"{}\" alt=\"{}\">",
40            escape(url),
41            escape(&config.title)
42        ),
43        None => String::new(),
44    };
45
46    // Page footer when configured.
47    let footer = match &config.footer_text {
48        Some(text) => format!("\n<footer class=\"admin-footer\">{}</footer>", escape(text)),
49        None => String::new(),
50    };
51
52    // Optional operator-supplied JS, injected near the end of the body.
53    let custom_js = match &config.custom_js {
54        Some(js) => format!("\n<script>{js}</script>"),
55        None => String::new(),
56    };
57
58    format!(
59        "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
60         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
61         <title>{title} · {app}</title>\n{favicon}<style>{css}</style>\n{custom_css}</head>\n\
62         <body>\n<header class=\"admin-header\">{logo}</header>\n\
63         <div class=\"admin-content\" data-admin-root>\n{body}\n</div>{footer}{custom_js}\n</body>\n</html>",
64        title = escape(title),
65        app = escape(&config.title),
66        favicon = favicon,
67        css = generate_admin_css(&config.theme),
68        custom_css = custom_css,
69        logo = logo,
70        body = body,
71        footer = footer,
72        custom_js = custom_js,
73    )
74}
75
76fn breadcrumbs_html(crumbs: &[crate::ui::Breadcrumb]) -> String {
77    let items: Vec<String> = crumbs
78        .iter()
79        .map(|c| match &c.url {
80            Some(url) => format!("<a href=\"{}\">{}</a>", escape(url), escape(&c.label)),
81            None => format!("<span>{}</span>", escape(&c.label)),
82        })
83        .collect();
84    format!(
85        "<nav class=\"admin-breadcrumbs\">{}</nav>",
86        items.join(" / ")
87    )
88}
89
90/// Render the dashboard page.
91pub fn render_dashboard(view: &DashboardView, config: &AdminConfig) -> String {
92    let stats: Vec<String> = view
93        .stats
94        .iter()
95        .map(|s| {
96            format!(
97                "<div class=\"admin-card admin-stat\"><span class=\"stat-title\">{}</span>\
98                 <span class=\"stat-value\">{}</span></div>",
99                escape(&s.title),
100                escape(&s.value)
101            )
102        })
103        .collect();
104
105    let models: Vec<String> = view
106        .model_summaries
107        .iter()
108        .map(|m| {
109            format!(
110                "<li><a href=\"{}\">{}</a> <span class=\"count\">{}</span></li>",
111                escape(&m.url),
112                escape(&m.verbose_name),
113                m.count
114            )
115        })
116        .collect();
117
118    let actions: Vec<String> = view
119        .quick_actions
120        .iter()
121        .map(|a| {
122            format!(
123                "<a class=\"admin-btn admin-btn-primary\" href=\"{}\">{}</a>",
124                escape(&a.url),
125                escape(&a.label)
126            )
127        })
128        .collect();
129
130    let body = format!(
131        "<h1>{title}</h1>\n<div class=\"admin-stats-row\">{stats}</div>\n\
132         <div class=\"admin-quick-actions\">{actions}</div>\n\
133         <h2>Models</h2>\n<ul class=\"admin-model-list\">{models}</ul>",
134        title = escape(&view.title),
135        stats = stats.join(""),
136        actions = actions.join(""),
137        models = models.join(""),
138    );
139    page(config, &view.title, &body)
140}
141
142/// Render a model list page (table of rows + pagination).
143pub fn render_list(view: &ListView, config: &AdminConfig) -> String {
144    let headers: Vec<String> = view
145        .columns
146        .iter()
147        .map(|c| format!("<th>{}</th>", escape(&c.label)))
148        .collect();
149
150    let rows: Vec<String> = view
151        .rows
152        .iter()
153        .map(|row| {
154            let cells: Vec<String> = row
155                .cells
156                .iter()
157                .map(|cell| format!("<td>{}</td>", cell.rendered))
158                .collect();
159            let view_url = format!(
160                "{}/{}/{}",
161                config.base_path,
162                view.model_name,
163                escape(&row.id)
164            );
165            format!(
166                "<tr>{}<td><a href=\"{}\">View</a></td></tr>",
167                cells.join(""),
168                view_url
169            )
170        })
171        .collect();
172
173    let rows_html = if rows.is_empty() {
174        format!(
175            "<tr><td colspan=\"{}\" class=\"admin-empty\">No records</td></tr>",
176            view.columns.len() + 1
177        )
178    } else {
179        rows.join("")
180    };
181
182    let p = &view.pagination;
183    let pagination = format!(
184        "<div class=\"admin-pagination\">Page {} of {} · {} items (showing {}–{})</div>",
185        p.page, p.total_pages, p.total_items, p.start_item, p.end_item
186    );
187
188    let add = if view.can_add {
189        format!(
190            "<a class=\"admin-btn admin-btn-primary\" href=\"{}\">Add {}</a>",
191            escape(&view.add_url),
192            escape(&view.verbose_name)
193        )
194    } else {
195        String::new()
196    };
197
198    // The list route this view was rendered from, used as the target for the
199    // search form and export link.
200    let list_url = format!("{}/{}", config.base_path, view.model_name);
201
202    // A search box that submits back to the list route with a `?search=` query
203    // param (the key `list_params_from_request` reads).
204    let search = if config.enable_search {
205        format!(
206            "<form class=\"admin-search\" method=\"get\" action=\"{}\">\
207             <input class=\"admin-input\" type=\"search\" name=\"search\" placeholder=\"Search\">\
208             <button class=\"admin-btn\" type=\"submit\">Search</button></form>",
209            escape(&list_url)
210        )
211    } else {
212        String::new()
213    };
214
215    // An export link pointing at the list route with `?export=csv`.
216    let export = if config.enable_export {
217        format!(
218            "<a class=\"admin-btn admin-export\" href=\"{}?export=csv\">Export</a>",
219            escape(&list_url)
220        )
221    } else {
222        String::new()
223    };
224
225    let body = format!(
226        "{crumbs}\n<div class=\"admin-list-header\"><h1>{title}</h1>{search}{export}{add}</div>\n\
227         <table class=\"admin-table\"><thead><tr>{headers}<th>Actions</th></tr></thead>\
228         <tbody>{rows}</tbody></table>\n{pagination}",
229        crumbs = breadcrumbs_html(&view.breadcrumbs),
230        title = escape(&view.title),
231        search = search,
232        export = export,
233        add = add,
234        headers = headers.join(""),
235        rows = rows_html,
236        pagination = pagination,
237    );
238    page(config, &view.title, &body)
239}
240
241/// Render a record detail page.
242pub fn render_detail(view: &DetailView, config: &AdminConfig) -> String {
243    let fields: Vec<String> = view
244        .fields
245        .iter()
246        .map(|f| {
247            format!(
248                "<div class=\"admin-field\"><span class=\"field-label\">{}</span>\
249                 <span class=\"field-value\">{}</span></div>",
250                escape(&f.label),
251                f.rendered
252            )
253        })
254        .collect();
255
256    let fields_html = if fields.is_empty() {
257        "<p class=\"admin-empty\">No data</p>".to_string()
258    } else {
259        fields.join("")
260    };
261
262    let mut actions = format!(
263        "<a class=\"admin-btn\" href=\"{}\">Back to list</a>",
264        escape(&view.list_url)
265    );
266    if view.can_edit {
267        actions.push_str(&format!(
268            "<a class=\"admin-btn admin-btn-primary\" href=\"{}\">Edit</a>",
269            escape(&view.edit_url)
270        ));
271    }
272    if view.can_delete {
273        actions.push_str(&format!(
274            "<form method=\"post\" action=\"{}\" class=\"admin-inline-form\">\
275             <button class=\"admin-btn admin-btn-danger\" type=\"submit\">Delete</button></form>",
276            escape(&view.delete_url)
277        ));
278    }
279
280    let body = format!(
281        "{crumbs}\n<h1>{title}</h1>\n<div class=\"admin-card\">{fields}</div>\n\
282         <div class=\"admin-actions\">{actions}</div>",
283        crumbs = breadcrumbs_html(&view.breadcrumbs),
284        title = escape(&view.title),
285        fields = fields_html,
286        actions = actions,
287    );
288    page(config, &view.title, &body)
289}
290
291fn form_input(field: &FormField) -> String {
292    let value = match &field.value {
293        serde_json::Value::Null => String::new(),
294        serde_json::Value::String(s) => escape(s),
295        other => escape(&other.to_string()),
296    };
297    let required = if field.required { " required" } else { "" };
298    let input = if field.widget.contains("textarea") {
299        format!(
300            "<textarea class=\"admin-input\" name=\"{}\"{}>{}</textarea>",
301            escape(&field.name),
302            required,
303            value
304        )
305    } else {
306        format!(
307            "<input class=\"admin-input\" name=\"{}\" value=\"{}\"{}>",
308            escape(&field.name),
309            value,
310            required
311        )
312    };
313    format!(
314        "<div class=\"admin-form-field\"><label>{}</label>{}</div>",
315        escape(&field.label),
316        input
317    )
318}
319
320/// Render the create form.
321pub fn render_create(view: &CreateView, config: &AdminConfig) -> String {
322    let fields: Vec<String> = view.fields.iter().map(form_input).collect();
323    let body = format!(
324        "{crumbs}\n<h1>{title}</h1>\n\
325         <form method=\"post\" action=\"{action}\" class=\"admin-card admin-form\">\
326         {fields}<div class=\"admin-actions\">\
327         <a class=\"admin-btn\" href=\"{cancel}\">Cancel</a>\
328         <button class=\"admin-btn admin-btn-primary\" type=\"submit\">Save</button></div></form>",
329        crumbs = breadcrumbs_html(&view.breadcrumbs),
330        title = escape(&view.title),
331        action = escape(&view.submit_url),
332        fields = fields.join(""),
333        cancel = escape(&view.cancel_url),
334    );
335    page(config, &view.title, &body)
336}
337
338/// Render the edit form.
339pub fn render_edit(view: &EditView, config: &AdminConfig) -> String {
340    let fields: Vec<String> = view.fields.iter().map(form_input).collect();
341    let delete = if view.can_delete {
342        format!(
343            "<form method=\"post\" action=\"{}\" class=\"admin-inline-form\">\
344             <button class=\"admin-btn admin-btn-danger\" type=\"submit\">Delete</button></form>",
345            escape(&view.delete_url)
346        )
347    } else {
348        String::new()
349    };
350    let body = format!(
351        "{crumbs}\n<h1>{title}</h1>\n\
352         <form method=\"post\" action=\"{action}\" class=\"admin-card admin-form\">\
353         {fields}<div class=\"admin-actions\">\
354         <a class=\"admin-btn\" href=\"{cancel}\">Cancel</a>\
355         <button class=\"admin-btn admin-btn-primary\" type=\"submit\">Save</button></div></form>\n{delete}",
356        crumbs = breadcrumbs_html(&view.breadcrumbs),
357        title = escape(&view.title),
358        action = escape(&view.submit_url),
359        fields = fields.join(""),
360        cancel = escape(&view.cancel_url),
361        delete = delete,
362    );
363    page(config, &view.title, &body)
364}
365
366/// A minimal unauthorized page (used when `require_auth` blocks a request).
367pub fn render_unauthorized(config: &AdminConfig) -> String {
368    page(
369        config,
370        "Unauthorized",
371        "<h1>401 Unauthorized</h1><p>Authentication is required to access this admin dashboard.</p>",
372    )
373}
374
375/// A minimal error page for failed mutations.
376pub fn render_error(config: &AdminConfig, message: &str) -> String {
377    page(
378        config,
379        "Error",
380        &format!("<h1>Request failed</h1><p>{}</p>", escape(message)),
381    )
382}
383
384/// A minimal not-found page.
385pub fn render_not_found(config: &AdminConfig) -> String {
386    page(
387        config,
388        "Not Found",
389        "<h1>404 Not Found</h1><p>The requested resource does not exist.</p>",
390    )
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::ListParams;
397    use crate::field::{FieldDefinition, FieldType};
398    use crate::model::ModelDefinition;
399
400    fn sample_list_view() -> ListView {
401        let model = ModelDefinition::builder("user")
402            .id_field()
403            .field(FieldDefinition::new("name", FieldType::String))
404            .list_display(["id", "name"])
405            .build();
406        ListView::new(&model, ListParams::default(), 25)
407    }
408
409    /// The display/config knobs must be genuinely consumed by rendering: when
410    /// set they appear in the HTML, when unset they are absent.
411    #[test]
412    fn test_display_knobs_wired_into_html() {
413        let config = AdminConfig {
414            logo_url: Some("https://cdn.example.com/logo.png".to_string()),
415            favicon_url: Some("https://cdn.example.com/favicon.ico".to_string()),
416            custom_css: Some(".admin-header{background:hotpink}".to_string()),
417            custom_js: Some("console.log('admin loaded');".to_string()),
418            footer_text: Some("© 2026 Example Corp".to_string()),
419            enable_search: true,
420            enable_export: true,
421            ..AdminConfig::default()
422        };
423
424        let html = render_list(&sample_list_view(), &config);
425
426        // Favicon + logo.
427        assert!(html.contains("<link rel=\"icon\" href=\"https://cdn.example.com/favicon.ico\">"));
428        assert!(html.contains("src=\"https://cdn.example.com/logo.png\""));
429        assert!(html.contains("admin-logo"));
430        // Custom CSS + JS.
431        assert!(html.contains(".admin-header{background:hotpink}"));
432        assert!(html.contains("console.log('admin loaded');"));
433        // Footer.
434        assert!(html.contains("admin-footer"));
435        assert!(html.contains("© 2026 Example Corp"));
436        // Search + export controls on the list view.
437        assert!(html.contains("admin-search"));
438        assert!(html.contains("name=\"search\""));
439        assert!(html.contains("admin-export"));
440        assert!(html.contains("?export=csv"));
441    }
442
443    /// Unset knobs must not leak markup into the page.
444    #[test]
445    fn test_display_knobs_absent_when_unset() {
446        let config = AdminConfig {
447            enable_search: false,
448            enable_export: false,
449            ..AdminConfig::default()
450        };
451        // Defaults leave logo/favicon/css/js/footer as None.
452
453        let html = render_list(&sample_list_view(), &config);
454
455        assert!(!html.contains("<link rel=\"icon\""));
456        assert!(!html.contains("admin-logo"));
457        assert!(!html.contains("admin-footer"));
458        assert!(!html.contains("admin-search"));
459        assert!(!html.contains("admin-export"));
460    }
461}