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;