Skip to main content

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}