Skip to main content

max / goingson

Render the modal form builder's fields in Rust openFormModal covers about fifteen callers, so this is most of the adoption in one place. It becomes async, which is transparent: no caller used its return value, every one is a bare statement call. Two translations worth naming, both places where the frontend had been saying something two ways: Selects. The old renderer marked an option selected on `opt.selected || opt.value === value`, and a dozen call sites compute `selected` from a comparison without also passing `value`. The description matches by value alone, which is the better contract, so the command reconciles the two rather than the crate: no value means take the value of whichever option marked itself. Dropping the flag would have silently unselected those. Checkboxes. The old renderer read truthiness off `value`; the description has an explicit on-or-off, so the spec sends `checked` and leaves `value` alone for everything else. The live-preview slot rides in as trailing markup rather than becoming a renderer feature. It is an empty div this module fills on input, its id has to agree with the lookup that fills it, and it is static markup authored here, so there is nothing for the emitter to decide. Note the ids are prefixed with formId and the names are not, which is what Filling::id_prefix is for. form.elements[field.name] collects the submission by name, so prefixing those would have broken every save.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 13:06 UTC
Signed with PGP, not checked
Commit: fe986869d2caa6ba36457c03a7bee3234edc7d1b
Parent: 5bd3e97
2 files changed, +60 insertions, -20 deletions
@@ -43,7 +43,7 @@
43 43 * @param {Function} [config.onCancel] - Custom cancel handler
44 44 * @param {string} [config.extraContent] - Extra HTML content to add before form actions
45 45 */
46 - function openFormModal(config) {
46 + async function openFormModal(config) {
47 47 const {
48 48 title,
49 49 entityType,
@@ -70,32 +70,41 @@
70 70 return v !== '';
71 71 });
72 72
73 + // Every field crosses once, before the template below is built, so the
74 + // template itself stays synchronous. `formId` is the id prefix, which is
75 + // what keeps the new-entity and edit-entity forms from sharing ids.
76 + //
77 + // The live-preview slot rides along as trailing markup rather than as a
78 + // renderer feature: it is an empty div this module fills on input, its id
79 + // has to agree with the lookup below, and it is static markup authored
80 + // here, so there is nothing for the emitter to decide about it.
81 + const rendered = await GoingsOn.ui.renderFields(fields.map(field => {
82 + const value = presetData[field.name] ?? field.value ?? '';
83 + return {
84 + kind: field.type,
85 + name: field.name,
86 + label: field.label,
87 + value: field.type === 'checkbox' ? undefined : String(value),
88 + checked: field.type === 'checkbox' ? !!value : undefined,
89 + placeholder: field.placeholder,
90 + required: field.required,
91 + options: field.options,
92 + hint: field.hint,
93 + trailingHtml: field.onInput
94 + ? `<div id="${formId}-${field.name}-preview" class="form-hint form-hint--preview"></div>`
95 + : undefined,
96 + };
97 + }), formId);
98 +
73 99 let inExtended = false;
74 100 const fieldsHtml = fields.map(field => {
75 - const value = presetData[field.name] ?? field.value ?? '';
76 - const inputId = `${formId}-${field.name}`;
77 -
78 101 let prefix = '';
79 102 if (hasExtended && field.extended && !inExtended) {
80 103 inExtended = true;
81 104 const expanded = extendedHasValues ? 'expanded' : '';
82 105 prefix = `<button type="button" class="form-more-toggle ${expanded}" data-act="ui.toggleExpand" data-a1="@el" data-text-expanded="Less options" data-text-collapsed="More options">${extendedHasValues ? 'Less options' : 'More options'}</button><div class="form-extended-fields ${extendedHasValues ? '' : 'hidden'}">`;
83 106 }
84 -
85 - const groupHtml = GoingsOn.ui.renderFormField({
86 - kind: field.type,
87 - name: field.name,
88 - id: inputId,
89 - label: field.label,
90 - value,
91 - placeholder: field.placeholder,
92 - required: field.required,
93 - options: field.options,
94 - hint: field.hint,
95 - preview: !!field.onInput,
96 - });
97 -
98 - return prefix + groupHtml;
107 + return prefix + rendered[field.name];
99 108 }).join('') + (inExtended ? '</div>' : '');
100 109
101 110 const content = `
@@ -34,6 +34,15 @@
34 34 /// What is read. Defaults to the value, matching `Choice::plain`.
35 35 #[serde(default)]
36 36 label: Option<String>,
37 + /// Whether this option is the current one.
38 + ///
39 + /// Redundant with the field's `value` and kept because the frontend has
40 + /// long said it both ways: the old JS renderer marked an option selected on
41 + /// `opt.selected || opt.value === value`, and a dozen call sites compute
42 + /// `selected` from a comparison without also passing `value`. Dropping it
43 + /// would silently unselect those. See [`FieldSpec::current_value`].
44 + #[serde(default)]
45 + selected: bool,
37 46 }
38 47
39 48 /// One field, as the frontend sends it.
@@ -85,6 +94,28 @@
85 94 trailing_html: Option<String>,
86 95 }
87 96
97 + impl FieldSpec {
98 + /// The value a select should show as current.
99 + ///
100 + /// `value` when the caller sent one, otherwise the value of whichever
101 + /// option marked itself selected. The description matches an option by
102 + /// value alone, which is the better contract, so the two ways the frontend
103 + /// says this are reconciled here rather than in the crate.
104 + fn current_value(&self) -> &str {
105 + if let Some(value) = self.value.as_deref() {
106 + if !value.is_empty() {
107 + return value;
108 + }
109 + }
110 + self.options
111 + .as_deref()
112 + .unwrap_or_default()
113 + .iter()
114 + .find(|opt| opt.selected)
115 + .map_or("", |opt| opt.value.as_str())
116 + }
117 + }
118 +
88 119 /// Map the frontend's kind string onto the description.
89 120 ///
90 121 /// Unknown kinds fall back to text rather than failing the render. A form that
@@ -157,7 +188,7 @@
157 188 FieldKind::Checkbox => Value::On(spec.checked.unwrap_or_default()),
158 189 FieldKind::Select => Value::Chosen {
159 190 options: &choices,
160 - value: spec.value.as_deref().unwrap_or_default(),
191 + value: spec.current_value(),
161 192 },
162 193 _ => match spec.value.as_deref() {
163 194 Some(text) => Value::Text(text),