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