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    let mut s = String::with_capacity(name.len());
414    for (i, word) in name.split('_').enumerate() {
415        if i > 0 {
416            s.push(' ');
417        }
418        let mut chars = word.chars();
419        if let Some(c) = chars.next() {
420            s.extend(c.to_uppercase());
421            s.extend(chars);
422        }
423    }
424    s
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use std::sync::Mutex;
431
432    /// Test fixture: an `AdminModel` whose `delete` records the id it was
433    /// asked to delete. Doesn't override `execute_action` — that's the
434    /// behaviour under test.
435    struct DeletingModel {
436        deleted: Mutex<Vec<i64>>,
437        fail_on: Option<i64>,
438    }
439
440    impl AdminModel for DeletingModel {
441        fn slug(&self) -> &'static str {
442            "tracked"
443        }
444        fn display_name(&self) -> &'static str {
445            "Tracked"
446        }
447        fn display_name_plural(&self) -> &'static str {
448            "Tracked"
449        }
450        fn fields(&self) -> Vec<AdminField> {
451            vec![]
452        }
453        fn list(
454            &self,
455            _pool: &diesel_async::pooled_connection::deadpool::Pool<
456                diesel_async::AsyncPgConnection,
457            >,
458            _params: ListParams,
459        ) -> AdminFuture<'_, ListResult> {
460            Box::pin(async {
461                Ok(ListResult {
462                    records: vec![],
463                    total: 0,
464                    page: 1,
465                    per_page: 25,
466                })
467            })
468        }
469        fn get(
470            &self,
471            _pool: &diesel_async::pooled_connection::deadpool::Pool<
472                diesel_async::AsyncPgConnection,
473            >,
474            _id: i64,
475        ) -> AdminFuture<'_, Option<Value>> {
476            Box::pin(async { Ok(None) })
477        }
478        fn create(
479            &self,
480            _pool: &diesel_async::pooled_connection::deadpool::Pool<
481                diesel_async::AsyncPgConnection,
482            >,
483            data: Value,
484        ) -> AdminFuture<'_, Value> {
485            Box::pin(async move { Ok(data) })
486        }
487        fn update(
488            &self,
489            _pool: &diesel_async::pooled_connection::deadpool::Pool<
490                diesel_async::AsyncPgConnection,
491            >,
492            _id: i64,
493            data: Value,
494        ) -> AdminFuture<'_, Value> {
495            Box::pin(async move { Ok(data) })
496        }
497        fn delete(
498            &self,
499            _pool: &diesel_async::pooled_connection::deadpool::Pool<
500                diesel_async::AsyncPgConnection,
501            >,
502            id: i64,
503        ) -> AdminFuture<'_, ()> {
504            let deleted = &self.deleted;
505            let fail_on = self.fail_on;
506            Box::pin(async move {
507                if Some(id) == fail_on {
508                    return Err(AdminError::Database("simulated failure".into()));
509                }
510                deleted.lock().unwrap().push(id);
511                Ok(())
512            })
513        }
514    }
515
516    /// Build a `Pool` whose manager would fail to connect — the test models
517    /// never call `pool.get()`, so the pool itself just sits unused.
518    fn dummy_pool()
519    -> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
520        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
521        use diesel_async::pooled_connection::deadpool::Pool;
522        let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
523            "postgresql://test",
524        );
525        Pool::builder(mgr).build().expect("build pool")
526    }
527
528    #[tokio::test]
529    async fn default_execute_action_delete_invokes_delete_for_each_id() {
530        let model = DeletingModel {
531            deleted: Mutex::new(vec![]),
532            fail_on: None,
533        };
534        let pool = dummy_pool();
535        let count = model
536            .execute_action(&pool, "delete", vec![10, 20, 30])
537            .await
538            .expect("default delete should succeed");
539        assert_eq!(count, 3);
540        assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
541    }
542
543    #[tokio::test]
544    async fn default_execute_action_delete_aborts_on_first_failure() {
545        let model = DeletingModel {
546            deleted: Mutex::new(vec![]),
547            fail_on: Some(20),
548        };
549        let pool = dummy_pool();
550        let err = model
551            .execute_action(&pool, "delete", vec![10, 20, 30])
552            .await
553            .expect_err("delete should propagate failure");
554        assert!(matches!(err, AdminError::Database(_)));
555        // Only the pre-failure id was committed.
556        assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
557    }
558
559    #[tokio::test]
560    async fn default_execute_action_rejects_unknown_action() {
561        let model = DeletingModel {
562            deleted: Mutex::new(vec![]),
563            fail_on: None,
564        };
565        let pool = dummy_pool();
566        let err = model
567            .execute_action(&pool, "promote", vec![1])
568            .await
569            .expect_err("unknown actions must error, not silently no-op");
570        assert!(
571            matches!(err, AdminError::Other(msg) if msg.contains("promote")),
572            "error should name the unhandled action"
573        );
574        assert!(model.deleted.lock().unwrap().is_empty());
575    }
576
577    #[test]
578    fn humanize_converts_snake_case() {
579        assert_eq!(humanize_field_name("created_at"), "Created At");
580        assert_eq!(humanize_field_name("user_id"), "User Id");
581        assert_eq!(humanize_field_name("name"), "Name");
582        assert_eq!(humanize_field_name(""), "");
583    }
584
585    #[test]
586    fn list_result_total_pages() {
587        let result = ListResult {
588            records: vec![],
589            total: 25,
590            page: 1,
591            per_page: 10,
592        };
593        assert_eq!(result.total_pages(), 3);
594    }
595
596    #[test]
597    fn list_result_total_pages_exact() {
598        let result = ListResult {
599            records: vec![],
600            total: 20,
601            page: 1,
602            per_page: 10,
603        };
604        assert_eq!(result.total_pages(), 2);
605    }
606
607    #[test]
608    fn list_result_total_pages_zero_per_page() {
609        let result = ListResult {
610            records: vec![],
611            total: 20,
612            page: 1,
613            per_page: 0,
614        };
615        assert_eq!(result.total_pages(), 0);
616    }
617
618    #[test]
619    fn admin_field_builder() {
620        let field = AdminField::new("email", AdminFieldKind::Text)
621            .label("Email Address")
622            .searchable()
623            .filterable()
624            .optional();
625
626        assert_eq!(field.name, "email");
627        assert_eq!(field.label, "Email Address");
628        assert!(field.searchable);
629        assert!(field.filterable);
630        assert!(!field.required);
631        assert!(field.editable);
632    }
633
634    #[test]
635    fn record_id_extracts_numeric_id() {
636        assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
637    }
638
639    #[test]
640    fn record_id_returns_none_for_missing_or_non_numeric() {
641        assert_eq!(record_id(&serde_json::json!({})), None);
642        assert_eq!(record_id(&serde_json::json!({"id": null})), None);
643        assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
644        // Floats aren't valid IDs either — only integers.
645        assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
646    }
647
648    #[test]
649    fn hidden_fields_default_to_not_editable() {
650        // AdminFieldKind::Hidden is documented as "not editable". Ensure the
651        // default matches the contract so admins who skip `.readonly()` still
652        // get safe behaviour.
653        let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
654        assert!(
655            !hidden.editable,
656            "Hidden fields must default to editable=false"
657        );
658
659        // Other kinds remain editable by default.
660        let text = AdminField::new("name", AdminFieldKind::Text);
661        assert!(text.editable);
662    }
663}