tablo_core/table/tab.rs
1//! List tabs: the [`ListTab`]s a table offers above its rows, the one `?tab=` picks, and the
2//! counts their badges show.
3
4use derive_where::derive_where;
5use toasty::stmt::{Expr, IntoExpr, List, Path, Query};
6use topcoat::{Result, context::Cx};
7
8use super::{Table, TableState};
9use crate::{DeclarationErrorKind, lens::Reads, schema::Options, values::FormScalar};
10
11/// One tab a table offers above its rows: a preset predicate the reader picks with
12/// `?tab=<key>`, beside the search and the filters it combines with.
13///
14/// The first tab is the one a list without `?tab=` shows, so a list whose first tab narrows its
15/// rows opens on that tab. A `?tab=` naming no tab the table declares is never dropped silently:
16/// the list shows its warning banner, and the export and a selection of every matching record
17/// refuse it, as they do an unknown filter.
18///
19/// A tab narrows what the list shows; it is not access control. A tab whose query reads a field
20/// [`field_access`](crate::ResourceDef::field_access) hides from the user does not show to them:
21/// the panel reads the fields off the query.
22///
23/// ```rust
24/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
25/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, published: bool }
26/// use tablo_core::{ListTab, Table, TextColumn, lens};
27///
28/// Table::new(TextColumn::new(lens!(Post.title)))
29/// .tab(ListTab::new("all", "All"))
30/// .tab(
31/// ListTab::new("drafts", "Drafts")
32/// .query(Post::fields().published().eq(false))
33/// .badge(),
34/// );
35/// ```
36#[derive_where(Clone)]
37pub struct ListTab<M> {
38 key: String,
39 label: crate::Text,
40 /// The rows the tab admits; `None` admits every row.
41 query: Option<Expr<bool>>,
42 /// Whether the tab shows how many rows it holds.
43 badge: bool,
44 _model: std::marker::PhantomData<fn() -> M>,
45}
46
47impl<M> ListTab<M> {
48 /// A tab admitting every row, whose `?tab=` value is `key` and whose link reads `label`.
49 pub fn new(key: impl Into<String>, label: impl Into<crate::Text>) -> Self {
50 Self {
51 key: key.into(),
52 label: label.into(),
53 query: None,
54 badge: false,
55 _model: std::marker::PhantomData,
56 }
57 }
58
59 /// Admit only the rows `predicate` selects.
60 pub fn query(mut self, predicate: Expr<bool>) -> Self {
61 self.query = Some(predicate);
62 self
63 }
64
65 /// Show how many rows the tab holds under the list's search and filters: one count query
66 /// per badged tab, each time the list renders.
67 pub fn badge(mut self) -> Self {
68 self.badge = true;
69 self
70 }
71
72 /// The `?tab=` value.
73 pub(crate) fn key(&self) -> &str {
74 &self.key
75 }
76
77 /// The link's label.
78 pub(crate) fn label(&self) -> crate::Text {
79 self.label.clone()
80 }
81
82 /// Whether the tab shows its count.
83 pub(crate) fn has_badge(&self) -> bool {
84 self.badge
85 }
86
87 /// The fields the tab's query reads off a row; a tab with no query reads nothing.
88 pub(crate) fn reads(&self) -> Reads<M>
89 where
90 M: toasty::schema::Model,
91 {
92 self.query
93 .as_ref()
94 .map_or_else(Reads::nothing, Reads::of_predicate)
95 }
96}
97
98impl<M> Table<M> {
99 /// Offer `tab` above the rows, after the declared ones.
100 pub fn tab(mut self, tab: ListTab<M>) -> Self {
101 self.tabs.push(tab);
102 self
103 }
104
105 /// Offer a tab admitting every row, keyed `all`, then a tab per option of the [`Options`]
106 /// field `lens` binds, in declaration order, after the declared ones. An option's tab is keyed
107 /// by its value and labelled by its label, admits the rows holding it, and shows their count.
108 ///
109 /// ```rust
110 /// # #[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, tablo_core::Options)]
111 /// # enum Status { Draft, Published }
112 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
113 /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String, status: Status }
114 /// use tablo_core::{Table, TextColumn, lens};
115 ///
116 /// // `?tab=all`, `?tab=draft` and `?tab=published`.
117 /// Table::new(TextColumn::new(lens!(Post.title))).option_tabs(lens!(Post.status));
118 /// ```
119 ///
120 /// An option whose value the field's type does not parse is a declaration error, since its
121 /// tab would admit no row.
122 pub fn option_tabs<T>(mut self, lens: impl Into<Path<M, T>>) -> Self
123 where
124 T: Options + FormScalar + IntoExpr<T>,
125 {
126 let path: Path<M, T> = lens.into();
127 self.tabs
128 .push(ListTab::new("all", crate::text!("tablo-list-tab-all")));
129 for (value, label) in T::options() {
130 match T::parse_form(&value) {
131 Ok(option) => self.tabs.push(
132 ListTab::new(value, label)
133 .query(path.clone().eq(option))
134 .badge(),
135 ),
136 Err(_) => self
137 .misdeclared
138 .push(DeclarationErrorKind::UnparsedTabOption { value }),
139 }
140 }
141 self
142 }
143
144 /// The declared tabs, in order.
145 pub(crate) fn declared_tabs(&self) -> &[ListTab<M>] {
146 &self.tabs
147 }
148
149 /// The tab `state` shows: the one its `?tab=` names, else the first. `None` when the table
150 /// declares no tab, or `?tab=` names none it declares.
151 pub(crate) fn active_tab(&self, state: &TableState) -> Option<&ListTab<M>> {
152 match &state.tab {
153 Some(key) => self.tabs.iter().find(|tab| tab.key == *key),
154 None => self.tabs.first(),
155 }
156 }
157
158 /// `state`'s `?tab=` when it names no tab the table declares.
159 pub(crate) fn unknown_tab<'s>(&self, state: &'s TableState) -> Option<&'s str> {
160 state
161 .tab
162 .as_deref()
163 .filter(|key| self.tabs.iter().all(|tab| tab.key != *key))
164 }
165
166 /// Narrow `query` to the rows the tab `state` shows admits.
167 pub(super) fn narrow_tab(&self, query: Query<List<M>>, state: &TableState) -> Query<List<M>>
168 where
169 M: toasty::schema::Model,
170 {
171 match self.active_tab(state).and_then(|tab| tab.query.clone()) {
172 Some(predicate) => query.filter(predicate),
173 None => query,
174 }
175 }
176
177 /// How many rows of `query` each badged tab holds under `state`'s search and filters, but the
178 /// `refused` filters: one count query per badged tab, by key, in declaration order.
179 ///
180 /// # Errors
181 ///
182 /// The database failing a count.
183 pub(crate) async fn tab_counts(
184 &self,
185 cx: &Cx,
186 query: &Query<List<M>>,
187 state: &TableState,
188 refused: &[String],
189 ) -> Result<Vec<(String, u64)>>
190 where
191 M: toasty::schema::Model,
192 {
193 let mut counts = Vec::new();
194 if !self.tabs.iter().any(ListTab::has_badge) {
195 return Ok(counts);
196 }
197 let mut db = crate::db::db(cx)?;
198 let narrowed = self.narrow_untabbed(cx, &db, query.clone(), state, refused);
199 for tab in self.tabs.iter().filter(|tab| tab.badge) {
200 let rows = match &tab.query {
201 Some(predicate) => narrowed.clone().filter(predicate.clone()),
202 None => narrowed.clone(),
203 };
204 let count = crate::toasty_compat::count(rows, &mut db)
205 .await
206 .map_err(crate::error::unavailable)?;
207 counts.push((tab.key.clone(), count));
208 }
209 Ok(counts)
210 }
211
212 /// What is wrong with the declared tabs: two sharing a key.
213 pub(super) fn tab_errors(&self) -> Vec<DeclarationErrorKind> {
214 let mut seen = std::collections::HashSet::with_capacity(self.tabs.len());
215 self.tabs
216 .iter()
217 .filter(|tab| !seen.insert(tab.key.as_str()))
218 .map(|tab| DeclarationErrorKind::DuplicateTab {
219 key: tab.key.clone(),
220 })
221 .collect()
222 }
223}
224
225#[cfg(test)]
226mod tests;