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;