Skip to main content

tablo_core/table/
state.rs

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