tablo_core/table/state.rs
1//! List state: [`TableState`], [`Cursor`], [`Sort`], and the URL codec.
2//!
3//! The URL query spells list state; [`TableState::from_query`] parses it and one
4//! encoder projects every link.
5
6use std::{borrow::Cow, collections::BTreeMap};
7
8use topcoat::context::Cx;
9
10use super::filter::FilterValue;
11use crate::{schema::clamp_query_term, topcoat_compat::href};
12
13/// The selection token for `key` on the bulk wire: the key percent-encoded between the commas that
14/// delimit it, so a key holding the wire's separator stays one token.
15pub(crate) fn bulk_token(key: &str) -> String {
16 format!(",{},", href::encode_query_value(key))
17}
18
19/// The key a [`bulk_token`]'s body spells: the inverse of the encoding it applies.
20///
21/// Each valid escape decodes, and a `%` that starts no valid escape stays as written. A body
22/// whose decoded bytes are not UTF-8 reads as written, whole, so a posted id holding no escape
23/// keeps its literal text.
24pub(crate) fn bulk_key(body: &str) -> String {
25 href::decode_query_value(body)
26}
27
28/// A column and a direction to sort a table by: parsed from `?sort=<column>&dir=asc|desc`, or
29/// declared as the table's [default](super::Table::default_sort).
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub struct Sort {
32 /// The app-level field name of the column (matches `TextColumn::name`).
33 pub column: String,
34 /// `true` for `dir=desc`.
35 pub descending: bool,
36}
37
38impl Sort {
39 /// Sort by `column`, smallest first.
40 pub fn asc(column: impl Into<String>) -> Self {
41 Self {
42 column: column.into(),
43 descending: false,
44 }
45 }
46
47 /// Sort by `column`, largest first.
48 pub fn desc(column: impl Into<String>) -> Self {
49 Self {
50 column: column.into(),
51 descending: true,
52 }
53 }
54}
55
56/// A pagination cursor: the page after, or the page before, an encoded row.
57///
58/// Conflicting cursors parse as none and render the first page.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Cursor {
61 /// `?after=` — the page after the encoded row.
62 After(String),
63 /// `?before=` — the page before the encoded row.
64 Before(String),
65}
66
67/// Request-scoped table state, parsed from the list's URL query.
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct TableState {
70 /// The prefix every parameter of this table carries, or `None` for a page's
71 /// own list.
72 pub prefix: Option<String>,
73 /// `?q=` — trimmed and clamped to `MAX_QUERY_TERM` chars; `None` when
74 /// absent or blank.
75 pub search: Option<String>,
76 /// `?sort=` + `?dir=` — `None` when absent or blank.
77 pub sort: Option<Sort>,
78 /// `?after=` or `?before=`, which a [`Pagination::Cursor`](super::Pagination::Cursor) table
79 /// reads.
80 pub cursor: Option<Cursor>,
81 /// `?page=`, from 1, which a numbered or simple table reads; `None` when absent or not a
82 /// positive number.
83 pub page: Option<u32>,
84 /// `?per_page=`, honored when the table offers it; `None` when absent or not a positive
85 /// number.
86 pub per_page: Option<u32>,
87 /// The active filters, by name: `?f.<name>=<value>`, repeated for each
88 /// value, and `?f.<name>.<part>=<value>` for each part. The parse keeps
89 /// no filter holding no value and no part.
90 pub filters: BTreeMap<String, FilterValue>,
91 /// Filter parameters the parse dropped.
92 pub filters_dropped: bool,
93 /// `?group_by=` — field name to group by (the query orders by it ahead of the sort).
94 pub group_by: Option<String>,
95 /// `?tab=` — the key of the [tab](super::ListTab) the list shows; `None` shows the first.
96 pub tab: Option<String>,
97}
98
99/// The URL parameters one table link projects.
100#[derive(Default)]
101struct UrlProjection<'a> {
102 /// `?tab=` key.
103 tab: Option<&'a str>,
104 /// `?q=` search term.
105 search: Option<&'a str>,
106 /// `?sort=` column and `?dir=` value.
107 sort: Option<(&'a str, &'a str)>,
108 /// The `?f.<name>=` filters.
109 filters: bool,
110 /// `?group_by=` column.
111 group_by: Option<&'a str>,
112 /// `?after=` or `?before=`.
113 cursor: Option<&'a Cursor>,
114 /// `?page=` page number.
115 page: Option<u32>,
116 /// `?per_page=` page size.
117 per_page: Option<u32>,
118}
119
120/// Caps applied filters per query.
121pub(crate) const MAX_FILTERS: usize = 32;
122
123/// Caps the values and parts one filter holds.
124pub(crate) const MAX_FILTER_VALUES: usize = 64;
125
126/// Caps filter name, part and value length in bytes.
127pub(crate) const MAX_FILTER_LEN: usize = 256;
128
129/// The prefix that names a filter parameter: `?f.status=published`.
130const FILTER_PREFIX: &str = "f.";
131
132impl TableState {
133 /// Parse the state from the request in `cx`: [`Self::from_query`] over
134 /// the request URI's query. Renders without a request in `cx` (e.g. unit
135 /// tests) get neutral state.
136 pub fn from_cx(cx: &Cx) -> Self {
137 Self::from_query(&request_query(cx))
138 }
139
140 /// [`Self::from_query`] for the table whose parameters carry `prefix`:
141 /// only the parameters spelled `{prefix}.{name}` are read, as `name`, and
142 /// the parsed state carries the prefix so its links spell the same names.
143 pub fn from_query_prefixed(query: &str, prefix: &str) -> Self {
144 let dotted = format!("{prefix}.");
145 let own = form_urlencoded::parse(query.as_bytes()).filter_map(|(name, value)| {
146 name.strip_prefix(dotted.as_str())
147 .map(|name| (Cow::Owned(name.to_string()), value))
148 });
149 Self {
150 prefix: Some(prefix.to_string()),
151 ..Self::from_pairs(own)
152 }
153 }
154
155 /// The URL parameter this table spells `name` as: `name` itself, or
156 /// `{prefix}.{name}` for a prefixed table.
157 pub(crate) fn param(&self, name: &str) -> String {
158 match &self.prefix {
159 Some(prefix) => format!("{prefix}.{name}"),
160 None => name.to_string(),
161 }
162 }
163
164 /// The URL parameter the filter `name` travels as: `f.{name}`, prefixed
165 /// like every other parameter.
166 pub(crate) fn filter_param(&self, name: &str) -> String {
167 self.param(&format!("{FILTER_PREFIX}{name}"))
168 }
169
170 /// The `f.{name}` and `f.{name}.{part}` parameters `value` spells, in the order the filter
171 /// holds them, prefixed like every other parameter.
172 fn filter_pairs<'a>(
173 &'a self,
174 name: &'a str,
175 value: &'a FilterValue,
176 ) -> impl Iterator<Item = (String, &'a str)> + 'a {
177 let param = self.filter_param(name);
178 let values = value
179 .values()
180 .iter()
181 .map(move |held| (param.clone(), held.as_str()));
182 let parts = value
183 .parts()
184 .map(move |(part, held)| (self.filter_param(&format!("{name}.{part}")), held));
185 values.chain(parts)
186 }
187
188 /// How a link spells the tab `key`, encoded like every other parameter.
189 pub(crate) fn spell_tab(&self, key: &str) -> String {
190 href::encode_query(&[(self.param("tab"), key)])
191 }
192
193 /// How a link spells `name`'s value: the parameters it travels as, encoded like every other
194 /// parameter.
195 pub(crate) fn spell_filter(&self, name: &str, value: &FilterValue) -> String {
196 href::encode_query(&self.filter_pairs(name, value).collect::<Vec<_>>())
197 }
198
199 /// Parse the state from a URL query (without the leading `?`): the one
200 /// parser behind the page and its exports.
201 ///
202 /// A blank or unknown query parses as neutral state rather than failing
203 /// the request. A duplicate key keeps its first occurrence, except a
204 /// filter's value: each `f.<name>` occurrence adds one, in order, and a
205 /// repeated value or part keeps its first occurrence. A blank or dropped
206 /// occurrence adds nothing (a dropped one flags [`Self::filters_dropped`]).
207 /// `q` is trimmed and clamped to `MAX_QUERY_TERM`, `dir` is trimmed before
208 /// comparing, and at most `MAX_FILTERS` filters of at most
209 /// `MAX_FILTER_VALUES` values and parts, each name, part and value at most
210 /// `MAX_FILTER_LEN` bytes, apply. A cursor token is checked later, when it
211 /// decodes.
212 ///
213 /// A rerun parses a client-owned query, so the parse is linear in its
214 /// length: only the known keys are remembered.
215 pub fn from_query(query: &str) -> Self {
216 Self::from_pairs(form_urlencoded::parse(query.as_bytes()))
217 }
218
219 /// The parse behind [`Self::from_query`] and [`Self::from_query_prefixed`],
220 /// over the query's decoded pairs with any table prefix already stripped.
221 fn from_pairs<'q>(pairs: impl Iterator<Item = (Cow<'q, str>, Cow<'q, str>)>) -> Self {
222 let mut state = Self::default();
223 let (mut sort, mut dir, mut after, mut before) = (None, None, None, None);
224 let mut seen = [false; 9];
225 for (key, value) in pairs {
226 if let Some(filter) = key.strip_prefix(FILTER_PREFIX) {
227 state.add_filter(filter, value.trim());
228 continue;
229 }
230 let slot = match key.as_ref() {
231 "q" => 0,
232 "sort" => 1,
233 "dir" => 2,
234 "after" => 3,
235 "before" => 4,
236 "group_by" => 5,
237 "page" => 6,
238 "per_page" => 7,
239 "tab" => 8,
240 // The retired single-parameter filter spelling: a saved link
241 // must warn (and its export refuse), not list everything.
242 "filters" => {
243 state.filters_dropped |= !value.trim().is_empty();
244 continue;
245 }
246 _ => continue,
247 };
248 if std::mem::replace(&mut seen[slot], true) {
249 continue;
250 }
251 let non_empty = || Some(value.trim().to_string()).filter(|v| !v.is_empty());
252 let positive = || value.trim().parse::<u32>().ok().filter(|n| *n > 0);
253 match slot {
254 0 => state.search = Some(clamp_query_term(&value)).filter(|t| !t.is_empty()),
255 1 => sort = non_empty(),
256 2 => dir = Some(value.trim() == "desc"),
257 3 => after = non_empty(),
258 4 => before = non_empty(),
259 5 => state.group_by = non_empty(),
260 6 => state.page = positive(),
261 7 => state.per_page = positive(),
262 _ => state.tab = non_empty(),
263 }
264 }
265 state.sort = sort.map(|column| Sort {
266 column,
267 descending: dir.unwrap_or(false),
268 });
269 state.cursor = match (after, before) {
270 (Some(token), None) => Some(Cursor::After(token)),
271 (None, Some(token)) => Some(Cursor::Before(token)),
272 _ => None,
273 };
274 state
275 }
276
277 /// Add one `f.{filter}={value}` occurrence, `filter` naming the filter
278 /// and, after its first `.`, the part.
279 fn add_filter(&mut self, filter: &str, value: &str) {
280 let (name, part) = match filter.split_once('.') {
281 Some((name, part)) => (name, Some(part)),
282 None => (filter, None),
283 };
284 let held = self.filters.get(name);
285 if name.is_empty()
286 || part == Some("")
287 || value.is_empty()
288 || held.is_some_and(|held| held.holds(part, value))
289 {
290 return;
291 }
292 let room = match held {
293 Some(held) => held.len() < MAX_FILTER_VALUES,
294 None => self.filters.len() < MAX_FILTERS,
295 };
296 if !room
297 || [name, part.unwrap_or_default(), value]
298 .iter()
299 .any(|s| s.len() > MAX_FILTER_LEN)
300 {
301 self.filters_dropped = true;
302 return;
303 }
304 let held = self.filters.entry(name.to_string()).or_default();
305 match part {
306 Some(part) => held.insert_part(part, value),
307 None => held.push(value),
308 }
309 }
310
311 /// The state's full query: every parameter [`Self::list_url`] carries,
312 /// without the path or the leading `?`.
313 pub(crate) fn query(&self) -> String {
314 self.project(self.full())
315 }
316
317 /// URL projection: `TableState` owns the table's URL vocabulary.
318 /// Callers ask for a user intent, never a parameter list, so adding a
319 /// parameter cannot silently drop it from half the links.
320 ///
321 /// One private encoder ([`Self::project`]) holds the vocabulary in
322 /// canonical order `tab, q, sort, dir, f.*, group_by, per_page, page, after|before`. Only a
323 /// filter's values depend on the order, and each filter writes them in
324 /// the order it holds them.
325 ///
326 /// Expects `group_by` pre-normalized: render seams normalize through
327 /// [`Table::normalize_state`](crate::table::Table::normalize_state), so
328 /// the projection echoes `state.group_by` as-is.
329 ///
330 /// Full state, including the cursor: the retry link for failures that
331 /// keep their evidence.
332 pub(crate) fn list_url(&self, path: &str) -> String {
333 href::with_query(path, &self.query())
334 }
335
336 /// Drops `q` (and the position in its result set); keeps the filters.
337 pub(crate) fn without_search(&self, path: &str) -> String {
338 let projection = UrlProjection {
339 search: None,
340 ..self.first_page_projection()
341 };
342 href::with_query(path, &self.project(projection))
343 }
344
345 /// Drops the filters (and the position in their result set); keeps the
346 /// search term and the tab.
347 pub(crate) fn without_filters(&self, path: &str) -> String {
348 let projection = UrlProjection {
349 filters: false,
350 ..self.first_page_projection()
351 };
352 href::with_query(path, &self.project(projection))
353 }
354
355 /// Drops the filters and the tab (and the position in their result set); keeps the search
356 /// term: the link out of a list ignoring what its URL asks.
357 pub(crate) fn without_narrowing(&self, path: &str) -> String {
358 let projection = UrlProjection {
359 filters: false,
360 tab: None,
361 ..self.first_page_projection()
362 };
363 href::with_query(path, &self.project(projection))
364 }
365
366 /// Shows the tab `key`, `None` for the first, from the first page of its result set.
367 pub(crate) fn with_tab(&self, path: &str, key: Option<&str>) -> String {
368 let projection = UrlProjection {
369 tab: key,
370 ..self.first_page_projection()
371 };
372 href::with_query(path, &self.project(projection))
373 }
374
375 /// Drops the cursor and the page number; keeps everything else, the page size included.
376 /// Back-to-first-page and the cursor-failure retry link.
377 pub(crate) fn first_page(&self, path: &str) -> String {
378 href::with_query(path, &self.project(self.first_page_projection()))
379 }
380
381 /// Full state but the cursor, the page number kept: where a write returns the reader. A
382 /// cursor can name a row the write removed; a page number past the end reads as the last.
383 pub(crate) fn without_cursor(&self, path: &str) -> String {
384 let projection = UrlProjection {
385 cursor: None,
386 ..self.full()
387 };
388 href::with_query(path, &self.project(projection))
389 }
390
391 /// Full state with `cursor` in place of the current position: a cursor pager's links.
392 pub(crate) fn with_cursor(&self, path: &str, cursor: &Cursor) -> String {
393 let projection = UrlProjection {
394 cursor: Some(cursor),
395 ..self.first_page_projection()
396 };
397 href::with_query(path, &self.project(projection))
398 }
399
400 /// Full state at page `page`: a numbered pager's links. Page 1 spells no parameter.
401 pub(crate) fn with_page(&self, path: &str, page: u32) -> String {
402 let projection = UrlProjection {
403 page: Some(page).filter(|page| *page > 1),
404 ..self.first_page_projection()
405 };
406 href::with_query(path, &self.project(projection))
407 }
408
409 /// Replaces `sort`/`dir`, drops the position: a new ordering is a new
410 /// result set.
411 pub(crate) fn sorted_by(&self, path: &str, column: &str, descending: bool) -> String {
412 let projection = UrlProjection {
413 sort: Some((column, if descending { "desc" } else { "asc" })),
414 ..self.first_page_projection()
415 };
416 href::with_query(path, &self.project(projection))
417 }
418
419 /// The parameters a form rerunning this state at its first page carries, besides its own
420 /// fields: every one but `skip`, the page and the cursor.
421 pub(crate) fn hidden_pairs(&self, skip: &str) -> Vec<(String, String)> {
422 let skip = self.param(skip);
423 form_urlencoded::parse(self.project(self.first_page_projection()).as_bytes())
424 .filter(|(name, _)| *name != skip)
425 .map(|(name, value)| (name.into_owned(), value.into_owned()))
426 .collect()
427 }
428
429 /// `?sort=` column + `?dir=` value for the projection.
430 fn sort_pair(&self) -> Option<(&str, &str)> {
431 self.sort
432 .as_ref()
433 .map(|s| (s.column.as_str(), if s.descending { "desc" } else { "asc" }))
434 }
435
436 /// The projection that keeps every link parameter.
437 fn full(&self) -> UrlProjection<'_> {
438 UrlProjection {
439 tab: self.tab.as_deref(),
440 search: self.search.as_deref(),
441 sort: self.sort_pair(),
442 filters: true,
443 group_by: self.group_by.as_deref(),
444 cursor: self.cursor.as_ref(),
445 page: self.page,
446 per_page: self.per_page,
447 }
448 }
449
450 /// The projection that keeps every parameter but the position: the result set's first page.
451 fn first_page_projection(&self) -> UrlProjection<'_> {
452 UrlProjection {
453 cursor: None,
454 page: None,
455 ..self.full()
456 }
457 }
458
459 /// The one encoder: every table link's parameter vocabulary lives here.
460 ///
461 /// The exhaustive destructure fails compilation when a field is added to
462 /// `TableState`, forcing the author to decide where it projects.
463 fn project(&self, projection: UrlProjection<'_>) -> String {
464 let TableState {
465 prefix: _,
466 search: _,
467 sort: _,
468 cursor: _,
469 page: _,
470 per_page: _,
471 filters: _,
472 filters_dropped: _,
473 group_by: _,
474 tab: _,
475 } = self;
476 let filters = projection
477 .filters
478 .then_some(&self.filters)
479 .into_iter()
480 .flatten()
481 .flat_map(|(name, value)| self.filter_pairs(name, value))
482 .map(|(key, value)| (key, Some(value)));
483 let cursor = match projection.cursor {
484 Some(Cursor::After(token)) => ("after", Some(token.as_str())),
485 Some(Cursor::Before(token)) => ("before", Some(token.as_str())),
486 None => ("after", None),
487 };
488 let per_page = projection.per_page.map(|n| n.to_string());
489 let page = projection.page.map(|n| n.to_string());
490 let pairs: Vec<(String, &str)> = [
491 (self.param("tab"), projection.tab),
492 (self.param("q"), projection.search),
493 (
494 self.param("sort"),
495 projection.sort.map(|(column, _)| column),
496 ),
497 (self.param("dir"), projection.sort.map(|(_, dir)| dir)),
498 ]
499 .into_iter()
500 .chain(filters)
501 .chain([
502 (self.param("group_by"), projection.group_by),
503 (self.param("per_page"), per_page.as_deref()),
504 (self.param("page"), page.as_deref()),
505 (self.param(cursor.0), cursor.1),
506 ])
507 .filter_map(|(key, value)| value.map(|value| (key, value)))
508 .collect();
509 href::encode_query(&pairs)
510 }
511}
512
513/// FNV-1a (32-bit): stable across runs and Rust versions, no dependency.
514/// Used only to disambiguate DOM ids, never for anything security-relevant.
515fn fnv1a_32(s: &str) -> u32 {
516 let mut hash: u32 = 0x811c_9dc5;
517 for byte in s.bytes() {
518 hash ^= u32::from(byte);
519 hash = hash.wrapping_mul(0x0100_0193);
520 }
521 hash
522}
523
524/// Stable DOM id for a table row: Topcoat's morph (topcoat#392) follows
525/// elements by `id` across reruns, so reorderable row content needs one in
526/// addition to the keyed-diff `key:`. Derived from the row key (stable for
527/// the record, unlike a loop index), sanitized to an HTML-safe token plus a
528/// short hash: distinct keys (`Ada Lovelace`, `Ada-Lovelace`) can sanitize to
529/// the same token, and duplicate DOM ids would make the morph follow one row.
530/// `scope` names the table, distinct per table on the page: two tables can hold one key.
531pub(crate) fn row_dom_id(scope: &str, key: &str) -> String {
532 dom_id(&format!("row-{scope}"), key)
533}
534
535/// Stable DOM id for a group header.
536///
537/// Same contract as [`row_dom_id`]: the header is injected, removed and moved
538/// as the page changes, so the in-place morph needs an id derived from the
539/// group it opens rather than from its position in the page.
540pub(crate) fn group_header_dom_id(scope: &str, label: &str) -> String {
541 dom_id(&format!("group-{scope}"), label)
542}
543
544/// The one sanitizer behind both ids: `{prefix}-{token}-{hash}`.
545fn dom_id(prefix: &str, key: &str) -> String {
546 let mut out = String::with_capacity(prefix.len() + key.len() + 14);
547 out.push_str(prefix);
548 out.push('-');
549 for c in key.chars() {
550 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.') {
551 out.push(c);
552 } else {
553 out.push('-');
554 }
555 }
556 out.push_str(&format!("-{:08x}", fnv1a_32(key)));
557 out
558}
559
560/// The request's URL query, without the leading `?`; empty without a
561/// request in `cx`.
562pub(crate) fn request_query(cx: &Cx) -> String {
563 topcoat::context::try_context::<http::request::Parts>(cx)
564 .and_then(|parts| parts.uri.query().map(str::to_string))
565 .unwrap_or_default()
566}
567
568/// The query part of a URL this module built: what follows the `?`, or empty.
569pub(crate) fn query_of(url: &str) -> &str {
570 url.split_once('?').map_or("", |(_, query)| query)
571}
572
573#[cfg(test)]
574mod tests;