Skip to main content

cratefield_ui/
fields.rs

1//! From an action's input schema to renderable fields, and from a
2//! submitted form back to the JSON the module accepts.
3//!
4//! The schema is whatever `schemars` derived from the handler's body type
5//! (`cratefield_core::schema_for`), with `x-cf-*` hints on the properties.
6//! Property order is struct order (`preserve_order`), so fields render in
7//! the order the module author wrote them.
8
9use std::collections::BTreeMap;
10
11use serde_json::{Map, Value};
12
13/// The input control a field renders as.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Widget {
16    Text,
17    Email,
18    Number,
19    Select,
20    Textarea,
21    Checkbox,
22    /// A secret typed by the visitor (the admin token); never pre-filled.
23    Password,
24    /// Never rendered as a control; carried as `<input type="hidden">`
25    /// when the page supplies a value, omitted otherwise.
26    Hidden,
27}
28
29/// One choice of a `Select`.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Option_ {
32    pub value: String,
33    pub label: String,
34}
35
36/// One renderable field of a form.
37#[derive(Debug, Clone)]
38pub struct Field {
39    /// The JSON property name; also the form control's `name`.
40    pub name: String,
41    pub label: String,
42    pub widget: Widget,
43    pub required: bool,
44    pub placeholder: Option<String>,
45    pub help: Option<String>,
46    pub options: Vec<Option_>,
47    /// JSON `type` the module expects (`string`, `integer`, `number`,
48    /// `boolean`), used when converting the form back.
49    pub json_type: JsonType,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum JsonType {
54    String,
55    Integer,
56    Number,
57    Boolean,
58    /// Objects and arrays: never form-renderable, always hidden.
59    Other,
60}
61
62/// Reads the fields of an object schema in property order.
63#[must_use]
64pub fn fields_of(schema: &Value) -> Vec<Field> {
65    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
66        return Vec::new();
67    };
68    let required: Vec<&str> = schema
69        .get("required")
70        .and_then(Value::as_array)
71        .map(|list| list.iter().filter_map(Value::as_str).collect())
72        .unwrap_or_default();
73    properties
74        .iter()
75        .map(|(name, property)| field_of(name, property, required.contains(&name.as_str())))
76        .collect()
77}
78
79fn field_of(name: &str, property: &Value, required: bool) -> Field {
80    let json_type = json_type_of(property);
81    let hint = |key: &str| property.get(key);
82    let hidden = hint("x-cf-hidden")
83        .and_then(Value::as_bool)
84        .unwrap_or(false)
85        || json_type == JsonType::Other;
86    let options: Vec<Option_> = hint("x-cf-options")
87        .and_then(Value::as_array)
88        .map(|list| {
89            list.iter()
90                .filter_map(|item| {
91                    let value = item.get("value").and_then(Value::as_str)?;
92                    let label = item.get("label").and_then(Value::as_str).unwrap_or(value);
93                    Some(Option_ {
94                        value: value.to_owned(),
95                        label: label.to_owned(),
96                    })
97                })
98                .collect()
99        })
100        .or_else(|| {
101            property.get("enum").and_then(Value::as_array).map(|list| {
102                list.iter()
103                    .filter_map(Value::as_str)
104                    .map(|value| Option_ {
105                        value: value.to_owned(),
106                        label: humanize(value),
107                    })
108                    .collect()
109            })
110        })
111        .unwrap_or_default();
112    let widget = if hidden {
113        Widget::Hidden
114    } else {
115        match hint("x-cf-widget").and_then(Value::as_str) {
116            Some("email") => Widget::Email,
117            Some("select") => Widget::Select,
118            Some("textarea") => Widget::Textarea,
119            Some("checkbox") => Widget::Checkbox,
120            Some("number") => Widget::Number,
121            Some("password") => Widget::Password,
122            Some("text") => Widget::Text,
123            _ if !options.is_empty() => Widget::Select,
124            _ => match json_type {
125                JsonType::Boolean => Widget::Checkbox,
126                JsonType::Integer | JsonType::Number => Widget::Number,
127                _ if property.get("format").and_then(Value::as_str) == Some("email") => {
128                    Widget::Email
129                }
130                _ => Widget::Text,
131            },
132        }
133    };
134    let text = |key: &str| hint(key).and_then(Value::as_str).map(str::to_owned);
135    Field {
136        name: name.to_owned(),
137        label: text("x-cf-label").unwrap_or_else(|| humanize(name)),
138        widget,
139        required,
140        placeholder: text("x-cf-placeholder"),
141        help: text("x-cf-help"),
142        options,
143        json_type,
144    }
145}
146
147fn json_type_of(property: &Value) -> JsonType {
148    let names: Vec<&str> = match property.get("type") {
149        Some(Value::String(one)) => vec![one.as_str()],
150        Some(Value::Array(many)) => many.iter().filter_map(Value::as_str).collect(),
151        _ => Vec::new(),
152    };
153    let first = names.iter().find(|t| **t != "null").copied();
154    match first {
155        Some("string") => JsonType::String,
156        Some("integer") => JsonType::Integer,
157        Some("number") => JsonType::Number,
158        Some("boolean") => JsonType::Boolean,
159        // Objects, arrays, or no `type` at all (`serde_json::Value`):
160        // anything goes, which a form cannot render.
161        _ => JsonType::Other,
162    }
163}
164
165/// `referral_code` -> `Referral code`, `captchaToken` -> `Captcha token`.
166#[must_use]
167pub fn humanize(name: &str) -> String {
168    let mut out = String::with_capacity(name.len() + 4);
169    for (i, ch) in name.chars().enumerate() {
170        if ch == '_' || ch == '-' {
171            out.push(' ');
172        } else if ch.is_ascii_uppercase() && i > 0 {
173            out.push(' ');
174            out.push(ch.to_ascii_lowercase());
175        } else if i == 0 {
176            out.extend(ch.to_uppercase());
177        } else {
178            out.push(ch);
179        }
180    }
181    out
182}
183
184/// Submitted values keyed by control name, whatever the source (form
185/// body on a POST, query string on a GET that pre-fills).
186pub type Values = BTreeMap<String, String>;
187
188/// Converts a submitted form into the JSON object the module's handler
189/// deserializes. Only declared fields are forwarded; an empty optional
190/// string is omitted rather than sent as `""`; a checkbox is `true` when
191/// present and `false` when absent; numbers that do not parse are sent as
192/// the raw string so the module's own validation produces the error.
193#[must_use]
194pub fn form_to_json(fields: &[Field], values: &Values) -> Map<String, Value> {
195    let mut object = Map::new();
196    for field in fields {
197        let raw = values.get(&field.name).map(String::as_str);
198        match field.json_type {
199            JsonType::Boolean => {
200                let checked = raw.is_some_and(|v| matches!(v, "on" | "true" | "1"));
201                if checked || field.required {
202                    object.insert(field.name.clone(), Value::Bool(checked));
203                }
204            }
205            JsonType::Integer => {
206                if let Some(raw) = raw.filter(|v| !v.is_empty()) {
207                    let value = raw
208                        .parse::<i64>()
209                        .map_or_else(|_| Value::String(raw.to_owned()), Value::from);
210                    object.insert(field.name.clone(), value);
211                }
212            }
213            JsonType::Number => {
214                if let Some(raw) = raw.filter(|v| !v.is_empty()) {
215                    let value = raw
216                        .parse::<f64>()
217                        .ok()
218                        .and_then(serde_json::Number::from_f64)
219                        .map_or_else(|| Value::String(raw.to_owned()), Value::Number);
220                    object.insert(field.name.clone(), value);
221                }
222            }
223            JsonType::String => {
224                if let Some(raw) = raw
225                    && (!raw.is_empty() || field.required)
226                {
227                    object.insert(field.name.clone(), Value::String(raw.to_owned()));
228                }
229            }
230            JsonType::Other => {
231                // Nested values only arrive as JSON text in a hidden
232                // control (a page that pre-fills `answers`).
233                if let Some(parsed) = raw.and_then(|v| serde_json::from_str::<Value>(v).ok()) {
234                    object.insert(field.name.clone(), parsed);
235                }
236            }
237        }
238    }
239    object
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use schemars::JsonSchema;
246
247    #[derive(JsonSchema)]
248    #[allow(dead_code)]
249    struct Body {
250        #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
251        email: String,
252        product: String,
253        count: Option<u32>,
254        agree: bool,
255        #[schemars(extend("x-cf-hidden" = true))]
256        #[serde(rename = "captchaToken")]
257        captcha_token: Option<String>,
258        answers: Option<serde_json::Value>,
259    }
260
261    fn schema() -> Value {
262        let mut schema = cratefield_core::schema_for::<Body>();
263        cratefield_core::hint_field(
264            &mut schema,
265            "product",
266            "enum",
267            serde_json::json!(["a", "b-c"]),
268        );
269        schema.to_value()
270    }
271
272    #[test]
273    fn fields_follow_struct_order_and_infer_widgets() {
274        let fields = fields_of(&schema());
275        let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
276        assert_eq!(
277            names,
278            [
279                "email",
280                "product",
281                "count",
282                "agree",
283                "captchaToken",
284                "answers"
285            ]
286        );
287        assert_eq!(fields[0].widget, Widget::Email);
288        assert!(fields[0].required);
289        assert_eq!(fields[0].label, "Email");
290        assert_eq!(fields[1].widget, Widget::Select);
291        assert_eq!(fields[1].options[1].label, "B c");
292        assert_eq!(fields[2].widget, Widget::Number);
293        assert!(!fields[2].required);
294        assert_eq!(fields[3].widget, Widget::Checkbox);
295        assert_eq!(fields[4].widget, Widget::Hidden);
296        assert_eq!(fields[4].label, "Captcha token");
297        assert_eq!(fields[5].widget, Widget::Hidden, "untyped value is hidden");
298    }
299
300    #[test]
301    fn form_to_json_respects_types_and_omits_empties() {
302        let fields = fields_of(&schema());
303        let values: Values = [
304            ("email", "a@b.co"),
305            ("product", "a"),
306            ("count", "3"),
307            ("agree", "on"),
308            ("captchaToken", ""),
309            ("answers", r#"{"q":"x"}"#),
310            ("unknown", "dropped"),
311        ]
312        .into_iter()
313        .map(|(k, v)| (k.to_owned(), v.to_owned()))
314        .collect();
315        let json = Value::Object(form_to_json(&fields, &values));
316        assert_eq!(
317            json,
318            serde_json::json!({
319                "email": "a@b.co", "product": "a", "count": 3, "agree": true,
320                "answers": {"q": "x"}
321            })
322        );
323
324        let empty: Values = Values::new();
325        let json = Value::Object(form_to_json(&fields, &empty));
326        assert_eq!(json, serde_json::json!({ "agree": false }));
327
328        let bad: Values = [("count".to_owned(), "lots".to_owned())].into();
329        let json = Value::Object(form_to_json(&fields, &bad));
330        assert_eq!(json["count"], "lots", "unparsable number goes through raw");
331    }
332
333    #[test]
334    fn humanize_splits_snake_kebab_and_camel() {
335        assert_eq!(humanize("referral_code"), "Referral code");
336        assert_eq!(humanize("captchaToken"), "Captcha token");
337        assert_eq!(humanize("email-signup"), "Email signup");
338        assert_eq!(humanize("join"), "Join");
339    }
340}