Skip to main content

tablo_core/table/
state.rs

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