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}