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