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