max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+279 insertions,
-23 deletions
| @@ -2248,6 +2248,8 @@ | |||
| 2248 | 2248 | "mailparse", | |
| 2249 | 2249 | "makeover", | |
| 2250 | 2250 | "makeover-build", | |
| 2251 | + | "makeover-layout", | |
| 2252 | + | "makeover-webview", | |
| 2251 | 2253 | "notify", | |
| 2252 | 2254 | "notify-debouncer-mini", | |
| 2253 | 2255 | "open", | |
| @@ -3390,15 +3392,11 @@ | |||
| 3390 | 3392 | ||
| 3391 | 3393 | [[package]] | |
| 3392 | 3394 | name = "makeover-layout" | |
| 3393 | - | version = "0.3.0" | |
| 3394 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3395 | - | checksum = "e220120a09f4ee3bf8e9a0959c3cde8bcb653827eda3f885f10671623b872252" | |
| 3395 | + | version = "0.4.0" | |
| 3396 | 3396 | ||
| 3397 | 3397 | [[package]] | |
| 3398 | 3398 | name = "makeover-webview" | |
| 3399 | - | version = "0.5.1" | |
| 3400 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3401 | - | checksum = "ff20dd9d759cfb3b407631272f93a660f889a0103f0337256238ccdde315ac9a" | |
| 3399 | + | version = "0.7.0" | |
| 3402 | 3400 | dependencies = [ | |
| 3403 | 3401 | "makeover-layout", | |
| 3404 | 3402 | ] |
| @@ -154,3 +154,12 @@ | |||
| 154 | 154 | match_same_arms = "allow" | |
| 155 | 155 | unnecessary_wraps = "allow" | |
| 156 | 156 | type_complexity = "allow" | |
| 157 | + | ||
| 158 | + | # Temporary, for the phase B adoption: the forms emitter and the description | |
| 159 | + | # are being wired in here and released once, rather than published per gap | |
| 160 | + | # found. Both are also reached indirectly through makeover-build, which pins | |
| 161 | + | # them from the registry, so patching is what keeps one copy of each in the | |
| 162 | + | # tree rather than two. | |
| 163 | + | [patch.crates-io] | |
| 164 | + | makeover-layout = { path = "../../Libraries/makeover-layout" } | |
| 165 | + | makeover-webview = { path = "../../Libraries/makeover-webview" } |
| @@ -86,6 +86,11 @@ | |||
| 86 | 86 | ||
| 87 | 87 | # Theme loading | |
| 88 | 88 | makeover = { workspace = true } | |
| 89 | + | # The forms emitter and the description it renders. Runtime deps, not build: | |
| 90 | + | # form markup depends on the value, the error and the options at the moment a | |
| 91 | + | # modal opens, so it cannot be materialised the way the stylesheet is. | |
| 92 | + | makeover-webview = "0.7.0" | |
| 93 | + | makeover-layout = "0.4.0" | |
| 89 | 94 | toml = { workspace = true } | |
| 90 | 95 | # Browser opening | |
| 91 | 96 | open = { workspace = true } |
| @@ -215,6 +215,8 @@ | |||
| 215 | 215 | // App info | |
| 216 | 216 | $crate::commands::get_changelog, | |
| 217 | 217 | $crate::commands::parse_natural_date, | |
| 218 | + | // Form markup | |
| 219 | + | $crate::commands::render_form_fields, | |
| 218 | 220 | // Window | |
| 219 | 221 | $crate::commands::open_compose_window, | |
| 220 | 222 | $crate::commands::set_window_title, |
| @@ -271,6 +271,13 @@ | |||
| 271 | 271 | parseNaturalDate: (input) => invoke('parse_natural_date', { input }), // "tomorrow", "friday 3pm" -> YYYY-MM-DDTHH:MM | |
| 272 | 272 | }, | |
| 273 | 273 | ||
| 274 | + | // Form markup, rendered by makeover-webview. A whole form's fields in one | |
| 275 | + | // round trip, keyed by name: see GoingsOn.ui.renderFields, which is what UI | |
| 276 | + | // code should call. | |
| 277 | + | forms: { | |
| 278 | + | renderFields: (fields, idPrefix) => invoke('render_form_fields', { fields, idPrefix }), | |
| 279 | + | }, | |
| 280 | + | ||
| 274 | 281 | // Day Planning | |
| 275 | 282 | dayPlanning: { | |
| 276 | 283 | getDay: (date) => invoke('get_day_planning', { date }), |
| @@ -144,7 +144,41 @@ | |||
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | 146 | /** | |
| 147 | - | * Render a single form field as an HTML string. The canonical primitive for forms. | |
| 147 | + | * Render a whole form's fields to markup, keyed by field name. | |
| 148 | + | * | |
| 149 | + | * The replacement for `renderFormField`. The markup comes from | |
| 150 | + | * makeover-webview's `form::field_html`, so one Rust encoder does the escaping | |
| 151 | + | * that four JS escapers and a per-site human choice used to do, and the initial | |
| 152 | + | * render finally sets `aria-invalid` and `aria-describedby` the way the runtime | |
| 153 | + | * validation path already did. | |
| 154 | + | * | |
| 155 | + | * Batched deliberately. Callers interpolate fields into larger template | |
| 156 | + | * literals synchronously, so a per-field async call would force a rewrite of | |
| 157 | + | * every surrounding template. Await this once, then read the map while building | |
| 158 | + | * the template exactly as before: | |
| 159 | + | * | |
| 160 | + | * const f = await GoingsOn.ui.renderFields(specs, formId); | |
| 161 | + | * el.innerHTML = `<form>${f.title}${f.notes}</form>`; | |
| 162 | + | * | |
| 163 | + | * @param {Array<Object>} specs - Field descriptions. `kind` is one of text, | |
| 164 | + | * password, number, email, url, tel, textarea, select, checkbox, hidden. | |
| 165 | + | * Carries `name`, `label`, and optionally `hint`, `error`, `required`, | |
| 166 | + | * `extended`, `value`, `checked`, `options`, `placeholder`, `trailingHtml`. | |
| 167 | + | * @param {string} [idPrefix] - Scopes the `id` attributes to one instance of | |
| 168 | + | * the form, so a new-entity and an edit-entity modal do not collide. Never | |
| 169 | + | * applied to `name`. | |
| 170 | + | * @returns {Promise<Object<string,string>>} - Markup by field name. | |
| 171 | + | */ | |
| 172 | + | async function renderFields(specs, idPrefix) { | |
| 173 | + | return GoingsOn.api.forms.renderFields(specs, idPrefix); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /** | |
| 177 | + | * Render a single form field as an HTML string. | |
| 178 | + | * | |
| 179 | + | * @deprecated Superseded by {@link renderFields}, which renders the same shape | |
| 180 | + | * from makeover-webview instead. Being retired call site by call site; do not | |
| 181 | + | * add new callers. | |
| 148 | 182 | * @param {Object} field - Field definition | |
| 149 | 183 | * @param {string} field.kind - 'text' | 'email' | 'number' | 'password' | 'date' | 'time' | 'datetime-local' | 'hidden' | 'select' | 'textarea' | 'checkbox' | |
| 150 | 184 | * @param {string} field.name - Form input name | |
| @@ -644,6 +678,7 @@ | |||
| 644 | 678 | renderEmptyState, | |
| 645 | 679 | emptyStateIcon, | |
| 646 | 680 | renderFormField, | |
| 681 | + | renderFields, | |
| 647 | 682 | ||
| 648 | 683 | // API wrapper | |
| 649 | 684 | apiCall, |
| @@ -29,7 +29,6 @@ | |||
| 29 | 29 | return; | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | - | const renderFormField = GoingsOn.ui.renderFormField; | |
| 33 | 32 | let sectionContent; | |
| 34 | 33 | ||
| 35 | 34 | if (!status.configured) { | |
| @@ -52,10 +51,21 @@ | |||
| 52 | 51 | ? 'Choose a password to encrypt your synced data. You will need this password on other devices.' | |
| 53 | 52 | : 'Enter the encryption password you set up on your first device.'; | |
| 54 | 53 | ||
| 54 | + | // Both fields cross once, before the template is built, so the | |
| 55 | + | // literal below stays synchronous. No id prefix: this section is | |
| 56 | + | // never on screen twice. | |
| 57 | + | const specs = [ | |
| 58 | + | { kind: 'password', name: 'sync-encryption-password', label: 'Encryption Password', placeholder: 'Enter password', required: true }, | |
| 59 | + | ]; | |
| 60 | + | if (isNewDevice) { | |
| 61 | + | specs.push({ kind: 'password', name: 'sync-encryption-confirm', label: 'Confirm Password', placeholder: 'Confirm password', required: true }); | |
| 62 | + | } | |
| 63 | + | const f = await GoingsOn.ui.renderFields(specs); | |
| 64 | + | ||
| 55 | 65 | sectionContent = ` | |
| 56 | 66 | <p class="text-secondary sync-hint">${esc(hint)}</p> | |
| 57 | - | ${renderFormField({ kind: 'password', name: 'sync-encryption-password', label: 'Encryption Password', placeholder: 'Enter password', required: true })} | |
| 58 | - | ${isNewDevice ? renderFormField({ kind: 'password', name: 'sync-encryption-confirm', label: 'Confirm Password', placeholder: 'Confirm password', required: true }) : ''} | |
| 67 | + | ${f['sync-encryption-password']} | |
| 68 | + | ${isNewDevice ? f['sync-encryption-confirm'] : ''} | |
| 59 | 69 | <div id="sync-encryption-error" class="sync-encryption-error"></div> | |
| 60 | 70 | <div class="sync-section-actions"> | |
| 61 | 71 | <button class="button button--primary" data-act="settings.submitEncryption" data-args='[${isNewDevice}]'>${esc(heading)}</button> | |
| @@ -81,6 +91,27 @@ | |||
| 81 | 91 | `; | |
| 82 | 92 | } catch (_) {} | |
| 83 | 93 | ||
| 94 | + | // Still on the old renderer, and the reason is worth stating: this | |
| 95 | + | // select carries an inline onchange through `attrs`, and the | |
| 96 | + | // emitter has no way to carry an app's hook attributes. It cannot | |
| 97 | + | // become a data-change either, because the delegated dispatcher | |
| 98 | + | // reads el.value off whichever element holds the attribute, so | |
| 99 | + | // moving it to a wrapper passes undefined. Blocked until the | |
| 100 | + | // emitter can carry them. | |
| 101 | + | const intervalField = GoingsOn.ui.renderFormField({ | |
| 102 | + | kind: 'select', | |
| 103 | + | name: 'sync-interval', | |
| 104 | + | id: 'sync-interval', | |
| 105 | + | label: 'Sync Interval', | |
| 106 | + | value: status.syncIntervalMinutes, | |
| 107 | + | attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' }, | |
| 108 | + | options: intervalOptions.map(m => ({ | |
| 109 | + | value: String(m), | |
| 110 | + | label: m === 1 ? '1 minute' : m + ' minutes', | |
| 111 | + | selected: status.syncIntervalMinutes === m, | |
| 112 | + | })), | |
| 113 | + | }); | |
| 114 | + | ||
| 84 | 115 | sectionContent = ` | |
| 85 | 116 | <div class="sync-status-row"> | |
| 86 | 117 | <span class="sync-status-dot"></span> | |
| @@ -113,19 +144,7 @@ | |||
| 113 | 144 | <span>Enable automatic sync</span> | |
| 114 | 145 | </label> | |
| 115 | 146 | </div> | |
| 116 | - | ${renderFormField({ | |
| 117 | - | kind: 'select', | |
| 118 | - | name: 'sync-interval', | |
| 119 | - | id: 'sync-interval', | |
| 120 | - | label: 'Sync Interval', | |
| 121 | - | value: status.syncIntervalMinutes, | |
| 122 | - | attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' }, | |
| 123 | - | options: intervalOptions.map(m => ({ | |
| 124 | - | value: String(m), | |
| 125 | - | label: m === 1 ? '1 minute' : m + ' minutes', | |
| 126 | - | selected: status.syncIntervalMinutes === m, | |
| 127 | - | })), | |
| 128 | - | })} | |
| 147 | + | ${intervalField} | |
| 129 | 148 | </div> | |
| 130 | 149 | ||
| 131 | 150 | <div class="section-divider"> |
| @@ -28,6 +28,7 @@ | |||
| 28 | 28 | pub mod error; | |
| 29 | 29 | mod event; | |
| 30 | 30 | mod export; | |
| 31 | + | mod form; | |
| 31 | 32 | mod group; | |
| 32 | 33 | mod import; | |
| 33 | 34 | mod import_external; | |
| @@ -126,6 +127,7 @@ | |||
| 126 | 127 | pub use email_sync::*; | |
| 127 | 128 | pub use event::*; | |
| 128 | 129 | pub use export::*; | |
| 130 | + | pub use form::*; | |
| 129 | 131 | pub use group::*; | |
| 130 | 132 | pub use import::*; | |
| 131 | 133 | pub use import_external::*; |
| @@ -1,0 +1,179 @@ | |||
| 1 | + | //! Form markup rendering. | |
| 2 | + | //! | |
| 3 | + | //! One command, and the only interesting thing about it is why it exists at | |
| 4 | + | //! all. Field markup used to be built in `js/components.js:renderFormField`, | |
| 5 | + | //! which held about twelve escape calls and picked among four JS escapers by | |
| 6 | + | //! hand at each one. That choice is unnecessary in Rust: `form::escape` encodes | |
| 7 | + | //! all five characters and is sound in element text and in a double-quoted | |
| 8 | + | //! attribute alike, where `escapeHtml` is built on `textContent` serialization | |
| 9 | + | //! and cannot encode a double quote. So the four-way choice disappears rather | |
| 10 | + | //! than moving. | |
| 11 | + | //! | |
| 12 | + | //! # Why a batch | |
| 13 | + | //! | |
| 14 | + | //! The frontend interpolates rendered fields into larger string-built forms, | |
| 15 | + | //! inside template literals, synchronously. A command per field would make | |
| 16 | + | //! every one of those literals async and force a rewrite of the surrounding | |
| 17 | + | //! templates, which is a migration rather than an adoption. So a form's fields | |
| 18 | + | //! cross once, together, and the caller reads the results out of a map by name | |
| 19 | + | //! while building its template exactly as before. | |
| 20 | + | ||
| 21 | + | use std::collections::HashMap; | |
| 22 | + | ||
| 23 | + | use makeover_layout::{Field, FieldKind}; | |
| 24 | + | use makeover_webview::Emit; | |
| 25 | + | use makeover_webview::form::{Choice, Filling, Markup, Value, field_html}; | |
| 26 | + | use serde::Deserialize; | |
| 27 | + | use tracing::{instrument, warn}; | |
| 28 | + | ||
| 29 | + | /// One option of a select, as the frontend sends it. | |
| 30 | + | #[derive(Debug, Deserialize)] | |
| 31 | + | pub struct ChoiceSpec { | |
| 32 | + | /// What is submitted. | |
| 33 | + | value: String, | |
| 34 | + | /// What is read. Defaults to the value, matching `Choice::plain`. | |
| 35 | + | #[serde(default)] | |
| 36 | + | label: Option<String>, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// One field, as the frontend sends it. | |
| 40 | + | /// | |
| 41 | + | /// A mirror of [`Field`] plus everything the description deliberately does not | |
| 42 | + | /// carry: the current value, the placeholder, the select's options. Those are | |
| 43 | + | /// renderer-side state and arrive in [`Filling`]. | |
| 44 | + | #[derive(Debug, Deserialize)] | |
| 45 | + | #[serde(rename_all = "camelCase")] | |
| 46 | + | pub struct FieldSpec { | |
| 47 | + | /// `text`, `password`, `number`, `email`, `url`, `tel`, `textarea`, | |
| 48 | + | /// `select`, `checkbox` or `hidden`. Unknown kinds render as text. | |
| 49 | + | kind: String, | |
| 50 | + | /// The name the value submits under. | |
| 51 | + | name: String, | |
| 52 | + | /// What the user is asked for. | |
| 53 | + | #[serde(default)] | |
| 54 | + | label: String, | |
| 55 | + | /// Standing help. | |
| 56 | + | #[serde(default)] | |
| 57 | + | hint: Option<String>, | |
| 58 | + | /// What is currently wrong. | |
| 59 | + | #[serde(default)] | |
| 60 | + | error: Option<String>, | |
| 61 | + | /// Whether the form refuses to submit without it. | |
| 62 | + | #[serde(default)] | |
| 63 | + | required: bool, | |
| 64 | + | /// Whether it lives behind a "more options" disclosure. | |
| 65 | + | #[serde(default)] | |
| 66 | + | extended: bool, | |
| 67 | + | /// The current value, for everything that takes typed text. | |
| 68 | + | #[serde(default)] | |
| 69 | + | value: Option<String>, | |
| 70 | + | /// The current state, for a checkbox. | |
| 71 | + | #[serde(default)] | |
| 72 | + | checked: Option<bool>, | |
| 73 | + | /// The options, for a select. | |
| 74 | + | #[serde(default)] | |
| 75 | + | options: Option<Vec<ChoiceSpec>>, | |
| 76 | + | /// Ghost text shown while empty. | |
| 77 | + | #[serde(default)] | |
| 78 | + | placeholder: Option<String>, | |
| 79 | + | /// Markup appended after the hint. NOT escaped. | |
| 80 | + | /// | |
| 81 | + | /// The caller is stating the contents are trusted, which is what | |
| 82 | + | /// [`Markup`] exists to make them say out loud. Two live callers need it, | |
| 83 | + | /// both passing a recurrence block built elsewhere. | |
| 84 | + | #[serde(default)] | |
| 85 | + | trailing_html: Option<String>, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// Map the frontend's kind string onto the description. | |
| 89 | + | /// | |
| 90 | + | /// Unknown kinds fall back to text rather than failing the render. A form that | |
| 91 | + | /// draws with one field slightly wrong is recoverable; a form that does not | |
| 92 | + | /// draw is not, and the warning says which happened. | |
| 93 | + | fn kind_of(spec_kind: &str) -> FieldKind { | |
| 94 | + | match spec_kind { | |
| 95 | + | "password" => FieldKind::Secret, | |
| 96 | + | "number" => FieldKind::Number, | |
| 97 | + | "email" => FieldKind::Email, | |
| 98 | + | "url" => FieldKind::Url, | |
| 99 | + | "tel" => FieldKind::Tel, | |
| 100 | + | "textarea" => FieldKind::Textarea, | |
| 101 | + | "select" => FieldKind::Select, | |
| 102 | + | "checkbox" => FieldKind::Checkbox, | |
| 103 | + | "hidden" => FieldKind::Hidden, | |
| 104 | + | "text" => FieldKind::Text, | |
| 105 | + | other => { | |
| 106 | + | warn!(kind = other, "unknown field kind, rendering as text"); | |
| 107 | + | FieldKind::Text | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Render a form's fields to markup, keyed by field name. | |
| 113 | + | /// | |
| 114 | + | /// `id_prefix` scopes the `id` attributes to one instance of the form, which | |
| 115 | + | /// matters because the new-entity and edit-entity modals are the same field set | |
| 116 | + | /// and their ids would otherwise collide. It never reaches `name`. | |
| 117 | + | /// | |
| 118 | + | /// Infallible by construction: every field renders to something, and an | |
| 119 | + | /// unrecognised kind renders as text with a warning rather than taking the form | |
| 120 | + | /// down with it. | |
| 121 | + | #[tauri::command] | |
| 122 | + | #[instrument(skip(fields), fields(count = fields.len()))] | |
| 123 | + | pub async fn render_form_fields( | |
| 124 | + | fields: Vec<FieldSpec>, | |
| 125 | + | id_prefix: Option<String>, | |
| 126 | + | ) -> HashMap<String, String> { | |
| 127 | + | let opts = Emit::default(); | |
| 128 | + | let mut out = HashMap::with_capacity(fields.len()); | |
| 129 | + | ||
| 130 | + | for spec in &fields { | |
| 131 | + | let kind = kind_of(&spec.kind); | |
| 132 | + | ||
| 133 | + | let field = Field { | |
| 134 | + | kind, | |
| 135 | + | name: &spec.name, | |
| 136 | + | label: &spec.label, | |
| 137 | + | hint: spec.hint.as_deref(), | |
| 138 | + | error: spec.error.as_deref(), | |
| 139 | + | required: spec.required, | |
| 140 | + | extended: spec.extended, | |
| 141 | + | }; | |
| 142 | + | ||
| 143 | + | // Built here rather than inline so the borrow outlives the Value that | |
| 144 | + | // points into it. | |
| 145 | + | let choices: Vec<Choice<'_>> = spec | |
| 146 | + | .options | |
| 147 | + | .as_deref() | |
| 148 | + | .unwrap_or_default() | |
| 149 | + | .iter() | |
| 150 | + | .map(|opt| Choice { | |
| 151 | + | value: &opt.value, | |
| 152 | + | label: opt.label.as_deref().unwrap_or(&opt.value), | |
| 153 | + | }) | |
| 154 | + | .collect(); | |
| 155 | + | ||
| 156 | + | let value = match kind { | |
| 157 | + | FieldKind::Checkbox => Value::On(spec.checked.unwrap_or_default()), | |
| 158 | + | FieldKind::Select => Value::Chosen { | |
| 159 | + | options: &choices, | |
| 160 | + | value: spec.value.as_deref().unwrap_or_default(), | |
| 161 | + | }, | |
| 162 | + | _ => match spec.value.as_deref() { | |
| 163 | + | Some(text) => Value::Text(text), | |
| 164 | + | None => Value::Absent, | |
| 165 | + | }, | |
| 166 | + | }; | |
| 167 | + | ||
| 168 | + | let filling = Filling { | |
| 169 | + | value, | |
| 170 | + | placeholder: spec.placeholder.as_deref(), | |
| 171 | + | trailing: spec.trailing_html.as_deref().map(Markup), | |
| 172 | + | id_prefix: id_prefix.as_deref(), | |
| 173 | + | }; | |
| 174 | + | ||
| 175 | + | out.insert(spec.name.clone(), field_html(&field, &filling, &opts)); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | out | |
| 179 | + | } |