Skip to main content

max / goingson

10.2 KB · 257 lines History Blame Raw
1 //! Form markup rendering.
2 //!
3 //! One command. Escaping is `form::escape`, which encodes all five characters
4 //! and is sound in element text and in a double-quoted attribute alike, so no
5 //! call site picks an escaper.
6 //!
7 //! # Why a batch
8 //!
9 //! A form's fields cross once, together, and the caller reads the results out
10 //! of a map by name. A command per field would make every call site that
11 //! assembles a form async.
12
13 use std::collections::HashMap;
14
15 use makeover_layout::{Choice, Curve, Field, FieldKind};
16 use makeover_webview::Emit;
17 use makeover_webview::form::{Filling, Markup, Value, field_html};
18 use serde::Deserialize;
19 use tracing::{instrument, warn};
20
21 /// One option of a select, as the frontend sends it.
22 #[derive(Debug, Deserialize)]
23 pub struct ChoiceSpec {
24 /// What is submitted.
25 value: String,
26 /// What is read. Defaults to the value, matching `Choice::plain`.
27 #[serde(default)]
28 label: Option<String>,
29 /// Whether this option is the current one.
30 ///
31 /// Redundant with the field's `value` and kept because callers say it both
32 /// ways: many compute `selected` from a comparison without also passing
33 /// `value`, and dropping it would silently unselect those. See
34 /// [`FieldSpec::current_value`].
35 #[serde(default)]
36 selected: bool,
37 }
38
39 /// One field, as the frontend sends it.
40 ///
41 /// A mirror of [`Field`] plus the one thing the description deliberately does
42 /// not carry, the current value, which arrives in [`Filling`].
43 ///
44 /// This type is the wire boundary: a description reorganising itself is not a
45 /// reason to make every caller send something new, so the JSON shape stays put
46 /// even when the fields land in a different struct.
47 #[derive(Debug, Deserialize)]
48 #[serde(rename_all = "camelCase")]
49 pub struct FieldSpec {
50 /// `text`, `password`, `number`, `email`, `url`, `tel`, `textarea`,
51 /// `select`, `checkbox` or `hidden`. Unknown kinds render as text.
52 kind: String,
53 /// The name the value submits under.
54 name: String,
55 /// What the user is asked for.
56 #[serde(default)]
57 label: String,
58 /// Standing help.
59 #[serde(default)]
60 hint: Option<String>,
61 /// What is currently wrong.
62 #[serde(default)]
63 error: Option<String>,
64 /// Whether the form refuses to submit without it.
65 #[serde(default)]
66 required: bool,
67 /// Whether it lives behind a "more options" disclosure.
68 #[serde(default)]
69 extended: bool,
70 /// The current value, for everything that takes typed text.
71 #[serde(default)]
72 value: Option<String>,
73 /// The current state, for a checkbox.
74 #[serde(default)]
75 checked: Option<bool>,
76 /// The options, for a select.
77 #[serde(default)]
78 options: Option<Vec<ChoiceSpec>>,
79 /// Ghost text shown while empty.
80 #[serde(default)]
81 placeholder: Option<String>,
82 /// Markup appended after the hint. NOT escaped.
83 ///
84 /// The caller is stating the contents are trusted, which is what
85 /// [`Markup`] exists to make them say out loud. Two live callers need it,
86 /// both passing a recurrence block built elsewhere.
87 #[serde(default)]
88 trailing_html: Option<String>,
89 }
90
91 impl FieldSpec {
92 /// The value a select should show as current.
93 ///
94 /// `value` when the caller sent one, otherwise the value of whichever
95 /// option marked itself selected. The description matches an option by
96 /// value alone, which is the better contract, so the two ways the frontend
97 /// says this are reconciled here rather than in the crate.
98 fn current_value(&self) -> &str {
99 if let Some(value) = self.value.as_deref()
100 && !value.is_empty()
101 {
102 return value;
103 }
104 self.options
105 .as_deref()
106 .unwrap_or_default()
107 .iter()
108 .find(|opt| opt.selected)
109 .map_or("", |opt| opt.value.as_str())
110 }
111 }
112
113 /// Map the frontend's kind string onto the description.
114 ///
115 /// Unknown kinds fall back to text rather than failing the render. A form that
116 /// draws with one field slightly wrong is recoverable; a form that does not
117 /// draw is not, and the warning says which happened.
118 fn kind_of(spec_kind: &str) -> FieldKind {
119 match spec_kind {
120 "password" => FieldKind::Secret,
121 "number" => FieldKind::Number,
122 "email" => FieldKind::Email,
123 "url" => FieldKind::Url,
124 "tel" => FieldKind::Tel,
125 "textarea" => FieldKind::Textarea,
126 "select" => FieldKind::Select,
127 "checkbox" => FieldKind::Checkbox,
128 "hidden" => FieldKind::Hidden,
129 "text" => FieldKind::Text,
130 other => {
131 warn!(kind = other, "unknown field kind, rendering as text");
132 FieldKind::Text
133 }
134 }
135 }
136
137 /// Render a form's fields to markup, keyed by field name.
138 ///
139 /// `id_prefix` scopes the `id` attributes to one instance of the form, which
140 /// matters because the new-entity and edit-entity modals are the same field set
141 /// and their ids would otherwise collide. It never reaches `name`.
142 ///
143 /// Infallible by construction: every field renders to something, and an
144 /// unrecognised kind renders as text with a warning rather than taking the form
145 /// down with it.
146 #[tauri::command]
147 #[instrument(skip(fields), fields(count = fields.len()))]
148 pub async fn render_form_fields(
149 fields: Vec<FieldSpec>,
150 id_prefix: Option<String>,
151 ) -> HashMap<String, String> {
152 let opts = Emit::default();
153 let mut out = HashMap::with_capacity(fields.len());
154
155 for spec in &fields {
156 let kind = kind_of(&spec.kind);
157
158 // Built here rather than inline so the borrow outlives the Field that
159 // points into it.
160 let choices: Vec<Choice<'_>> = spec
161 .options
162 .as_deref()
163 .unwrap_or_default()
164 .iter()
165 .map(|opt| Choice::new(&opt.value, opt.label.as_deref().unwrap_or(&opt.value)))
166 .collect();
167
168 let field = Field {
169 kind,
170 name: &spec.name,
171 label: &spec.label,
172 hint: spec.hint.as_deref(),
173 error: spec.error.as_deref(),
174 // makeover-layout 0.36.0's note, on the same footing as the
175 // constraints below: the JS form spec carries no consequence
176 // channel, so this says nothing rather than promoting a hint into
177 // one and guessing at its tone.
178 note: None,
179 // Both moved onto the description in makeover-layout 0.8.0. They
180 // arrived in `Filling` until then, which is why this function used
181 // to assemble the field and its filling from the same spec in two
182 // places.
183 placeholder: spec.placeholder.as_deref(),
184 options: &choices,
185 // makeover-layout 0.38.0's theme picker, and the same position as
186 // the file members below: this JS form spec cannot name a theme
187 // kind, so it never carries themes. The described settings screen
188 // is where the picker lives (`quasi::settings`), and it builds one
189 // out of what the host resolved rather than out of a form spec.
190 themes: &[],
191 follows: None,
192 // makeover-layout 0.31.0's file members, and the same position:
193 // the JS form spec carries no accept list and no multiplicity, so
194 // an empty list is what this app actually says. It means any file,
195 // which is what its one file field already was.
196 accept: &[],
197 multiple: false,
198 required: spec.required,
199 // makeover-layout 0.11.0's constraints. This form spec carries none
200 // of them yet: the runtime form model is the JS one, and adding
201 // them here without the spec growing fields would be inventing
202 // limits the app never stated.
203 max_length: None,
204 min: None,
205 max: None,
206 step: None,
207 // makeover-layout 0.32.0's curve, on the same footing as the
208 // constraints above: this spec describes no ranges, so the default
209 // linear track with the host's own granularity is what the app
210 // actually says rather than a mapping invented here.
211 curve: Curve::default(),
212 // makeover-layout 0.33.0's unit, on the same footing again: the JS
213 // form spec has no field for what a number is measured in, so this
214 // says nothing rather than parsing one out of the label.
215 unit: None,
216 // makeover-layout 0.34.0's second end, on the same footing again:
217 // this spec describes one value per field, so an interval is not
218 // something the JS form model can say and naming an upper end here
219 // would be inventing a question the app never asked.
220 upper_name: None,
221 extended: spec.extended,
222 // makeover-layout 0.37.0's instant flag, on the same footing again:
223 // the JS form spec has no way to say a datetime is submitted as the
224 // moment it names, and this runtime form posts to Tauri commands
225 // rather than to a route storing an instant. Saying it here would
226 // convert a value nothing asked to have converted.
227 as_instant: false,
228 };
229
230 let value = match kind {
231 FieldKind::Checkbox => Value::On(spec.checked.unwrap_or_default()),
232 // A select's value is the value of one of its options and the
233 // options are on the field now, so it takes the same variant
234 // everything else typed does.
235 FieldKind::Select => Value::Text(spec.current_value()),
236 _ => match spec.value.as_deref() {
237 Some(text) => Value::Text(text),
238 None => Value::Absent,
239 },
240 };
241
242 let filling = Filling {
243 value,
244 trailing: spec.trailing_html.as_deref().map(Markup),
245 // Nothing of this app's own goes on the control. The seam exists
246 // for a host that knows a fact the description does not carry, and
247 // quasi's suggestion source is the one caller there is.
248 control_attrs: None,
249 id_prefix: id_prefix.as_deref(),
250 };
251
252 out.insert(spec.name.clone(), field_html(&field, &filling, &opts));
253 }
254
255 out
256 }
257