use std::{borrow::Cow, collections::BTreeMap};
use topcoat::{
context::Cx,
runtime::{Signal, signal},
};
use crate::{query_term::clamp_query_term, topcoat_compat::href};
#[derive(Clone)]
pub(crate) struct TableSignals {
pub(crate) query: Signal<String>,
pub(crate) bulk: Signal<String>,
pub(crate) confirm: ConfirmSignals,
}
#[derive(Clone)]
pub(crate) struct ConfirmSignals {
pub(crate) action: Signal<String>,
pub(crate) title: Signal<String>,
pub(crate) label: Signal<String>,
pub(crate) bulk: Signal<bool>,
}
impl TableSignals {
pub(crate) fn new(cx: &Cx, prefix: Option<&str>) -> Self {
let path = topcoat::context::try_request_context::<http::request::Parts>(cx)
.map(|parts| parts.uri.path().to_string())
.unwrap_or_default();
let cx = cx.keyed(("tablo-table", path, prefix.unwrap_or_default()));
let query = request_query(&cx);
Self {
query: signal(&cx, move || query),
bulk: signal(&cx, String::new),
confirm: ConfirmSignals {
action: signal(&cx, String::new),
title: signal(&cx, String::new),
label: signal(&cx, String::new),
bulk: signal(&cx, || false),
},
}
}
pub(crate) fn state(&self, prefix: Option<&str>) -> TableState {
let query = self.query.get();
match prefix {
Some(prefix) => TableState::from_query_prefixed(&query, prefix),
None => TableState::from_query(&query),
}
}
}
pub(crate) fn bulk_token(key: &str) -> String {
format!(",{key},")
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Sort {
pub column: String,
pub descending: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cursor {
After(String),
Before(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TableState {
pub prefix: Option<String>,
pub search: Option<String>,
pub sort: Option<Sort>,
pub cursor: Option<Cursor>,
pub filters: BTreeMap<String, String>,
pub filters_dropped: bool,
pub group_by: Option<String>,
}
#[derive(Default)]
struct UrlProjection<'a> {
search: Option<&'a str>,
sort: Option<(&'a str, &'a str)>,
filters: bool,
group_by: Option<&'a str>,
cursor: Option<&'a Cursor>,
}
pub(crate) const MAX_FILTERS: usize = 32;
pub(crate) const MAX_FILTER_LEN: usize = 256;
const FILTER_PREFIX: &str = "f.";
impl TableState {
pub fn from_cx(cx: &Cx) -> Self {
Self::from_query(&request_query(cx))
}
pub fn from_query_prefixed(query: &str, prefix: &str) -> Self {
let dotted = format!("{prefix}.");
let own = form_urlencoded::parse(query.as_bytes()).filter_map(|(name, value)| {
name.strip_prefix(dotted.as_str())
.map(|name| (Cow::Owned(name.to_string()), value))
});
Self {
prefix: Some(prefix.to_string()),
..Self::from_pairs(own)
}
}
pub(crate) fn param(&self, name: &str) -> String {
match &self.prefix {
Some(prefix) => format!("{prefix}.{name}"),
None => name.to_string(),
}
}
pub(crate) fn filter_param(&self, name: &str) -> String {
self.param(&format!("{FILTER_PREFIX}{name}"))
}
pub fn from_query(query: &str) -> Self {
Self::from_pairs(form_urlencoded::parse(query.as_bytes()))
}
fn from_pairs<'q>(pairs: impl Iterator<Item = (Cow<'q, str>, Cow<'q, str>)>) -> Self {
let mut state = Self::default();
let (mut sort, mut dir, mut after, mut before) = (None, None, None, None);
let mut seen = [false; 6];
for (key, value) in pairs {
if let Some(name) = key.strip_prefix(FILTER_PREFIX) {
let value = value.trim();
if name.is_empty() || value.is_empty() || state.filters.contains_key(name) {
continue;
}
if state.filters.len() == MAX_FILTERS
|| name.len() > MAX_FILTER_LEN
|| value.len() > MAX_FILTER_LEN
{
state.filters_dropped = true;
continue;
}
state.filters.insert(name.to_string(), value.to_string());
continue;
}
let slot = match key.as_ref() {
"q" => 0,
"sort" => 1,
"dir" => 2,
"after" => 3,
"before" => 4,
"group_by" => 5,
"filters" => {
state.filters_dropped |= !value.trim().is_empty();
continue;
}
_ => continue,
};
if std::mem::replace(&mut seen[slot], true) {
continue;
}
let non_empty = || Some(value.trim().to_string()).filter(|v| !v.is_empty());
match slot {
0 => state.search = Some(clamp_query_term(&value)).filter(|t| !t.is_empty()),
1 => sort = non_empty(),
2 => dir = Some(value.trim() == "desc"),
3 => after = non_empty(),
4 => before = non_empty(),
_ => state.group_by = non_empty(),
}
}
state.sort = sort.map(|column| Sort {
column,
descending: dir.unwrap_or(false),
});
state.cursor = match (after, before) {
(Some(token), None) => Some(Cursor::After(token)),
(None, Some(token)) => Some(Cursor::Before(token)),
_ => None,
};
state
}
pub(crate) fn query(&self) -> String {
self.project(self.full())
}
pub(crate) fn list_url(&self, path: &str) -> String {
href::with_query(path, &self.query())
}
pub(crate) fn without_search(&self, path: &str) -> String {
let projection = UrlProjection {
search: None,
cursor: None,
..self.full()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn without_filters(&self, path: &str) -> String {
let projection = UrlProjection {
filters: false,
cursor: None,
..self.full()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn without_cursor(&self, path: &str) -> String {
let projection = UrlProjection {
cursor: None,
..self.full()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn with_cursor(&self, path: &str, cursor: &Cursor) -> String {
let projection = UrlProjection {
cursor: Some(cursor),
..self.full()
};
href::with_query(path, &self.project(projection))
}
pub(crate) fn sorted_by(&self, path: &str, column: &str, descending: bool) -> String {
let projection = UrlProjection {
sort: Some((column, if descending { "desc" } else { "asc" })),
cursor: None,
..self.full()
};
href::with_query(path, &self.project(projection))
}
fn sort_pair(&self) -> Option<(&str, &str)> {
self.sort
.as_ref()
.map(|s| (s.column.as_str(), if s.descending { "desc" } else { "asc" }))
}
fn full(&self) -> UrlProjection<'_> {
UrlProjection {
search: self.search.as_deref(),
sort: self.sort_pair(),
filters: true,
group_by: self.group_by.as_deref(),
cursor: self.cursor.as_ref(),
}
}
fn project(&self, projection: UrlProjection<'_>) -> String {
let TableState {
prefix: _,
search: _,
sort: _,
cursor: _,
filters: _,
filters_dropped: _,
group_by: _,
} = self;
let filters = projection
.filters
.then_some(&self.filters)
.into_iter()
.flatten()
.map(|(name, value)| (self.filter_param(name), Some(value.as_str())));
let cursor = match projection.cursor {
Some(Cursor::After(token)) => ("after", Some(token.as_str())),
Some(Cursor::Before(token)) => ("before", Some(token.as_str())),
None => ("after", None),
};
let pairs: Vec<(String, &str)> = [
(self.param("q"), projection.search),
(
self.param("sort"),
projection.sort.map(|(column, _)| column),
),
(self.param("dir"), projection.sort.map(|(_, dir)| dir)),
]
.into_iter()
.chain(filters)
.chain([
(self.param("group_by"), projection.group_by),
(self.param(cursor.0), cursor.1),
])
.filter_map(|(key, value)| value.map(|value| (key, value)))
.collect();
href::encode_query(&pairs)
}
}
pub(crate) const RECORD_ROUTE_PARAM: &str = "{id}";
pub(crate) const CREATE_ROUTE_SEGMENT: &str = "create";
pub(crate) const EDIT_ROUTE_SEGMENT: &str = "edit";
pub(crate) const DELETE_ROUTE_SEGMENT: &str = "delete";
pub(crate) const BULK_DELETE_ROUTE_SEGMENT: &str = "bulk-delete";
pub(crate) const ACTIONS_ROUTE_SEGMENT: &str = "actions";
pub(crate) const DASH_ROUTE_SEGMENT: &str = "-";
pub(crate) const ACTION_ROUTE_PARAM: &str = "{action}";
pub(crate) fn row_edit_url(prefix: &str, key: &str) -> String {
format!(
"{prefix}/{}/{EDIT_ROUTE_SEGMENT}",
href::encode_path_segment(key)
)
}
pub(crate) fn row_view_url(prefix: &str, key: &str) -> String {
format!("{prefix}/{}", href::encode_path_segment(key))
}
pub(crate) fn delete_action_url(prefix: &str, key: &str) -> String {
format!(
"{prefix}/{}/{DELETE_ROUTE_SEGMENT}",
href::encode_path_segment(key)
)
}
pub(crate) fn row_action_url(prefix: &str, key: &str, name: &str) -> String {
format!(
"{prefix}/{}/{DASH_ROUTE_SEGMENT}/{ACTIONS_ROUTE_SEGMENT}/{name}",
href::encode_path_segment(key)
)
}
pub(crate) fn bulk_action_url(list_path: &str, name: &str) -> String {
format!("{list_path}/{DASH_ROUTE_SEGMENT}/{ACTIONS_ROUTE_SEGMENT}/{name}")
}
pub(crate) fn create_page_url(list_path: &str) -> String {
format!("{list_path}/{CREATE_ROUTE_SEGMENT}")
}
pub(crate) fn bulk_delete_url(list_path: &str) -> String {
format!("{list_path}/{BULK_DELETE_ROUTE_SEGMENT}")
}
pub(crate) const RETURN_PARAM: &str = "return";
pub(crate) fn with_return(url: &str, target: &str) -> String {
format!("{url}?{RETURN_PARAM}={}", href::encode_query_value(target))
}
fn fnv1a_32(s: &str) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for byte in s.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
pub(crate) fn row_dom_id(key: &str) -> String {
dom_id("row", key)
}
pub(crate) fn group_header_dom_id(label: &str) -> String {
dom_id("group", label)
}
fn dom_id(prefix: &str, key: &str) -> String {
let mut out = String::with_capacity(prefix.len() + key.len() + 14);
out.push_str(prefix);
out.push('-');
for c in key.chars() {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.') {
out.push(c);
} else {
out.push('-');
}
}
out.push_str(&format!("-{:08x}", fnv1a_32(key)));
out
}
pub(crate) fn request_query(cx: &Cx) -> String {
topcoat::context::try_request_context::<http::request::Parts>(cx)
.and_then(|parts| parts.uri.query().map(str::to_string))
.unwrap_or_default()
}
pub(crate) fn query_of(url: &str) -> &str {
url.split_once('?').map_or("", |(_, query)| query)
}
#[cfg(test)]
mod tests;