Skip to main content

tablo_core/resource/
soft_delete.rs

1//! Soft deletes: the column [`ResourceDef::soft_delete`](super::ResourceDef::soft_delete) names,
2//! the [`Trashed`] scope every loader reads through, and the list's trash control.
3
4use toasty::stmt::{Expr, Path};
5use topcoat::{context::Cx, view::BoxView};
6
7use crate::{
8    DeclarationErrorKind, Lens,
9    table::filter::{Filter, FilterInput, FilterValue},
10    toasty_compat::model::{self, ModelPath},
11};
12
13/// Which records a query over a soft-deleting resource admits: the live ones, the trashed ones,
14/// or both.
15///
16/// On a resource that declares no [`soft_delete`](super::ResourceDef::soft_delete) column, every
17/// record is live, so `Only` admits none.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub enum Trashed {
20    /// The live records only: what every list, form and record loader reads by default.
21    #[default]
22    Without,
23    /// Live and trashed records alike.
24    With,
25    /// The trashed records only.
26    Only,
27}
28
29impl Trashed {
30    /// Whether this scope admits a record that is, or is not, `trashed`.
31    pub fn admits(self, trashed: bool) -> bool {
32        match self {
33            Self::Without => !trashed,
34            Self::With => true,
35            Self::Only => trashed,
36        }
37    }
38
39    /// The scope the list's trash control selects with `value`, or `None` for a value it does not
40    /// offer.
41    pub(crate) fn from_filter(value: Option<&FilterValue>) -> Option<Self> {
42        match value.map(FilterValue::single) {
43            None => Some(Self::Without),
44            Some(Some(WITH)) => Some(Self::With),
45            Some(Some(ONLY)) => Some(Self::Only),
46            Some(_) => None,
47        }
48    }
49}
50
51impl<R: super::Resource> super::ResourceDef<R> {
52    /// Deletes by stamping `lens`'s column with the time instead of removing the row, as in
53    /// `.soft_delete(lens!(Post.deleted_at))`, and keeps the stamped records out of every loader
54    /// unless it asks for them.
55    ///
56    /// The list hides the trashed records behind a trash control, and offers Restore, which
57    /// empties the column, and Force delete, which removes the row, on each one: the policy decides
58    /// who may through [`Ability::Restore`](crate::Ability::Restore) and
59    /// [`Ability::ForceDelete`](crate::Ability::ForceDelete). The detail page opens a
60    /// trashed record; its edit page and every other action refuse one until it is restored.
61    /// [`scoped_query`](crate::scoped_query) admits the live records only, and
62    /// [`scoped_query_in`](crate::scoped_query_in) the ones a [`Trashed`](crate::Trashed) scope
63    /// names, each under the tenant filter.
64    ///
65    /// The unique pre-check probes the trashed records too, since a unique index covers them.
66    /// Mounting the panel refuses a lens that is not one field of the model, and a record form
67    /// that writes the column.
68    #[must_use]
69    pub fn soft_delete(mut self, lens: Lens<R::Model, Option<jiff::Timestamp>>) -> Self {
70        self.soft_delete = Some(SoftDelete::new(lens));
71        self
72    }
73}
74
75/// The column a resource soft-deletes through, as its def declares it.
76pub(crate) struct SoftDelete<M> {
77    path: Path<M, Option<jiff::Timestamp>>,
78    /// Whether a loaded record's column is set.
79    read: fn(&M) -> &Option<jiff::Timestamp>,
80    /// The column, or why the lens names none.
81    field: Result<(usize, String), DeclarationErrorKind>,
82}
83
84impl<M: toasty::schema::Model + Send + Sync + 'static> SoftDelete<M> {
85    pub(crate) fn new(lens: Lens<M, Option<jiff::Timestamp>>) -> Self {
86        let field =
87            model::field::<M>(&ModelPath::of(lens.path())).map(|field| (field.index, field.name));
88        Self {
89            path: lens.path().clone(),
90            read: lens.reader(),
91            field,
92        }
93    }
94}
95
96impl<M> SoftDelete<M> {
97    /// The predicate `trashed` adds to a query, or `None` when it admits every record.
98    pub(crate) fn filter(&self, trashed: Trashed) -> Option<Expr<bool>> {
99        match trashed {
100            Trashed::Without => Some(self.path.clone().is_none()),
101            Trashed::With => None,
102            Trashed::Only => Some(self.path.clone().is_some()),
103        }
104    }
105
106    /// Whether `record` is trashed.
107    pub(crate) fn is_trashed(&self, record: &M) -> bool {
108        (self.read)(record).is_some()
109    }
110
111    /// The column's index in the model, or why the lens names no column.
112    pub(crate) fn field(&self) -> Result<&(usize, String), &DeclarationErrorKind> {
113        self.field.as_ref()
114    }
115}
116
117/// The trash control's filter name and URL values.
118pub(crate) const TRASH_FILTER: &str = "trashed";
119const WITH: &str = "with";
120const ONLY: &str = "only";
121
122/// The list's trash control: a select between the live records, every record and the trashed
123/// ones. It adds no predicate itself: the list's base query reads its value through
124/// [`Trashed::from_filter`], since the default it replaces is a predicate no filter could drop.
125pub(crate) struct TrashFilter;
126
127impl<M> Filter<M> for TrashFilter {
128    fn name(&self) -> &str {
129        TRASH_FILTER
130    }
131
132    fn label(&self) -> crate::Text {
133        crate::Text::message("tablo-trash")
134    }
135
136    /// Nothing a row shows: which records are trashed is what Restore and Delete permanently
137    /// offer anyway.
138    fn reads(&self) -> Option<crate::lens::Reads<M>> {
139        Some(crate::lens::Reads::nothing())
140    }
141
142    fn to_expr(&self, _cx: &Cx, _value: &FilterValue) -> Option<Expr<bool>> {
143        None
144    }
145
146    fn is_noop_value(&self, value: &FilterValue) -> bool {
147        Trashed::from_filter(Some(value)).is_some()
148    }
149
150    fn control<'a>(&self, cx: &'a Cx, input: FilterInput) -> BoxView<'a> {
151        input.select_led_by(
152            cx,
153            &crate::t!(cx, "tablo-trash-without"),
154            vec![
155                (WITH.to_string(), crate::t!(cx, "tablo-trash-with")),
156                (ONLY.to_string(), crate::t!(cx, "tablo-trash-only")),
157            ],
158        )
159    }
160}