Skip to main content

rustio_core/admin/
form.rs

1//! Admin-new form engine.
2//!
3//! Turn a [`FormConfig`] of [`FieldConfig`]s into drawer-based form
4//! HTML. Every input type routes through a dedicated renderer that
5//! uses only classnames present in the approved `components.css` —
6//! no new classes, no new styles, no JS, no filesystem, no DB.
7//!
8//! The **ForeignKey rule** is load-bearing: FK fields *always* render
9//! as a `<select>` populated from [`FieldConfig::options`]. Raw numeric
10//! row ids are never presented to the admin user; the caller resolves
11//! `(value, label)` pairs upstream and passes human-readable labels in.
12//!
13//! UI-level validation lives here too: [`validate_form`] walks the
14//! fields, applies a small set of basic rules (required / email /
15//! number), and writes any failure into `FieldConfig::error`. The
16//! renderers add `class="invalid"` to the input and append a
17//! `.field-error` block below — both classes are referenced but **not
18//! styled here**; styling is assumed to live in the bundled CSS.
19
20use std::collections::HashMap;
21
22use crate::admin::ui::html_escape;
23
24// ---------------------------------------------------------------
25// Configuration
26// ---------------------------------------------------------------
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum FieldType {
30    Text,
31    Email,
32    Number,
33    DateTime,
34    Boolean,
35    Select,
36    ForeignKey,
37    TextArea,
38}
39
40#[derive(Debug, Clone)]
41pub struct FieldConfig {
42    pub name: String,
43    pub label: String,
44    pub field_type: FieldType,
45
46    pub required: bool,
47    pub readonly: bool,
48
49    pub placeholder: Option<String>,
50    pub help: Option<String>,
51
52    pub value: Option<String>,
53
54    /// `(value, label)` pairs used by [`FieldType::Select`] and
55    /// [`FieldType::ForeignKey`]. The label is what the user sees;
56    /// the value is what the form submits.
57    pub options: Vec<(String, String)>,
58
59    /// UI-level validation error attached to this field. `Some(msg)`
60    /// causes the renderer to add `class="invalid"` to the input and
61    /// append a `<div class="field-error">{msg}</div>` block below.
62    /// Populated by [`validate_form`] or by callers directly.
63    pub error: Option<String>,
64}
65
66#[derive(Debug, Clone)]
67pub struct FormConfig {
68    pub title: String,
69    pub subtitle: String,
70    pub fields: Vec<FieldConfig>,
71
72    /// `true` once values have been bound from a real submission. The
73    /// renderer uses this to swap the inline error summary for a
74    /// `.form-success` banner when validation passes, so the user
75    /// sees an explicit confirmation rather than a silent re-render.
76    /// Set automatically by [`bind_form`].
77    pub submitted: bool,
78
79    /// `true` if a submission was bound + validated cleanly but the
80    /// downstream persistence write returned an error. The renderer
81    /// shows a `.form-error-summary` "save failed" banner instead of
82    /// the success banner. Independent of `submitted` so callers can
83    /// flip just this one field.
84    pub save_failed: bool,
85
86    /// Extra `<input type="hidden">` fields to emit inside the form,
87    /// in order. Used to round-trip the editing primary key (and any
88    /// other state the route handler wants preserved across a POST)
89    /// without requiring the field to appear in `fields`.
90    pub hidden_fields: Vec<(String, String)>,
91}
92
93// ---------------------------------------------------------------
94// Name-based inference
95// ---------------------------------------------------------------
96
97/// Infer a likely [`FieldType`] from a field name.
98///
99/// Callers that don't know a field's type up front (e.g. auto-built
100/// forms from a schema with only column names) can seed
101/// `FieldConfig::field_type` with this helper's output. Rules mirror
102/// Django-style admin conventions:
103///
104/// - ends with `_id`   → [`FieldType::ForeignKey`]
105/// - starts with `is_` or `has_` → [`FieldType::Boolean`]
106/// - contains `email`  → [`FieldType::Email`]
107/// - contains `amount` or `price` → [`FieldType::Number`]
108/// - otherwise         → [`FieldType::Text`]
109pub fn infer_field_type(name: &str) -> FieldType {
110    let lower = name.to_ascii_lowercase();
111    if lower.ends_with("_id") {
112        return FieldType::ForeignKey;
113    }
114    if lower.starts_with("is_") || lower.starts_with("has_") {
115        return FieldType::Boolean;
116    }
117    if lower.contains("email") {
118        return FieldType::Email;
119    }
120    if lower.contains("amount") || lower.contains("price") {
121        return FieldType::Number;
122    }
123    FieldType::Text
124}
125
126/// Decide which control actually renders. `ForeignKey` stays
127/// `ForeignKey` (rendered as a select internally). `Boolean` stays
128/// `Boolean` (switch) even if the caller passes options — true/false
129/// isn't a dropdown. Every other type with a non-empty `options`
130/// promotes to `Select`.
131fn effective_type(f: &FieldConfig) -> FieldType {
132    match f.field_type {
133        FieldType::ForeignKey | FieldType::Boolean => f.field_type,
134        other if !f.options.is_empty() && other != FieldType::Select => FieldType::Select,
135        other => other,
136    }
137}
138
139// ---------------------------------------------------------------
140// Validation
141// ---------------------------------------------------------------
142
143/// Walk the form's fields and apply the basic validation rules:
144///
145/// - **Required:** empty value on a `required` field → `"This field
146///   is required"`.
147/// - **Email:** non-empty `Email` value missing `@` →
148///   `"Invalid email address"`.
149/// - **Number:** non-empty `Number` value that doesn't parse as
150///   `f64` → `"Must be a valid number"`.
151///
152/// The first failing rule per field wins; the function never panics
153/// or unwraps. Empty optional fields skip the type-specific checks.
154pub fn validate_form(form: &mut FormConfig) {
155    for field in form.fields.iter_mut() {
156        let value = field.value.as_deref().unwrap_or("").trim();
157        if field.required && value.is_empty() {
158            field.error = Some("This field is required".into());
159            continue;
160        }
161        if value.is_empty() {
162            continue;
163        }
164        match field.field_type {
165            FieldType::Email if !value.contains('@') => {
166                field.error = Some("Invalid email address".into());
167            }
168            FieldType::Number if value.parse::<f64>().is_err() => {
169                field.error = Some("Must be a valid number".into());
170            }
171            _ => {}
172        }
173    }
174}
175
176/// Render the `.field-error` block for a single message. Empty
177/// `msg` → empty string, so callers don't need to gate the call.
178pub fn render_error(msg: &str) -> String {
179    if msg.is_empty() {
180        return String::new();
181    }
182    format!(r#"<div class="field-error">{}</div>"#, html_escape(msg))
183}
184
185/// Pull submitted values out of a parsed urlencoded body and write
186/// them into the form's fields.
187///
188/// - Non-Boolean: the field's `value` is set to the matching `params`
189///   entry if present, otherwise left as-is.
190/// - Boolean: HTML form submission omits unchecked checkboxes /
191///   switches entirely, so the rule is *presence-of-key*, not value:
192///   `value = Some(params.contains_key(name).to_string())`.
193///
194/// Pre-existing per-field `error`s are cleared so subsequent calls
195/// to [`validate_form`] start from a clean slate. The form's
196/// `submitted` flag flips to `true` so [`render_form`] can swap the
197/// error summary for the `.form-success` banner when validation
198/// passes.
199///
200/// Never panics; missing keys silently leave non-boolean fields
201/// untouched.
202pub fn bind_form(form: &mut FormConfig, params: &HashMap<String, String>) {
203    form.submitted = true;
204    // A fresh bind clears any previous save failure — the caller
205    // will re-set it from the new persistence outcome.
206    form.save_failed = false;
207    for field in form.fields.iter_mut() {
208        if field.field_type == FieldType::Boolean {
209            // Boolean now ships a real `<input>` pair: a hidden
210            // baseline (`value="false"`) plus a real checkbox
211            // (`value="true"`). When checked, both submit and
212            // last-write-wins parsing yields `"true"`. When
213            // unchecked, only the hidden submits → `"false"`. So
214            // we just read the value through and fall back to
215            // `"false"` for the (now rare) case where neither input
216            // was present in the body at all.
217            field.value = params
218                .get(&field.name)
219                .cloned()
220                .or(Some("false".to_string()));
221        } else if let Some(v) = params.get(&field.name) {
222            field.value = Some(v.clone());
223        }
224        field.error = None;
225    }
226}
227
228// ---------------------------------------------------------------
229// Field renderer
230// ---------------------------------------------------------------
231
232pub fn render_field(f: &FieldConfig) -> String {
233    render_field_inner(f, false)
234}
235
236/// Internal render path — same as [`render_field`] but with an
237/// `autofocus` flag the form-level renderer can flip on for the first
238/// invalid field. Public signature of [`render_field`] is preserved.
239fn render_field_inner(f: &FieldConfig, autofocus: bool) -> String {
240    match effective_type(f) {
241        // Boolean is a switch (a label, not an input element). The
242        // browser cannot autofocus a label, so we ignore the flag for
243        // booleans rather than emit invalid HTML.
244        FieldType::Boolean => render_boolean_field(f),
245        FieldType::Text => wrap_field(f, &render_input(f, "text", false, autofocus)),
246        FieldType::Email => wrap_field(f, &render_input(f, "email", false, autofocus)),
247        FieldType::Number => wrap_field(f, &render_input(f, "number", true, autofocus)),
248        FieldType::DateTime => wrap_field(f, &render_input(f, "datetime-local", false, autofocus)),
249        FieldType::TextArea => wrap_field(f, &render_textarea(f, autofocus)),
250        FieldType::Select | FieldType::ForeignKey => wrap_field(f, &render_select(f, autofocus)),
251    }
252}
253
254fn wrap_field(f: &FieldConfig, input_html: &str) -> String {
255    let req = if f.required {
256        r#"<span class="field-required">*</span>"#
257    } else {
258        ""
259    };
260    let help = match &f.help {
261        Some(h) if !h.is_empty() => {
262            format!(r#"<div class="field-help">{}</div>"#, html_escape(h))
263        }
264        _ => String::new(),
265    };
266    let error = match &f.error {
267        Some(e) if !e.is_empty() => render_error(e),
268        _ => String::new(),
269    };
270    format!(
271        r#"<div class="field">
272  <label class="field-label" for="{id}">{label}{req}</label>
273  {input}
274  {help}
275  {error}
276</div>"#,
277        id = html_escape(&f.name),
278        label = html_escape(&f.label),
279        req = req,
280        input = input_html,
281        help = help,
282        error = error,
283    )
284}
285
286fn render_input(f: &FieldConfig, input_type: &str, mono: bool, autofocus: bool) -> String {
287    let class_attr = match (mono, f.error.is_some()) {
288        (true, true) => r#" class="mono invalid""#.to_string(),
289        (true, false) => r#" class="mono""#.to_string(),
290        (false, true) => r#" class="invalid""#.to_string(),
291        (false, false) => String::new(),
292    };
293    let value_attr = match &f.value {
294        Some(v) if !v.is_empty() => format!(r#" value="{}""#, html_escape(v)),
295        _ => String::new(),
296    };
297    let placeholder_attr = match &f.placeholder {
298        Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, html_escape(p)),
299        _ => String::new(),
300    };
301    let required_attr = if f.required { " required" } else { "" };
302    let readonly_attr = if f.readonly { " readonly" } else { "" };
303    let autofocus_attr = if autofocus { " autofocus" } else { "" };
304    format!(
305        r#"<input type="{ty}" id="{id}" name="{name}"{cls}{val}{ph}{req}{ro}{af}>"#,
306        ty = input_type,
307        id = html_escape(&f.name),
308        name = html_escape(&f.name),
309        cls = class_attr,
310        val = value_attr,
311        ph = placeholder_attr,
312        req = required_attr,
313        ro = readonly_attr,
314        af = autofocus_attr,
315    )
316}
317
318fn render_textarea(f: &FieldConfig, autofocus: bool) -> String {
319    let value = f.value.as_deref().unwrap_or("");
320    let class_attr = if f.error.is_some() {
321        r#" class="mono invalid""#
322    } else {
323        r#" class="mono""#
324    };
325    let placeholder_attr = match &f.placeholder {
326        Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, html_escape(p)),
327        _ => String::new(),
328    };
329    let required_attr = if f.required { " required" } else { "" };
330    let readonly_attr = if f.readonly { " readonly" } else { "" };
331    let autofocus_attr = if autofocus { " autofocus" } else { "" };
332    format!(
333        r#"<textarea id="{id}" name="{name}"{cls}{ph}{req}{ro}{af}>{value}</textarea>"#,
334        id = html_escape(&f.name),
335        name = html_escape(&f.name),
336        cls = class_attr,
337        ph = placeholder_attr,
338        req = required_attr,
339        ro = readonly_attr,
340        af = autofocus_attr,
341        value = html_escape(value),
342    )
343}
344
345fn render_select(f: &FieldConfig, autofocus: bool) -> String {
346    let selected_value = f.value.as_deref().unwrap_or("");
347    let class_attr = if f.error.is_some() {
348        r#" class="invalid""#
349    } else {
350        ""
351    };
352    let required_attr = if f.required { " required" } else { "" };
353    // <select> has no `readonly` attribute; `disabled` is the closest
354    // native equivalent. Disabled selects don't submit, which matches
355    // the intent of "user can't change this value here".
356    let disabled_attr = if f.readonly { " disabled" } else { "" };
357    let autofocus_attr = if autofocus { " autofocus" } else { "" };
358    let mut s = format!(
359        r#"<select id="{id}" name="{name}"{cls}{req}{dis}{af}>"#,
360        id = html_escape(&f.name),
361        name = html_escape(&f.name),
362        cls = class_attr,
363        req = required_attr,
364        dis = disabled_attr,
365        af = autofocus_attr,
366    );
367    for (value, label) in &f.options {
368        let selected = if value == selected_value {
369            " selected"
370        } else {
371            ""
372        };
373        s.push_str(&format!(
374            r#"<option value="{}"{}>{}</option>"#,
375            html_escape(value),
376            selected,
377            html_escape(label),
378        ));
379    }
380    s.push_str("</select>");
381    s
382}
383
384/// Boolean follows the approved components.html pattern: the switch
385/// *is* the field and carries its own visible label via `.switch-label`
386/// — no redundant `.field-label` above it. `.field-help` and the
387/// validation `.field-error` block still render below when supplied.
388///
389/// Two real form controls are injected inside the existing `.switch`
390/// label so the field actually submits data:
391///
392/// 1. A hidden `<input type="hidden" value="false">` always sends a
393///    `false` baseline. This guarantees the field name appears in
394///    every POST body even when the switch is off.
395/// 2. A real `<input type="checkbox" value="true" hidden>` overrides
396///    the baseline when checked — its `value` is sent *after* the
397///    hidden's, and `FormData::parse` uses last-write-wins, so the
398///    final bound value is `"true"` when checked, `"false"` when not.
399///
400/// The `hidden` HTML attribute keeps the checkbox out of the visual
401/// flow — no CSS changes needed, no class names added.
402fn render_boolean_field(f: &FieldConfig) -> String {
403    let on = matches!(f.value.as_deref(), Some("1" | "true" | "on" | "yes"));
404    let on_cls = if on { " on" } else { "" };
405    let checked_attr = if on { " checked" } else { "" };
406    let help = match &f.help {
407        Some(h) if !h.is_empty() => {
408            format!(r#"<div class="field-help">{}</div>"#, html_escape(h))
409        }
410        _ => String::new(),
411    };
412    let error = match &f.error {
413        Some(e) if !e.is_empty() => render_error(e),
414        _ => String::new(),
415    };
416    format!(
417        r#"<div class="field">
418  <label class="switch{cls}">
419    <input type="hidden" name="{name}" value="false">
420    <input type="checkbox" name="{name}" value="true" hidden{checked}>
421    <span class="switch-track"></span>
422    <span class="switch-label">{label}</span>
423  </label>
424  {help}
425  {error}
426</div>"#,
427        cls = on_cls,
428        name = html_escape(&f.name),
429        checked = checked_attr,
430        label = html_escape(&f.label),
431        help = help,
432        error = error,
433    )
434}
435
436// ---------------------------------------------------------------
437// Form renderer
438// ---------------------------------------------------------------
439
440pub fn render_form(form: &FormConfig) -> String {
441    // First-invalid lookup powers two UX behaviours at once: the
442    // `autofocus` attribute on that field's input, and the gating of
443    // the submit button + the inline error summary.
444    let first_invalid = form.fields.iter().position(|f| f.error.is_some());
445    let has_errors = first_invalid.is_some();
446
447    let summary = render_form_banner(form, has_errors);
448
449    let mut body = String::new();
450    body.push_str(&summary);
451    for (i, field) in form.fields.iter().enumerate() {
452        let autofocus = Some(i) == first_invalid;
453        body.push_str(&render_field_inner(field, autofocus));
454    }
455
456    // Hidden state pieces (e.g. the editing `id`) live inside the
457    // form so they round-trip on submit. Caller-controlled order;
458    // values are HTML-escaped.
459    let mut hidden = String::new();
460    for (k, v) in &form.hidden_fields {
461        hidden.push_str(&format!(
462            r#"<input type="hidden" name="{}" value="{}">"#,
463            html_escape(k),
464            html_escape(v),
465        ));
466    }
467
468    let save_disabled_attr = if has_errors { " disabled" } else { "" };
469
470    // The drawer is wrapped in a single `<form data-admin-form>`:
471    //   - Save / Cancel both belong to one POST submission flow.
472    //   - admin.js scopes Esc-to-close + Cmd+Enter-to-submit to
473    //     `[data-admin-form]`, so unrelated UI is unaffected.
474    //   - Backdrop is a sibling element so a click outside the drawer
475    //     closes it via the same `href="?"` cancel target.
476    // Header × and footer Cancel both navigate to `?` — empty query
477    // string drops `?id=` and the browser re-renders the list view
478    // with the drawer hidden. Search / filter / sort state is lost on
479    // cancel; a follow-up could thread the current state through.
480    format!(
481        r#"<a class="drawer-backdrop open" href="?" aria-label="Close drawer"></a>
482<form data-admin-form class="drawer open" action="" method="post">
483  {hidden}
484  <header class="drawer-header">
485    <div class="drawer-title-group">
486      <h2 class="drawer-title">{title}</h2>
487      <div class="drawer-subtitle">{subtitle}</div>
488    </div>
489    <a class="drawer-close" href="?" aria-label="Close">×</a>
490  </header>
491  <div class="drawer-body">
492    {body}
493  </div>
494  <footer class="drawer-footer">
495    <a class="btn btn-ghost" href="?">Cancel</a>
496    <button type="submit" class="btn btn-primary"{save_disabled}>Save changes</button>
497  </footer>
498</form>"#,
499        title = html_escape(&form.title),
500        subtitle = html_escape(&form.subtitle),
501        body = body,
502        hidden = hidden,
503        save_disabled = save_disabled_attr,
504    )
505}
506
507/// Render the banner that appears at the top of the form's body —
508/// validation errors take priority, then save failure, then the
509/// "Saved successfully" success banner. `Pristine` (GET, never
510/// submitted) renders nothing.
511fn render_form_banner(form: &FormConfig, has_errors: bool) -> String {
512    if has_errors {
513        return String::from(
514            r#"<div class="form-banner form-banner-error" role="alert">Please fix the errors below.</div>"#,
515        );
516    }
517    if form.save_failed {
518        return String::from(
519            r#"<div class="form-banner form-banner-error" role="alert">Could not save the record.</div>"#,
520        );
521    }
522    if form.submitted {
523        return String::from(
524            r#"<div class="form-banner form-banner-success" role="status">Changes saved.</div>"#,
525        );
526    }
527    String::new()
528}