renox_core/select.rs
1//! Options a searchable select asks the server for: the endpoint behind the
2//! UI kit's `select(…, options_url=…, editable=…)`.
3//!
4//! The kit talks to one URL:
5//!
6//! | Request | Answer |
7//! |---|---|
8//! | `GET ?q=kop` (what was typed) | the matching options, a JSON list of [`SelectOption`] |
9//! | `GET ?values=3&values=5` (labels for values the page has) | those options |
10//! | `POST label=Coffee` (`editable`: "Add “Coffee”") | the new option, auto-selected |
11//! | `POST _method=PUT value=3&label=Iced Coffee` (`editable`: renaming the chosen one) | the option as saved |
12//!
13//! A 422 (from `Valid`) shows its first message under the field. Who may
14//! add or rename options is the handler's call, like any route.
15//!
16//! ```
17//! # use renox::prelude::*;
18//! use renox::select::{OptionQuery, SelectOption};
19//!
20//! #[derive(Model, serde::Serialize, Default)]
21//! struct Category { id: i64, name: String }
22//!
23//! async fn search(State(db): State<Db>, query: OptionQuery) -> Result<Json<Vec<SelectOption>>> {
24//! let rows = if query.is_lookup() {
25//! Category::query().where_in("id", query.values_as::<i64>()).get(&db).await?
26//! } else {
27//! Category::query()
28//! .where_op("name", "like", format!("%{}%", query.q))
29//! .order_by("name")
30//! .limit(20)
31//! .get(&db)
32//! .await?
33//! };
34//! Ok(Json(rows.iter().map(|c| SelectOption::new(c.id, &c.name)).collect()))
35//! }
36//!
37//! #[derive(serde::Deserialize)]
38//! struct NewCategory { label: String }
39//!
40//! impl Validate for NewCategory {
41//! fn rules(&self, v: &mut Validator) {
42//! v.field("label", &self.label).required().max(60).unique("category", "name");
43//! }
44//! }
45//!
46//! async fn create(State(db): State<Db>, Valid(form): Valid<NewCategory>) -> Result<Json<SelectOption>> {
47//! let mut category = Category { name: form.label.trim().to_owned(), ..Default::default() };
48//! category.save(&db).await?;
49//! Ok(Json(SelectOption::new(category.id, &category.name)))
50//! }
51//! ```
52
53use axum::extract::FromRequestParts;
54use axum::http::request::Parts;
55use serde::{Deserialize, Serialize};
56use std::convert::Infallible;
57
58/// One option of a select: the value the form sends and the label people
59/// see. Serialized as `{"value": "3", "label": "Coffee"}`; the value is
60/// always text, as a form sends it.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[non_exhaustive]
63pub struct SelectOption {
64 /// What the form sends when this option is chosen (e.g. an id).
65 pub value: String,
66 /// What the option reads.
67 pub label: String,
68}
69
70impl SelectOption {
71 /// An option from any value (an `i64` id, a `Ulid`…) and its label.
72 pub fn new(value: impl ToString, label: impl Into<String>) -> Self {
73 Self {
74 value: value.to_string(),
75 label: label.into(),
76 }
77 }
78}
79
80/// What a searchable select asks for, from the query string: `q`, the text
81/// typed (trimmed; empty when the list just opened), or `values`, the
82/// options whose labels the page needs (after a failed submit, the values
83/// sent back have no label yet).
84#[derive(Debug, Clone, Default, PartialEq, Eq)]
85#[non_exhaustive]
86pub struct OptionQuery {
87 /// The text typed, trimmed.
88 pub q: String,
89 /// Values to look up (`?values=3&values=5`), when this is a lookup.
90 pub values: Vec<String>,
91}
92
93impl OptionQuery {
94 /// Whether the select asks for labels of known values rather than
95 /// searching: answer with exactly those options.
96 pub fn is_lookup(&self) -> bool {
97 !self.values.is_empty()
98 }
99
100 /// The looked-up values parsed as the key type (`i64` ids), skipping
101 /// any that don't parse, so the query binds them typed (PostgreSQL won't
102 /// compare a `BIGINT` with text).
103 pub fn values_as<T: std::str::FromStr>(&self) -> Vec<T> {
104 self.values.iter().filter_map(|v| v.parse().ok()).collect()
105 }
106
107 /// Reads a query string (`q=kop`, `values=3&values=5`).
108 pub fn parse(query: &str) -> Self {
109 let mut parsed = OptionQuery::default();
110 for (key, value) in form_urlencoded::parse(query.as_bytes()) {
111 match key.as_ref() {
112 "q" => parsed.q = value.trim().to_owned(),
113 // `values[]` too, as some clients send it.
114 "values" | "values[]" if !value.trim().is_empty() && parsed.values.len() < 100 => {
115 parsed.values.push(value.trim().to_owned());
116 }
117 _ => {}
118 }
119 }
120 parsed
121 }
122}
123
124impl<S: Send + Sync> FromRequestParts<S> for OptionQuery {
125 type Rejection = Infallible;
126
127 async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Infallible> {
128 Ok(OptionQuery::parse(parts.uri.query().unwrap_or_default()))
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn reads_searches_and_lookups() {
138 let search = OptionQuery::parse("q=%20Iced%20Coffee%20");
139 assert_eq!(search.q, "Iced Coffee");
140 assert!(!search.is_lookup());
141 let lookup = OptionQuery::parse("values=3&values=&values%5B%5D=5&other=x");
142 assert_eq!(lookup.values, ["3", "5"]);
143 assert_eq!(
144 OptionQuery::parse("values=3&values=x").values_as::<i64>(),
145 [3]
146 );
147 assert!(lookup.is_lookup());
148 let many = OptionQuery::parse(&"values=1&".repeat(500));
149 assert_eq!(many.values.len(), 100);
150 }
151
152 #[test]
153 fn options_serialize_with_text_values() {
154 let json = serde_json::to_string(&SelectOption::new(3, "Coffee")).unwrap();
155 assert_eq!(json, r#"{"value":"3","label":"Coffee"}"#);
156 }
157}