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