Skip to main content

autumn_admin_plugin/
traits.rs

1//! Core traits that models implement to participate in the admin panel.
2
3use std::future::Future;
4use std::pin::Pin;
5
6use serde::Deserialize;
7use serde_json::Value;
8
9// ── Field metadata ──────────────────────────────────────────────────
10
11/// The kind of a model field, used to select the appropriate form widget.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum AdminFieldKind {
14    /// Single-line text input.
15    Text,
16    /// Multi-line textarea.
17    TextArea,
18    /// Integer input.
19    Integer,
20    /// Floating-point input.
21    Float,
22    /// Boolean checkbox.
23    Boolean,
24    /// Date picker (no time).
25    Date,
26    /// Date + time picker.
27    DateTime,
28    /// Select dropdown with fixed choices.
29    Select(Vec<SelectOption>),
30    /// Hidden field (shown in detail, not editable).
31    Hidden,
32    /// Password field (write-only, never displayed).
33    Password,
34    /// JSON editor.
35    Json,
36}
37
38/// A single option in a [`AdminFieldKind::Select`] dropdown.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SelectOption {
41    pub value: String,
42    pub label: String,
43}
44
45/// Metadata for a single model field.
46#[derive(Debug, Clone)]
47#[allow(clippy::struct_excessive_bools)] // orthogonal flags on a plain config record
48pub struct AdminField {
49    /// Column name in the database / struct field name.
50    pub name: &'static str,
51    /// Human-readable label for the UI.
52    pub label: String,
53    /// Widget type.
54    pub kind: AdminFieldKind,
55    /// Whether this field appears in the list view table.
56    pub list_display: bool,
57    /// Whether this field is searchable (included in text search).
58    pub searchable: bool,
59    /// Whether this field can be used as a filter.
60    pub filterable: bool,
61    /// Whether this field is required on create/edit forms.
62    pub required: bool,
63    /// Whether this field is editable (false for IDs, timestamps, etc.).
64    pub editable: bool,
65    /// Sort priority in list view (None = not sortable).
66    pub sortable: bool,
67}
68
69impl AdminField {
70    /// Create a new field with sensible defaults.
71    ///
72    /// By default: displayed in list, not searchable, not filterable,
73    /// required, and sortable. Editable defaults to `true` except for
74    /// [`AdminFieldKind::Hidden`], which is read-only by contract
75    /// (and is therefore excluded from `strip_meta_fields` acceptance
76    /// even if a caller later flips `editable` back to `true`).
77    #[must_use]
78    pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
79        let editable = !matches!(kind, AdminFieldKind::Hidden);
80        Self {
81            name,
82            label: humanize_field_name(name),
83            kind,
84            list_display: true,
85            searchable: false,
86            filterable: false,
87            required: true,
88            editable,
89            sortable: true,
90        }
91    }
92
93    /// Set the human-readable label.
94    #[must_use]
95    pub fn label(mut self, label: impl Into<String>) -> Self {
96        self.label = label.into();
97        self
98    }
99
100    /// Mark this field as searchable.
101    #[must_use]
102    pub const fn searchable(mut self) -> Self {
103        self.searchable = true;
104        self
105    }
106
107    /// Mark this field as filterable.
108    #[must_use]
109    pub const fn filterable(mut self) -> Self {
110        self.filterable = true;
111        self
112    }
113
114    /// Mark this field as optional.
115    #[must_use]
116    pub const fn optional(mut self) -> Self {
117        self.required = false;
118        self
119    }
120
121    /// Mark this field as read-only.
122    #[must_use]
123    pub const fn readonly(mut self) -> Self {
124        self.editable = false;
125        self
126    }
127
128    /// Hide this field from the list view.
129    #[must_use]
130    pub const fn hide_from_list(mut self) -> Self {
131        self.list_display = false;
132        self
133    }
134}
135
136// ── Bulk actions ────────────────────────────────────────────────────
137
138/// A named bulk action that can be performed on selected records.
139pub struct AdminAction {
140    /// Machine name (used in form values).
141    pub name: &'static str,
142    /// Human-readable label for the button.
143    pub label: String,
144    /// CSS class for styling (e.g., "danger" for destructive actions).
145    pub style: ActionStyle,
146    /// Whether a confirmation dialog is shown before executing.
147    pub confirm: bool,
148}
149
150/// Visual style for an admin action button.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum ActionStyle {
153    /// Default/neutral style.
154    Default,
155    /// Primary/positive action.
156    Primary,
157    /// Destructive/dangerous action (red).
158    Danger,
159}
160
161// ── The core trait ──────────────────────────────────────────────────
162
163/// Type alias for the boxed future returned by async `AdminModel` methods.
164pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
165
166/// Error type for admin operations.
167#[derive(Debug, thiserror::Error)]
168pub enum AdminError {
169    #[error("Record not found")]
170    NotFound,
171
172    #[error("Validation failed: {0}")]
173    Validation(String),
174
175    #[error("Database error: {0}")]
176    Database(String),
177
178    #[error("{0}")]
179    Other(String),
180}
181
182/// The core trait that enables a model to be managed in the admin panel.
183///
184/// Implementors provide field metadata, CRUD operations, and display
185/// configuration. The admin plugin uses this trait to generate all views
186/// dynamically at runtime.
187///
188/// # Design notes
189///
190/// All data flows through `serde_json::Value` to keep the trait object-safe.
191/// The admin panel doesn't need to know concrete types — it renders fields
192/// based on [`AdminField`] metadata and passes values as JSON.
193pub trait AdminModel: Send + Sync + 'static {
194    /// URL-safe slug for this model (e.g., "projects", "tickets").
195    /// Used in admin URLs: `/admin/projects/`, `/admin/projects/42/`.
196    fn slug(&self) -> &'static str;
197
198    /// Human-readable singular name (e.g., "Project").
199    fn display_name(&self) -> &'static str;
200
201    /// Human-readable plural name (e.g., "Projects").
202    fn display_name_plural(&self) -> &'static str;
203
204    /// Field metadata for this model.
205    fn fields(&self) -> Vec<AdminField>;
206
207    /// Available bulk actions (default: just "Delete selected").
208    fn actions(&self) -> Vec<AdminAction> {
209        vec![AdminAction {
210            name: "delete",
211            label: "Delete selected".to_owned(),
212            style: ActionStyle::Danger,
213            confirm: true,
214        }]
215    }
216
217    // ── CRUD operations ─────────────────────────────────────────
218
219    /// List records with pagination, search, sort, and filters.
220    fn list(
221        &self,
222        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
223        params: ListParams,
224    ) -> AdminFuture<'_, ListResult>;
225
226    /// Get a single record by ID.
227    fn get(
228        &self,
229        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
230        id: i64,
231    ) -> AdminFuture<'_, Option<Value>>;
232
233    /// Create a new record from form data.
234    fn create(
235        &self,
236        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
237        data: Value,
238    ) -> AdminFuture<'_, Value>;
239
240    /// Update an existing record.
241    fn update(
242        &self,
243        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
244        id: i64,
245        data: Value,
246    ) -> AdminFuture<'_, Value>;
247
248    /// Delete a record by ID.
249    fn delete(
250        &self,
251        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
252        id: i64,
253    ) -> AdminFuture<'_, ()>;
254
255    /// Execute a bulk action on the given IDs.
256    fn execute_action(
257        &self,
258        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
259        action: &str,
260        ids: Vec<i64>,
261    ) -> AdminFuture<'_, u64> {
262        // Default implementation: dispatch the built-in `"delete"` action
263        // by calling `self.delete` for each id. Any other action name
264        // returns an error so it doesn't silently no-op — overriders that
265        // declare custom actions must implement them here.
266        //
267        // We clone the pool (deadpool::Pool is Arc-backed, cheap) so the
268        // returned future only borrows from `&self` and avoids the
269        // lifetime mismatch between `&self` and `&pool` that would
270        // otherwise show up in the trait's elided `'_` return signature.
271        let action = action.to_owned();
272        let pool = pool.clone();
273        Box::pin(async move {
274            match action.as_str() {
275                "delete" => {
276                    let mut count: u64 = 0;
277                    for id in ids {
278                        self.delete(&pool, id).await?;
279                        count += 1;
280                    }
281                    Ok(count)
282                }
283                other => Err(AdminError::Other(format!(
284                    "unhandled bulk action '{other}'; \
285                     override AdminModel::execute_action to support it"
286                ))),
287            }
288        })
289    }
290
291    /// Return a display string for a record (used in breadcrumbs, titles).
292    ///
293    /// Defaults to `"ModelName #id"` (or `"ModelName <no id>"` when the
294    /// record has no numeric `id`).
295    fn record_display(&self, record: &Value) -> String {
296        record_id(record).map_or_else(
297            || format!("{} <no id>", self.display_name()),
298            |id| format!("{} #{id}", self.display_name()),
299        )
300    }
301
302    /// Records per page in the list view. Override to taste.
303    fn per_page(&self) -> u64 {
304        25
305    }
306
307    /// Count records matching a list query (defaults to `list(..., per_page: 0).total`).
308    ///
309    /// Override if the backend can count without materializing records.
310    fn count(
311        &self,
312        pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
313    ) -> AdminFuture<'_, u64> {
314        let params = ListParams {
315            page: 1,
316            per_page: 0,
317            ..Default::default()
318        };
319        let fut = self.list(pool, params);
320        Box::pin(async move { fut.await.map(|r| r.total) })
321    }
322}
323
324/// Extract the `"id"` field of a record as `i64`.
325///
326/// Returns `None` when the field is missing or non-numeric. Callers in
327/// mutation paths should treat `None` as an error (the model returned a
328/// payload without a routable identifier); display contexts may fall back
329/// to a placeholder like `"#?"`.
330#[must_use]
331pub fn record_id(record: &Value) -> Option<i64> {
332    record.get("id").and_then(Value::as_i64)
333}
334
335// ── Query parameters ────────────────────────────────────────────────
336
337/// Parameters for a list query.
338#[derive(Debug, Clone, Default)]
339pub struct ListParams {
340    /// Page number (1-indexed).
341    pub page: u64,
342    /// Records per page.
343    pub per_page: u64,
344    /// Full-text search query.
345    pub search: Option<String>,
346    /// Column to sort by.
347    pub sort_by: Option<String>,
348    /// Sort direction.
349    pub sort_dir: SortDirection,
350    /// Active filters (`field_name` → value).
351    pub filters: Vec<(String, String)>,
352}
353
354/// Sort direction for list queries.
355#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
356#[serde(rename_all = "lowercase")]
357pub enum SortDirection {
358    #[default]
359    Asc,
360    Desc,
361}
362
363impl SortDirection {
364    /// URL-friendly representation (`"asc"` / `"desc"`).
365    #[must_use]
366    pub const fn as_str(self) -> &'static str {
367        match self {
368            Self::Asc => "asc",
369            Self::Desc => "desc",
370        }
371    }
372
373    /// The opposite direction (used to flip sort on re-click).
374    #[must_use]
375    pub const fn flipped(self) -> Self {
376        match self {
377            Self::Asc => Self::Desc,
378            Self::Desc => Self::Asc,
379        }
380    }
381}
382
383/// Result of a list query, containing records and pagination metadata.
384#[derive(Debug, Clone)]
385pub struct ListResult {
386    /// The records for the current page (as JSON objects).
387    pub records: Vec<Value>,
388    /// Total number of records matching the query (for pagination).
389    pub total: u64,
390    /// Current page number.
391    pub page: u64,
392    /// Records per page.
393    pub per_page: u64,
394}
395
396impl ListResult {
397    /// Total number of pages.
398    #[must_use]
399    pub const fn total_pages(&self) -> u64 {
400        if self.per_page == 0 {
401            return 0;
402        }
403        self.total.div_ceil(self.per_page)
404    }
405}
406
407// ── Helpers ─────────────────────────────────────────────────────────
408
409/// Convert a `snake_case` field name to a human-readable label.
410///
411/// `"created_at"` → `"Created At"`, `"user_id"` → `"User Id"`.
412fn humanize_field_name(name: &str) -> String {
413    name.split('_')
414        .map(|word| {
415            let mut chars = word.chars();
416            chars.next().map_or_else(String::new, |c| {
417                let mut s = c.to_uppercase().to_string();
418                s.extend(chars);
419                s
420            })
421        })
422        .collect::<Vec<_>>()
423        .join(" ")
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use std::sync::Mutex;
430
431    /// Test fixture: an `AdminModel` whose `delete` records the id it was
432    /// asked to delete. Doesn't override `execute_action` — that's the
433    /// behaviour under test.
434    struct DeletingModel {
435        deleted: Mutex<Vec<i64>>,
436        fail_on: Option<i64>,
437    }
438
439    impl AdminModel for DeletingModel {
440        fn slug(&self) -> &'static str {
441            "tracked"
442        }
443        fn display_name(&self) -> &'static str {
444            "Tracked"
445        }
446        fn display_name_plural(&self) -> &'static str {
447            "Tracked"
448        }
449        fn fields(&self) -> Vec<AdminField> {
450            vec![]
451        }
452        fn list(
453            &self,
454            _pool: &diesel_async::pooled_connection::deadpool::Pool<
455                diesel_async::AsyncPgConnection,
456            >,
457            _params: ListParams,
458        ) -> AdminFuture<'_, ListResult> {
459            Box::pin(async {
460                Ok(ListResult {
461                    records: vec![],
462                    total: 0,
463                    page: 1,
464                    per_page: 25,
465                })
466            })
467        }
468        fn get(
469            &self,
470            _pool: &diesel_async::pooled_connection::deadpool::Pool<
471                diesel_async::AsyncPgConnection,
472            >,
473            _id: i64,
474        ) -> AdminFuture<'_, Option<Value>> {
475            Box::pin(async { Ok(None) })
476        }
477        fn create(
478            &self,
479            _pool: &diesel_async::pooled_connection::deadpool::Pool<
480                diesel_async::AsyncPgConnection,
481            >,
482            data: Value,
483        ) -> AdminFuture<'_, Value> {
484            Box::pin(async move { Ok(data) })
485        }
486        fn update(
487            &self,
488            _pool: &diesel_async::pooled_connection::deadpool::Pool<
489                diesel_async::AsyncPgConnection,
490            >,
491            _id: i64,
492            data: Value,
493        ) -> AdminFuture<'_, Value> {
494            Box::pin(async move { Ok(data) })
495        }
496        fn delete(
497            &self,
498            _pool: &diesel_async::pooled_connection::deadpool::Pool<
499                diesel_async::AsyncPgConnection,
500            >,
501            id: i64,
502        ) -> AdminFuture<'_, ()> {
503            let deleted = &self.deleted;
504            let fail_on = self.fail_on;
505            Box::pin(async move {
506                if Some(id) == fail_on {
507                    return Err(AdminError::Database("simulated failure".into()));
508                }
509                deleted.lock().unwrap().push(id);
510                Ok(())
511            })
512        }
513    }
514
515    /// Build a `Pool` whose manager would fail to connect — the test models
516    /// never call `pool.get()`, so the pool itself just sits unused.
517    fn dummy_pool()
518    -> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
519        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
520        use diesel_async::pooled_connection::deadpool::Pool;
521        let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
522            "postgresql://test",
523        );
524        Pool::builder(mgr).build().expect("build pool")
525    }
526
527    #[tokio::test]
528    async fn default_execute_action_delete_invokes_delete_for_each_id() {
529        let model = DeletingModel {
530            deleted: Mutex::new(vec![]),
531            fail_on: None,
532        };
533        let pool = dummy_pool();
534        let count = model
535            .execute_action(&pool, "delete", vec![10, 20, 30])
536            .await
537            .expect("default delete should succeed");
538        assert_eq!(count, 3);
539        assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
540    }
541
542    #[tokio::test]
543    async fn default_execute_action_delete_aborts_on_first_failure() {
544        let model = DeletingModel {
545            deleted: Mutex::new(vec![]),
546            fail_on: Some(20),
547        };
548        let pool = dummy_pool();
549        let err = model
550            .execute_action(&pool, "delete", vec![10, 20, 30])
551            .await
552            .expect_err("delete should propagate failure");
553        assert!(matches!(err, AdminError::Database(_)));
554        // Only the pre-failure id was committed.
555        assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
556    }
557
558    #[tokio::test]
559    async fn default_execute_action_rejects_unknown_action() {
560        let model = DeletingModel {
561            deleted: Mutex::new(vec![]),
562            fail_on: None,
563        };
564        let pool = dummy_pool();
565        let err = model
566            .execute_action(&pool, "promote", vec![1])
567            .await
568            .expect_err("unknown actions must error, not silently no-op");
569        assert!(
570            matches!(err, AdminError::Other(msg) if msg.contains("promote")),
571            "error should name the unhandled action"
572        );
573        assert!(model.deleted.lock().unwrap().is_empty());
574    }
575
576    #[test]
577    fn humanize_converts_snake_case() {
578        assert_eq!(humanize_field_name("created_at"), "Created At");
579        assert_eq!(humanize_field_name("user_id"), "User Id");
580        assert_eq!(humanize_field_name("name"), "Name");
581        assert_eq!(humanize_field_name(""), "");
582    }
583
584    #[test]
585    fn list_result_total_pages() {
586        let result = ListResult {
587            records: vec![],
588            total: 25,
589            page: 1,
590            per_page: 10,
591        };
592        assert_eq!(result.total_pages(), 3);
593    }
594
595    #[test]
596    fn list_result_total_pages_exact() {
597        let result = ListResult {
598            records: vec![],
599            total: 20,
600            page: 1,
601            per_page: 10,
602        };
603        assert_eq!(result.total_pages(), 2);
604    }
605
606    #[test]
607    fn list_result_total_pages_zero_per_page() {
608        let result = ListResult {
609            records: vec![],
610            total: 20,
611            page: 1,
612            per_page: 0,
613        };
614        assert_eq!(result.total_pages(), 0);
615    }
616
617    #[test]
618    fn admin_field_builder() {
619        let field = AdminField::new("email", AdminFieldKind::Text)
620            .label("Email Address")
621            .searchable()
622            .filterable()
623            .optional();
624
625        assert_eq!(field.name, "email");
626        assert_eq!(field.label, "Email Address");
627        assert!(field.searchable);
628        assert!(field.filterable);
629        assert!(!field.required);
630        assert!(field.editable);
631    }
632
633    #[test]
634    fn record_id_extracts_numeric_id() {
635        assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
636    }
637
638    #[test]
639    fn record_id_returns_none_for_missing_or_non_numeric() {
640        assert_eq!(record_id(&serde_json::json!({})), None);
641        assert_eq!(record_id(&serde_json::json!({"id": null})), None);
642        assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
643        // Floats aren't valid IDs either — only integers.
644        assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
645    }
646
647    #[test]
648    fn hidden_fields_default_to_not_editable() {
649        // AdminFieldKind::Hidden is documented as "not editable". Ensure the
650        // default matches the contract so admins who skip `.readonly()` still
651        // get safe behaviour.
652        let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
653        assert!(
654            !hidden.editable,
655            "Hidden fields must default to editable=false"
656        );
657
658        // Other kinds remain editable by default.
659        let text = AdminField::new("name", AdminFieldKind::Text);
660        assert!(text.editable);
661    }
662}