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}