Skip to main content

rustio_core/admin/
admin_form_bridge.rs

1//! AdminUiModel → Form bridge + model registry.
2//!
3//! Lifts an admin-level metadata description (column data types,
4//! relations, options) into a [`FormConfig`] the existing form engine
5//! already knows how to render. The mapping is purely declarative and
6//! deterministic: same input → same output, every time.
7//!
8//! The trait + struct here are deliberately named [`AdminUiModel`] /
9//! [`AdminUiField`] (not `AdminModel` / `AdminField`). The framework's
10//! existing admin layer in `crate::admin` already owns the unsuffixed
11//! names with a different shape; the `Ui` suffix keeps the two
12//! vocabularies unambiguous in any glob import.
13//!
14//! As of this step the trait uses **`&self` methods** (object-safe)
15//! so an [`AdminRegistry`] can store `Box<dyn AdminUiModel>` and the
16//! `/admin-new/<slug>` route can pick a model by URL slug.
17
18use std::collections::HashMap;
19
20use crate::admin::auto_form::FormBuilder;
21use crate::admin::form::{FieldConfig, FieldType, FormConfig};
22
23// ---------------------------------------------------------------
24// Admin-level metadata
25// ---------------------------------------------------------------
26
27/// Storage / semantic data type for a column. Translated into a form
28/// [`FieldType`] by [`form_from_admin_ui_model`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum AdminDataType {
31    String,
32    Text,
33    Integer,
34    Float,
35    Boolean,
36    DateTime,
37    Email,
38}
39
40#[derive(Debug, Clone)]
41pub struct AdminUiField {
42    pub name: &'static str,
43    pub label: &'static str,
44
45    pub data_type: AdminDataType,
46
47    pub required: bool,
48    pub readonly: bool,
49
50    /// `true` when the column points at another model (FK). Forces
51    /// the bridge to emit [`FieldType::ForeignKey`] regardless of
52    /// `data_type` — a `<select>` populated from `options` is the
53    /// only correct rendering.
54    pub is_relation: bool,
55
56    /// `(value, label)` pairs supplied for FK / enum-like columns.
57    pub options: Vec<(String, String)>,
58
59    /// `true` → field is rendered as a default filter in the
60    /// toolbar. The control type is decided by [`resolve_filter_type`]
61    /// from the field's `data_type` / `is_relation` / `options`.
62    pub filterable: bool,
63
64    /// `true` → field is offered in the "+ Add filter" advanced
65    /// dropdown, not the always-visible toolbar. A field can set
66    /// neither (no filter), one, or both.
67    pub advanced_filter: bool,
68
69    /// `true` → table column header for this field becomes a
70    /// clickable sort link (`?sort=<name>&dir=asc|desc`). Fields
71    /// with `sortable = false` are silently rejected even if the
72    /// URL asks for them — metadata is the gate.
73    pub sortable: bool,
74
75    /// `true` → column appears in the listing table. `false` keeps
76    /// the field in the form / detail view but hides it from the
77    /// rows. Defaults to `true` for editable columns.
78    pub visible_in_table: bool,
79}
80
81impl AdminUiField {
82    /// Internal helper. All public constructors funnel through this
83    /// so defaults stay in one place: `required = false`,
84    /// `readonly = false`, no relation, no options, no filter, not
85    /// sortable, visible in the listing table. The generator builder
86    /// methods below flip individual flags.
87    fn base(name: &'static str, label: &'static str, data_type: AdminDataType) -> Self {
88        Self {
89            name,
90            label,
91            data_type,
92            required: false,
93            readonly: false,
94            is_relation: false,
95            options: Vec::new(),
96            filterable: false,
97            advanced_filter: false,
98            sortable: false,
99            visible_in_table: true,
100        }
101    }
102
103    pub fn text(name: &'static str, label: &'static str) -> Self {
104        Self::base(name, label, AdminDataType::String)
105    }
106    pub fn textarea(name: &'static str, label: &'static str) -> Self {
107        Self::base(name, label, AdminDataType::Text)
108    }
109    pub fn integer(name: &'static str, label: &'static str) -> Self {
110        Self::base(name, label, AdminDataType::Integer)
111    }
112    pub fn float(name: &'static str, label: &'static str) -> Self {
113        Self::base(name, label, AdminDataType::Float)
114    }
115    pub fn boolean(name: &'static str, label: &'static str) -> Self {
116        Self::base(name, label, AdminDataType::Boolean)
117    }
118    pub fn datetime(name: &'static str, label: &'static str) -> Self {
119        Self::base(name, label, AdminDataType::DateTime)
120    }
121    pub fn email(name: &'static str, label: &'static str) -> Self {
122        Self::base(name, label, AdminDataType::Email)
123    }
124
125    pub fn required(mut self, value: bool) -> Self {
126        self.required = value;
127        self
128    }
129    pub fn readonly(mut self, value: bool) -> Self {
130        self.readonly = value;
131        self
132    }
133    pub fn relation(mut self, value: bool) -> Self {
134        self.is_relation = value;
135        self
136    }
137    pub fn options(mut self, options: Vec<(String, String)>) -> Self {
138        self.options = options;
139        self
140    }
141    pub fn filterable(mut self, value: bool) -> Self {
142        self.filterable = value;
143        self
144    }
145    pub fn advanced_filter(mut self, value: bool) -> Self {
146        self.advanced_filter = value;
147        self
148    }
149    pub fn sortable(mut self, value: bool) -> Self {
150        self.sortable = value;
151        self
152    }
153    pub fn visible_in_table(mut self, value: bool) -> Self {
154        self.visible_in_table = value;
155        self
156    }
157}
158
159/// How a filter input should render and how SQL should query it.
160/// Resolved from [`AdminUiField`] via [`resolve_filter_type`].
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum FilterType {
163    /// Boolean column → tri-state `<select>` (All / true / false),
164    /// SQL: `column = ?`.
165    Boolean,
166    /// Enum-like / FK column → `<select>` populated from `options`,
167    /// SQL: `column = ?`.
168    Select,
169    /// Free-text column → `<input type="text">`,
170    /// SQL: `LOWER(column) LIKE ?` with `%value%`.
171    Exact,
172}
173
174/// Decide which control + SQL operator a filter on `field` should
175/// use. Pure function — does not look at any data, only metadata.
176pub fn resolve_filter_type(field: &AdminUiField) -> FilterType {
177    if field.data_type == AdminDataType::Boolean {
178        FilterType::Boolean
179    } else if field.is_relation || !field.options.is_empty() {
180        FilterType::Select
181    } else {
182        FilterType::Exact
183    }
184}
185
186/// A model that can describe its admin-UI shape (display name +
187/// table mapping + column list + searchable / sortable / filterable
188/// / status semantics).
189///
190/// **Object-safe** (`&self` methods + `Send + Sync + 'static`) so
191/// implementations can be stored as `Box<dyn AdminUiModel>` in
192/// [`AdminRegistry`] for dynamic-by-URL-slug dispatch.
193pub trait AdminUiModel: Send + Sync + 'static {
194    /// URL slug used as the `:model` path segment, e.g. `"users"` →
195    /// `/admin/users`. Must be unique within a registry.
196    fn slug(&self) -> &'static str;
197
198    /// Human-readable display name shown in subtitles / banners,
199    /// e.g. `"User"`. Used in `format!("{} · {} records", …)`.
200    fn model_name(&self) -> &'static str;
201
202    /// SQL table name. `quote_ident` is applied by the persistence
203    /// layer, so callers can safely pass a static identifier.
204    fn table_name(&self) -> &'static str;
205
206    /// Primary-key column name. Used by the persistence layer for
207    /// `WHERE pk = ?` lookups and by the form engine to skip the
208    /// PK from auto-generated INSERT / UPDATE column maps.
209    fn primary_key(&self) -> &'static str;
210
211    fn fields(&self) -> Vec<AdminUiField>;
212
213    /// Field names participating in free-text search (`?q=…`).
214    /// Persistence emits a single `OR`-clause across these columns
215    /// with `LOWER(col) LIKE ?`.
216    fn searchable_fields(&self) -> Vec<&'static str>;
217
218    /// Boolean column the bulk Activate/Deactivate actions flip
219    /// (typically `is_active`). `None` disables those bulk actions
220    /// for the model — Delete still works since it doesn't depend on
221    /// a status column.
222    fn primary_status_field(&self) -> Option<&'static str>;
223
224    /// Optional `CREATE TABLE IF NOT EXISTS …` statement run on
225    /// every request. Returning `None` skips auto-creation (caller
226    /// is responsible for migrations). Idempotent SQL is required.
227    fn ensure_table_sql(&self) -> Option<&'static str>;
228}
229
230// ---------------------------------------------------------------
231// Conversion
232// ---------------------------------------------------------------
233
234/// Build a [`FormConfig`] from an [`AdminUiModel`] impl. The drawer
235/// title becomes `"Edit <model_name>"`; subtitle is empty (the
236/// `AdminUiModel` contract has no subtitle slot today).
237pub fn form_from_admin_ui_model(model: &dyn AdminUiModel) -> FormConfig {
238    let fields = model.fields().into_iter().map(field_config_from).collect();
239    FormConfig {
240        title: format!("Edit {}", model.model_name()),
241        subtitle: String::new(),
242        fields,
243        submitted: false,
244        save_failed: false,
245        hidden_fields: Vec::new(),
246    }
247}
248
249fn field_config_from(f: AdminUiField) -> FieldConfig {
250    // 1. Base mapping from storage type → form widget.
251    let mut ty = match f.data_type {
252        AdminDataType::String => FieldType::Text,
253        AdminDataType::Text => FieldType::TextArea,
254        AdminDataType::Integer => FieldType::Number,
255        AdminDataType::Float => FieldType::Number,
256        AdminDataType::Boolean => FieldType::Boolean,
257        AdminDataType::DateTime => FieldType::DateTime,
258        AdminDataType::Email => FieldType::Email,
259    };
260
261    // 2. Relation override — FK always wins over the data-type
262    //    mapping. The widget is a `<select>`; the user never sees a
263    //    raw row id.
264    if f.is_relation {
265        ty = FieldType::ForeignKey;
266    }
267
268    // 3. Options promote non-Boolean / non-FK columns to Select.
269    //    (Boolean stays a switch; FK is already handled.)
270    if !f.options.is_empty() && ty != FieldType::Boolean && !f.is_relation {
271        ty = FieldType::Select;
272    }
273
274    FieldConfig {
275        name: f.name.to_string(),
276        label: f.label.to_string(),
277        field_type: ty,
278        required: f.required,
279        readonly: f.readonly,
280        placeholder: None,
281        help: None,
282        value: None,
283        options: f.options,
284        error: None,
285    }
286}
287
288// ---------------------------------------------------------------
289// FormBuilder integration
290// ---------------------------------------------------------------
291
292impl FormBuilder {
293    /// Construct a builder seeded from an [`AdminUiModel`] impl.
294    pub fn from_admin_ui_model(model: &dyn AdminUiModel) -> Self {
295        Self {
296            form: form_from_admin_ui_model(model),
297        }
298    }
299}
300
301// ---------------------------------------------------------------
302// Registry: URL slug → boxed model
303// ---------------------------------------------------------------
304
305/// Boxed factory: any `Fn` (closure or `fn` pointer) that yields a
306/// fresh `Box<dyn AdminUiModel>` and is safe to share across the
307/// async runtime.
308pub type ModelFactory = Box<dyn Fn() -> Box<dyn AdminUiModel> + Send + Sync>;
309
310/// Slug → factory mapping for the `/admin-new/<slug>` dispatcher.
311///
312/// Each registered model is stored as a constructor closure; a
313/// fresh `Box<dyn AdminUiModel>` is built per lookup. Hand-written
314/// models are typically zero-sized unit structs (allocation is
315/// effectively free); generator-driven models capture an
316/// `AdminModelConfig` and clone it on each call.
317pub struct AdminRegistry {
318    factories: HashMap<&'static str, ModelFactory>,
319}
320
321impl AdminRegistry {
322    pub fn new() -> Self {
323        Self {
324            factories: HashMap::new(),
325        }
326    }
327
328    /// Register any `Fn` factory under `slug`. A bare `fn` pointer
329    /// satisfies the bound, so existing call sites
330    /// (`reg.register("users", new_user_admin)`) continue to compile
331    /// unchanged.
332    pub fn register<F>(&mut self, slug: &'static str, factory: F)
333    where
334        F: Fn() -> Box<dyn AdminUiModel> + Send + Sync + 'static,
335    {
336        self.factories.insert(slug, Box::new(factory));
337    }
338
339    /// Look the slug up; returns a fresh boxed model on hit, `None`
340    /// on miss (the route handler turns that into a 404).
341    pub fn get(&self, slug: &str) -> Option<Box<dyn AdminUiModel>> {
342        self.factories.get(slug).map(|f| f())
343    }
344
345    /// Iterate registered slugs (for future model-index pages).
346    pub fn slugs(&self) -> impl Iterator<Item = &&'static str> {
347        self.factories.keys()
348    }
349}
350
351impl Default for AdminRegistry {
352    fn default() -> Self {
353        Self::new()
354    }
355}