//! Form markup rendering. //! //! One command. Escaping is `form::escape`, which encodes all five characters //! and is sound in element text and in a double-quoted attribute alike, so no //! call site picks an escaper. //! //! # Why a batch //! //! A form's fields cross once, together, and the caller reads the results out //! of a map by name. A command per field would make every call site that //! assembles a form async. use std::collections::HashMap; use makeover_layout::{Choice, Curve, Field, FieldKind}; use makeover_webview::Emit; use makeover_webview::form::{Filling, Markup, Value, field_html}; use serde::Deserialize; use tracing::{instrument, warn}; /// One option of a select, as the frontend sends it. #[derive(Debug, Deserialize)] pub struct ChoiceSpec { /// What is submitted. value: String, /// What is read. Defaults to the value, matching `Choice::plain`. #[serde(default)] label: Option, /// Whether this option is the current one. /// /// Redundant with the field's `value` and kept because callers say it both /// ways: many compute `selected` from a comparison without also passing /// `value`, and dropping it would silently unselect those. See /// [`FieldSpec::current_value`]. #[serde(default)] selected: bool, } /// One field, as the frontend sends it. /// /// A mirror of [`Field`] plus the one thing the description deliberately does /// not carry, the current value, which arrives in [`Filling`]. /// /// This type is the wire boundary: a description reorganising itself is not a /// reason to make every caller send something new, so the JSON shape stays put /// even when the fields land in a different struct. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FieldSpec { /// `text`, `password`, `number`, `email`, `url`, `tel`, `textarea`, /// `select`, `checkbox` or `hidden`. Unknown kinds render as text. kind: String, /// The name the value submits under. name: String, /// What the user is asked for. #[serde(default)] label: String, /// Standing help. #[serde(default)] hint: Option, /// What is currently wrong. #[serde(default)] error: Option, /// Whether the form refuses to submit without it. #[serde(default)] required: bool, /// Whether it lives behind a "more options" disclosure. #[serde(default)] extended: bool, /// The current value, for everything that takes typed text. #[serde(default)] value: Option, /// The current state, for a checkbox. #[serde(default)] checked: Option, /// The options, for a select. #[serde(default)] options: Option>, /// Ghost text shown while empty. #[serde(default)] placeholder: Option, /// Markup appended after the hint. NOT escaped. /// /// The caller is stating the contents are trusted, which is what /// [`Markup`] exists to make them say out loud. Two live callers need it, /// both passing a recurrence block built elsewhere. #[serde(default)] trailing_html: Option, } impl FieldSpec { /// The value a select should show as current. /// /// `value` when the caller sent one, otherwise the value of whichever /// option marked itself selected. The description matches an option by /// value alone, which is the better contract, so the two ways the frontend /// says this are reconciled here rather than in the crate. fn current_value(&self) -> &str { if let Some(value) = self.value.as_deref() && !value.is_empty() { return value; } self.options .as_deref() .unwrap_or_default() .iter() .find(|opt| opt.selected) .map_or("", |opt| opt.value.as_str()) } } /// Map the frontend's kind string onto the description. /// /// Unknown kinds fall back to text rather than failing the render. A form that /// draws with one field slightly wrong is recoverable; a form that does not /// draw is not, and the warning says which happened. fn kind_of(spec_kind: &str) -> FieldKind { match spec_kind { "password" => FieldKind::Secret, "number" => FieldKind::Number, "email" => FieldKind::Email, "url" => FieldKind::Url, "tel" => FieldKind::Tel, "textarea" => FieldKind::Textarea, "select" => FieldKind::Select, "checkbox" => FieldKind::Checkbox, "hidden" => FieldKind::Hidden, "text" => FieldKind::Text, other => { warn!(kind = other, "unknown field kind, rendering as text"); FieldKind::Text } } } /// Render a form's fields to markup, keyed by field name. /// /// `id_prefix` scopes the `id` attributes to one instance of the form, which /// matters because the new-entity and edit-entity modals are the same field set /// and their ids would otherwise collide. It never reaches `name`. /// /// Infallible by construction: every field renders to something, and an /// unrecognised kind renders as text with a warning rather than taking the form /// down with it. #[tauri::command] #[instrument(skip(fields), fields(count = fields.len()))] pub async fn render_form_fields( fields: Vec, id_prefix: Option, ) -> HashMap { let opts = Emit::default(); let mut out = HashMap::with_capacity(fields.len()); for spec in &fields { let kind = kind_of(&spec.kind); // Built here rather than inline so the borrow outlives the Field that // points into it. let choices: Vec> = spec .options .as_deref() .unwrap_or_default() .iter() .map(|opt| Choice::new(&opt.value, opt.label.as_deref().unwrap_or(&opt.value))) .collect(); let field = Field { kind, name: &spec.name, label: &spec.label, hint: spec.hint.as_deref(), error: spec.error.as_deref(), // makeover-layout 0.36.0's note, on the same footing as the // constraints below: the JS form spec carries no consequence // channel, so this says nothing rather than promoting a hint into // one and guessing at its tone. note: None, // Both moved onto the description in makeover-layout 0.8.0. They // arrived in `Filling` until then, which is why this function used // to assemble the field and its filling from the same spec in two // places. placeholder: spec.placeholder.as_deref(), options: &choices, // makeover-layout 0.38.0's theme picker, and the same position as // the file members below: this JS form spec cannot name a theme // kind, so it never carries themes. The described settings screen // is where the picker lives (`quasi::settings`), and it builds one // out of what the host resolved rather than out of a form spec. themes: &[], follows: None, // makeover-layout 0.31.0's file members, and the same position: // the JS form spec carries no accept list and no multiplicity, so // an empty list is what this app actually says. It means any file, // which is what its one file field already was. accept: &[], multiple: false, required: spec.required, // makeover-layout 0.11.0's constraints. This form spec carries none // of them yet: the runtime form model is the JS one, and adding // them here without the spec growing fields would be inventing // limits the app never stated. max_length: None, min: None, max: None, step: None, // makeover-layout 0.32.0's curve, on the same footing as the // constraints above: this spec describes no ranges, so the default // linear track with the host's own granularity is what the app // actually says rather than a mapping invented here. curve: Curve::default(), // makeover-layout 0.33.0's unit, on the same footing again: the JS // form spec has no field for what a number is measured in, so this // says nothing rather than parsing one out of the label. unit: None, // makeover-layout 0.34.0's second end, on the same footing again: // this spec describes one value per field, so an interval is not // something the JS form model can say and naming an upper end here // would be inventing a question the app never asked. upper_name: None, extended: spec.extended, // makeover-layout 0.37.0's instant flag, on the same footing again: // the JS form spec has no way to say a datetime is submitted as the // moment it names, and this runtime form posts to Tauri commands // rather than to a route storing an instant. Saying it here would // convert a value nothing asked to have converted. as_instant: false, }; let value = match kind { FieldKind::Checkbox => Value::On(spec.checked.unwrap_or_default()), // A select's value is the value of one of its options and the // options are on the field now, so it takes the same variant // everything else typed does. FieldKind::Select => Value::Text(spec.current_value()), _ => match spec.value.as_deref() { Some(text) => Value::Text(text), None => Value::Absent, }, }; let filling = Filling { value, trailing: spec.trailing_html.as_deref().map(Markup), // Nothing of this app's own goes on the control. The seam exists // for a host that knows a fact the description does not carry, and // quasi's suggestion source is the one caller there is. control_attrs: None, id_prefix: id_prefix.as_deref(), }; out.insert(spec.name.clone(), field_html(&field, &filling, &opts)); } out }