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}