rustio_core/admin/auto_form.rs
1//! Auto form generation — derive a [`FormConfig`] from a Rust type
2//! that implements [`FormModel`], no manual field-by-field wiring.
3//!
4//! Callers describe their model once (as a `Vec<AutoField>` in
5//! `form_fields`), and [`form_from_model`] or the fluent
6//! [`FormBuilder`] produce the same [`FormConfig`] the manual
7//! engine consumes. The existing [`crate::admin::form`] renderer is
8//! untouched — this module is purely additive.
9//!
10//! No macros, no runtime reflection, no DB introspection. Pure typed
11//! metadata supplied by the caller.
12
13use crate::admin::form::{infer_field_type, FieldConfig, FieldType, FormConfig};
14
15// ---------------------------------------------------------------
16// Model-side description
17// ---------------------------------------------------------------
18
19/// Declarative description of one field on a model. The caller fills
20/// this in once per column; the converter turns it into a
21/// [`FieldConfig`].
22///
23/// `field_type = None` asks the engine to infer the type from the
24/// field's `name` (via [`infer_field_type`]). `is_foreign_key = true`
25/// always wins — even if `field_type` or inference says otherwise,
26/// the final widget is a `<select>` populated from `options`.
27#[derive(Debug, Clone)]
28pub struct AutoField {
29 pub name: &'static str,
30 pub label: &'static str,
31
32 /// If `None`, [`infer_field_type`] runs against `name`.
33 pub field_type: Option<FieldType>,
34
35 pub required: bool,
36
37 /// Forces [`FieldType::ForeignKey`], overriding both `field_type`
38 /// and inference. The FK is always rendered as a `<select>` with
39 /// `options` supplying human-readable labels.
40 pub is_foreign_key: bool,
41
42 /// `(value, label)` pairs consumed by Select / ForeignKey.
43 pub options: Vec<(String, String)>,
44}
45
46/// Types that can describe their own form shape.
47///
48/// Invoked through the turbofish: `FormBuilder::from_model::<User>()`
49/// — no instance needed, so this composes with unit structs used
50/// purely as type tags.
51pub trait FormModel {
52 fn form_fields() -> Vec<AutoField>;
53 fn form_title() -> &'static str;
54}
55
56// ---------------------------------------------------------------
57// Conversion
58// ---------------------------------------------------------------
59
60/// Build a [`FormConfig`] from a model's [`FormModel`] impl. The
61/// resulting form has no `value`s and no `help` — overrides populate
62/// those via [`FormBuilder::override_field`].
63pub fn form_from_model<T: FormModel>() -> FormConfig {
64 let fields = T::form_fields()
65 .into_iter()
66 .map(field_config_from)
67 .collect();
68
69 FormConfig {
70 title: T::form_title().to_string(),
71 // Subtitle isn't part of the FormModel contract today; a
72 // future extension could derive it from a type name or an
73 // extra trait method. Empty string renders as an empty
74 // `.drawer-subtitle` div, which the approved CSS handles.
75 subtitle: String::new(),
76 fields,
77 submitted: false,
78 save_failed: false,
79 hidden_fields: Vec::new(),
80 }
81}
82
83fn field_config_from(f: AutoField) -> FieldConfig {
84 // 1. Start from the caller's declared type, falling back to name
85 // inference when the caller left it blank.
86 let mut ty = match f.field_type {
87 Some(t) => t,
88 None => infer_field_type(f.name),
89 };
90 // 2. `is_foreign_key` is the hard override — it always wins,
91 // even against a caller-declared `field_type`. Matches the
92 // invariant "FK columns render as dropdowns, never raw ids".
93 if f.is_foreign_key {
94 ty = FieldType::ForeignKey;
95 }
96 FieldConfig {
97 name: f.name.to_string(),
98 label: f.label.to_string(),
99 field_type: ty,
100 required: f.required,
101 readonly: false,
102 placeholder: None,
103 help: None,
104 value: None,
105 options: f.options,
106 error: None,
107 }
108}
109
110// ---------------------------------------------------------------
111// Override / builder
112// ---------------------------------------------------------------
113
114/// Patch applied to a single field after auto-generation. Every
115/// sub-field is `Option` — `None` means "leave as the generated
116/// value".
117#[derive(Debug, Clone, Default)]
118pub struct FieldOverride {
119 pub field_type: Option<FieldType>,
120 pub label: Option<String>,
121 pub help: Option<String>,
122}
123
124/// Fluent builder that starts from an auto-generated [`FormConfig`]
125/// and lets the caller patch individual fields. An
126/// `override_field(name, ...)` call whose `name` doesn't match any
127/// generated field is a silent no-op — the builder intentionally
128/// doesn't fail on typos so it composes with optional overrides from
129/// config files.
130pub struct FormBuilder {
131 pub form: FormConfig,
132}
133
134impl FormBuilder {
135 pub fn from_model<T: FormModel>() -> Self {
136 Self {
137 form: form_from_model::<T>(),
138 }
139 }
140
141 pub fn override_field(mut self, name: &str, patch: FieldOverride) -> Self {
142 if let Some(field) = self.form.fields.iter_mut().find(|f| f.name == name) {
143 if let Some(ty) = patch.field_type {
144 field.field_type = ty;
145 }
146 if let Some(label) = patch.label {
147 field.label = label;
148 }
149 if let Some(help) = patch.help {
150 field.help = Some(help);
151 }
152 }
153 self
154 }
155
156 pub fn build(self) -> FormConfig {
157 self.form
158 }
159}