Skip to main content

max / goingson

Delete renderFormField Every call site now renders through makeover-webview. The last four moved here: the sync interval select, the backup settings in both the export modal and the settings pane, and the IMAP/SMTP account form. The sync interval select's inline onchange became a listener bound by id after render, which is what the modal form builder already did for every handler it wires. A data-change was not an option: the delegated dispatcher reads el.value off whichever element carries the attribute, and the emitter puts attributes on the control rather than on markup the app supplies. The account form's ids change shape. They used to be spelled per field (`acct-imap-server` for a field named `imap_server`, `acct-name` for `account_name`), and they are derived as prefix-plus-name now, so the six autodetect lookups follow the field names. Uniform derivation is the point: an id that has to be remembered separately from the name is an id that can disagree with it. The signature textarea loses its three-row request. rows arrived through an arbitrary-attribute escape hatch, and it is presentation, so it goes to the stylesheet's default height rather than into the description. Two dead locals went with it: syncOptionsHtml built option markup nothing read, and passwordRequired was computed and never used. Both predate this change and were only visible once the field calls around them left.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 13:10 UTC
Signed with PGP, not checked
Commit: c7660fb47c72701c53f77cd21218637d7329b349
Parent: fe98686
5 files changed, +94 insertions, -183 deletions
@@ -173,98 +173,6 @@
173 173 return GoingsOn.api.forms.renderFields(specs, idPrefix);
174 174 }
175 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.
182 - * @param {Object} field - Field definition
183 - * @param {string} field.kind - 'text' | 'email' | 'number' | 'password' | 'date' | 'time' | 'datetime-local' | 'hidden' | 'select' | 'textarea' | 'checkbox'
184 - * @param {string} field.name - Form input name
185 - * @param {string} [field.label] - Field label
186 - * @param {string} [field.id] - Input id (defaults to field.name)
187 - * @param {*} [field.value] - Current value
188 - * @param {string} [field.placeholder]
189 - * @param {boolean} [field.required]
190 - * @param {Array<{value, label, selected?}>} [field.options] - For select. A value
191 - * matching no option is kept as a generated leading option rather than being
192 - * dropped to the browser's first-option fallback.
193 - * @param {string} [field.hint] - Help text under input (HTML-escaped)
194 - * @param {string} [field.hintExtraHtml] - Raw HTML appended after hint (NOT escaped; caller must sanitize)
195 - * @param {string} [field.error] - Error text (renders has-error variant)
196 - * @param {boolean} [field.preview] - Whether to render a preview slot under the input
197 - * @returns {string} - HTML string for the field group
198 - */
199 - function renderFormField(field) {
200 - const utils = GoingsOn.utils;
201 - const esc = utils.escapeHtml;
202 - const escAttr = utils.escapeAttrValue;
203 - const kind = field.kind || field.type || 'text';
204 - const inputId = field.id || field.name;
205 - const value = field.value ?? '';
206 - const required = field.required ? 'required' : '';
207 - const placeholder = field.placeholder ? `placeholder="${escAttr(field.placeholder)}"` : '';
208 - const extraAttrs = field.attrs
209 - ? Object.entries(field.attrs).map(([k, v]) => `${k}="${escAttr(String(v))}"`).join(' ')
210 - : '';
211 -
212 - if (kind === 'hidden') {
213 - return `<input type="hidden" name="${field.name}" value="${escAttr(value)}">`;
214 - }
215 -
216 - let inputHtml = '';
217 - let isCheckbox = false;
218 -
219 - switch (kind) {
220 - case 'textarea':
221 - inputHtml = `<textarea class="field" id="${inputId}" name="${field.name}" ${required} ${placeholder} ${extraAttrs}>${esc(value)}</textarea>`;
222 - break;
223 - case 'select': {
224 - const options = field.options || [];
225 - // A select fed a value no option carries renders with nothing
226 - // selected, so the browser falls back to the first option and the
227 - // next save writes a value nobody chose (the backup retention
228 - // default of 10 against a 1/3/7/14/0 list did exactly that).
229 - // Carry the stray value as its own option instead: it stays
230 - // selected, it round-trips through a save untouched, and it is
231 - // visible rather than silent. Compared as strings because callers
232 - // routinely pass a number for value and strings for opt.value.
233 - const matched = options.some(opt => opt.selected || String(opt.value) === String(value));
234 - let strayHtml = '';
235 - if (value !== '' && !matched) {
236 - console.warn(`renderFormField: select "${field.name}" has no option for value ${JSON.stringify(value)}; keeping the value as-is.`);
237 - strayHtml = `<option value="${escAttr(String(value))}" selected data-unmatched="true">${esc(String(value))}</option>`;
238 - }
239 - const optionsHtml = options.map(opt => {
240 - const selected = (opt.selected || opt.value === value) ? 'selected' : '';
241 - return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`;
242 - }).join('');
243 - inputHtml = `<select class="field" id="${inputId}" name="${field.name}" ${required} ${extraAttrs}>${strayHtml}${optionsHtml}</select>`;
244 - break;
245 - }
246 - case 'checkbox':
247 - isCheckbox = true;
248 - inputHtml = `<label class="form-checkbox-label"><input type="checkbox" id="${inputId}" name="${field.name}" ${value ? 'checked' : ''} ${extraAttrs}><span>${esc(field.label || '')}</span></label>`;
249 - break;
250 - default:
251 - inputHtml = `<input type="${kind}" class="field" id="${inputId}" name="${field.name}" ${required} ${placeholder} value="${escAttr(value)}" ${extraAttrs}>`;
252 - }
253 -
254 - const hintText = field.hint ? `<div class="form-hint">${esc(field.hint)}</div>` : '';
255 - const hintExtra = field.hintExtraHtml || '';
256 - const hintHtml = hintText + hintExtra;
257 - const previewHtml = field.preview ? `<div id="${inputId}-preview" class="form-hint form-hint--preview"></div>` : '';
258 - const errorHtml = field.error ? `<div class="form-error visible">${esc(field.error)}</div>` : '';
259 - const errorClass = field.error ? ' has-error' : '';
260 -
261 - if (isCheckbox) {
262 - return `<div class="form-group${errorClass}">${inputHtml}${hintHtml}${errorHtml}</div>`;
263 - }
264 - const labelHtml = field.label ? `<label class="form-label" for="${inputId}">${esc(field.label)}</label>` : '';
265 - return `<div class="form-group${errorClass}">${labelHtml}${inputHtml}${hintHtml}${previewHtml}${errorHtml}</div>`;
266 - }
267 -
268 176 // Context Menu
269 177
270 178 let contextMenuElement = null;
@@ -677,7 +585,6 @@
677 585 // View helpers
678 586 renderEmptyState,
679 587 emptyStateIcon,
680 - renderFormField,
681 588 renderFields,
682 589
683 590 // API wrapper
@@ -35,36 +35,21 @@
35 35 * @param {string} opts.submitLabel - Submit button label
36 36 * @returns {string} HTML string for the form
37 37 */
38 - function buildAccountFormHtml(opts) {
38 + async function buildAccountFormHtml(opts) {
39 39 const { formId, idPrefix, submitAct, submitId, values, isEdit, submitLabel } = opts;
40 40
41 41 const passwordLabel = isEdit ? 'Password (leave empty to keep current)' : 'Password';
42 - const passwordRequired = isEdit ? '' : 'required';
43 42 const passwordPlaceholder = isEdit ? 'Enter new password or leave empty' : 'your password';
44 43
45 44 const archiveHint = isEdit
46 45 ? 'Use Test Connection to see available folders on this account.'
47 46 : 'Gmail: [Gmail]/All Mail, Fastmail: Archive. Use Test Connection to see available folders.';
48 47
49 - const syncOptionsHtml = SYNC_INTERVAL_OPTIONS.map(opt => {
50 - let selected = '';
51 - if (isEdit) {
52 - // For edit: match against current value (null maps to '')
53 - const current = values.syncIntervalMinutes != null ? String(values.syncIntervalMinutes) : '';
54 - selected = (opt.value === current) ? 'selected' : '';
55 - } else {
56 - // For add: default to 15 minutes
57 - selected = (opt.value === '15') ? 'selected' : '';
58 - }
59 - return `<option value="${escAttr(opt.value)}" ${selected}>${esc(opt.label)}</option>`;
60 - }).join('');
61 -
62 48 // For new accounts, check if server fields have been pre-filled (edit mode always shows them)
63 49 const hasServerValues = isEdit || !!(values.imapServer || values.smtpServer);
64 50 const advancedExpanded = hasServerValues ? 'expanded' : '';
65 51 const advancedHidden = hasServerValues ? '' : 'hidden';
66 52
67 - const ff = GoingsOn.ui.renderFormField;
68 53 const syncIntervalOpts = SYNC_INTERVAL_OPTIONS.map(opt => {
69 54 let selected;
70 55 if (isEdit) {
@@ -76,28 +61,47 @@
76 61 return { value: opt.value, label: opt.label, selected };
77 62 });
78 63
64 + // Ids are derived as `${idPrefix}-${name}` now rather than being spelled
65 + // per field, so the lookups in autodetect below follow the field names.
66 + // That is why they read `-imap_server` and not `-imap-server`.
67 + const f = await GoingsOn.ui.renderFields([
68 + { kind: 'text', name: 'account_name', label: 'Account Name', value: values.accountName || '', placeholder: 'Personal, Work, etc.', required: true },
69 + { kind: 'text', name: 'username', label: 'Username', value: values.username || '', placeholder: 'Usually your email address', required: true },
70 + { kind: 'password', name: 'password', label: passwordLabel, placeholder: passwordPlaceholder, required: !isEdit },
71 + { kind: 'text', name: 'archive_folder_name', label: 'Archive Folder Name', value: values.archiveFolderName || 'Archive', placeholder: 'Archive', hint: archiveHint },
72 + // The signature textarea used to ask for three rows through an extra
73 + // attribute. It takes the default height now; the emitter carries no
74 + // arbitrary attributes and rows is presentation.
75 + { kind: 'textarea', name: 'email_signature', label: 'Email Signature', value: values.emailSignature || '', placeholder: '-- \nYour Name', hint: 'Appended to outbound emails. Plain text only.' },
76 + { kind: 'text', name: 'imap_server', label: 'IMAP Server', value: values.imapServer || '', placeholder: 'imap.example.com', required: true },
77 + { kind: 'number', name: 'imap_port', label: 'IMAP Port', value: String(values.imapPort || 993), required: true },
78 + { kind: 'text', name: 'smtp_server', label: 'SMTP Server', value: values.smtpServer || '', placeholder: 'smtp.example.com', required: true },
79 + { kind: 'number', name: 'smtp_port', label: 'SMTP Port', value: String(values.smtpPort || 587), required: true },
80 + { kind: 'select', name: 'sync_interval_minutes', label: 'Auto-sync Interval', options: syncIntervalOpts, hint: 'Automatically check for new emails at this interval.' },
81 + ], idPrefix);
82 +
79 83 return `
80 84 <form id="${formId}" data-submit="${escAttr(submitAct)}" data-a1="@event"${submitId ? ` data-a2="${escAttr(submitId)}"` : ''}>
81 - ${ff({ kind: 'text', name: 'account_name', id: `${idPrefix}-name`, label: 'Account Name', value: values.accountName || '', placeholder: 'Personal, Work, etc.', required: true })}
85 + ${f['account_name']}
82 86 <div class="form-group">
83 87 <label class="form-label" for="${idPrefix}-email">Email Address</label>
84 88 <input type="email" class="field" id="${idPrefix}-email" name="email_address" required placeholder="you@example.com" value="${escAttr(values.emailAddress || '')}">
85 89 <div id="${idPrefix}-detect-status" class="email-detect-status"></div>
86 90 <div id="${idPrefix}-detect-note" class="email-detect-note hidden"></div>
87 91 </div>
88 - ${ff({ kind: 'text', name: 'username', id: `${idPrefix}-username`, label: 'Username', value: values.username || '', placeholder: 'Usually your email address', required: true })}
89 - ${ff({ kind: 'password', name: 'password', id: `${idPrefix}-password`, label: passwordLabel, placeholder: passwordPlaceholder, required: !isEdit })}
90 - ${ff({ kind: 'text', name: 'archive_folder_name', id: `${idPrefix}-archive-folder`, label: 'Archive Folder Name', value: values.archiveFolderName || 'Archive', placeholder: 'Archive', hint: archiveHint })}
91 - ${ff({ kind: 'textarea', name: 'email_signature', id: `${idPrefix}-signature`, label: 'Email Signature', value: values.emailSignature || '', placeholder: '-- \nYour Name', hint: 'Appended to outbound emails. Plain text only.', attrs: { rows: 3 } })}
92 + ${f['username']}
93 + ${f['password']}
94 + ${f['archive_folder_name']}
95 + ${f['email_signature']}
92 96 <button type="button" class="form-more-toggle ${advancedExpanded}" data-act="ui.toggleExpand" data-a1="@el">Advanced settings</button>
93 97 <div class="${advancedHidden}">
94 98 <div class="form-grid-2">
95 - ${ff({ kind: 'text', name: 'imap_server', id: `${idPrefix}-imap-server`, label: 'IMAP Server', value: values.imapServer || '', placeholder: 'imap.example.com', required: true })}
96 - ${ff({ kind: 'number', name: 'imap_port', id: `${idPrefix}-imap-port`, label: 'IMAP Port', value: values.imapPort || 993, required: true })}
99 + ${f['imap_server']}
100 + ${f['imap_port']}
97 101 </div>
98 102 <div class="form-grid-2">
99 - ${ff({ kind: 'text', name: 'smtp_server', id: `${idPrefix}-smtp-server`, label: 'SMTP Server', value: values.smtpServer || '', placeholder: 'smtp.example.com', required: true })}
100 - ${ff({ kind: 'number', name: 'smtp_port', id: `${idPrefix}-smtp-port`, label: 'SMTP Port', value: values.smtpPort || 587, required: true })}
103 + ${f['smtp_server']}
104 + ${f['smtp_port']}
101 105 </div>
102 106 <div class="form-group">
103 107 <label class="form-checkbox-label">
@@ -106,7 +110,7 @@
106 110 </label>
107 111 <div class="form-hint">Show a system notification when new emails arrive during auto-sync. Off by default.</div>
108 112 </div>
109 - ${ff({ kind: 'select', name: 'sync_interval_minutes', id: `${idPrefix}-sync-interval`, label: 'Auto-sync Interval', options: syncIntervalOpts, hint: 'Automatically check for new emails at this interval.' })}
113 + ${f['sync_interval_minutes']}
110 114 </div>
111 115 <div class="form-actions">
112 116 <button type="button" class="button button--secondary" data-act="emails.refreshAccountsView">Cancel</button>
@@ -182,12 +186,12 @@
182 186
183 187 lastDetectedDomain = domain;
184 188
185 - const imapEl = document.getElementById(`${idPrefix}-imap-server`);
186 - const imapPortEl = document.getElementById(`${idPrefix}-imap-port`);
187 - const smtpEl = document.getElementById(`${idPrefix}-smtp-server`);
188 - const smtpPortEl = document.getElementById(`${idPrefix}-smtp-port`);
189 + const imapEl = document.getElementById(`${idPrefix}-imap_server`);
190 + const imapPortEl = document.getElementById(`${idPrefix}-imap_port`);
191 + const smtpEl = document.getElementById(`${idPrefix}-smtp_server`);
192 + const smtpPortEl = document.getElementById(`${idPrefix}-smtp_port`);
189 193 const usernameEl = document.getElementById(`${idPrefix}-username`);
190 - const archiveEl = document.getElementById(`${idPrefix}-archive-folder`);
194 + const archiveEl = document.getElementById(`${idPrefix}-archive_folder_name`);
191 195
192 196 // Auto-fill server fields
193 197 if (imapEl && (!imapEl.value || imapEl.value === 'imap.example.com')) imapEl.value = settings.imap;
@@ -375,7 +379,7 @@
375 379 `
376 380 : '';
377 381
378 - const formHtml = buildAccountFormHtml({
382 + const formHtml = await buildAccountFormHtml({
379 383 formId: 'email-account-form',
380 384 idPrefix: 'acct',
381 385 submitAct: 'emails.createAccount',
@@ -429,7 +433,7 @@
429 433 return;
430 434 }
431 435
432 - const content = buildAccountFormHtml({
436 + const content = await buildAccountFormHtml({
433 437 formId: 'edit-email-account-form',
434 438 idPrefix: 'edit-acct',
435 439 submitAct: 'emails.updateAccount', submitId: id,
@@ -245,7 +245,24 @@
245 245 { value: 0, label: 'Keep all backups' },
246 246 ];
247 247
248 - const ff = GoingsOn.ui.renderFormField;
248 + const f = await GoingsOn.ui.renderFields([
249 + {
250 + kind: 'select',
251 + name: 'backup-frequency',
252 + label: 'Backup Frequency',
253 + value: String(settings.backupFrequencyMinutes),
254 + options: frequencyOptions.map(o => ({ value: String(o.value), label: o.label })),
255 + },
256 + {
257 + kind: 'select',
258 + name: 'backup-retention',
259 + label: 'Retention Policy',
260 + value: String(settings.maxBackupsToKeep),
261 + options: retentionOptions.map(o => ({ value: String(o.value), label: o.label })),
262 + hint: 'Older backups are automatically deleted to save space.',
263 + },
264 + ]);
265 +
249 266 const content = `
250 267 <p class="export-desc">
251 268 Automatic backups protect your data by creating compressed snapshots on a schedule.
@@ -259,24 +276,9 @@
259 276 </label>
260 277 </div>
261 278
262 - ${ff({
263 - kind: 'select',
264 - name: 'backup-frequency',
265 - id: 'backup-frequency',
266 - label: 'Backup Frequency',
267 - value: settings.backupFrequencyMinutes,
268 - options: frequencyOptions.map(o => ({ value: String(o.value), label: o.label, selected: settings.backupFrequencyMinutes === o.value })),
269 - })}
279 + ${f['backup-frequency']}
270 280
271 - ${ff({
272 - kind: 'select',
273 - name: 'backup-retention',
274 - id: 'backup-retention',
275 - label: 'Retention Policy',
276 - value: settings.maxBackupsToKeep,
277 - options: retentionOptions.map(o => ({ value: String(o.value), label: o.label, selected: settings.maxBackupsToKeep === o.value })),
278 - hint: 'Older backups are automatically deleted to save space.',
279 - })}
281 + ${f['backup-retention']}
280 282
281 283 <div class="export-note">
282 284 <p class="export-note-text">${esc(lastBackupText)}</p>
@@ -91,26 +91,24 @@
91 91 `;
92 92 } catch (_) {}
93 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({
94 + // The change handler used to be an inline onchange passed through
95 + // as an extra attribute. It is bound by id after render instead,
96 + // below, which is what the modal form builder already does for
97 + // every handler it wires. A data-change would not work here: the
98 + // delegated dispatcher reads el.value off whichever element carries
99 + // the attribute, and the emitter puts attributes on the control it
100 + // is describing, not on markup the app supplies.
101 + const interval = await GoingsOn.ui.renderFields([{
102 102 kind: 'select',
103 103 name: 'sync-interval',
104 - id: 'sync-interval',
105 104 label: 'Sync Interval',
106 - value: status.syncIntervalMinutes,
107 - attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' },
105 + value: String(status.syncIntervalMinutes),
108 106 options: intervalOptions.map(m => ({
109 107 value: String(m),
110 108 label: m === 1 ? '1 minute' : m + ' minutes',
111 - selected: status.syncIntervalMinutes === m,
112 109 })),
113 - });
110 + }]);
111 + const intervalField = interval['sync-interval'];
114 112
115 113 sectionContent = `
116 114 <div class="sync-status-row">
@@ -161,6 +159,13 @@
161 159 </div>
162 160 `;
163 161
162 + // Bind the sync-interval handler now the select is in the document.
163 + // Replaces the inline onchange it used to carry as an extra attribute.
164 + const intervalEl = document.getElementById('sync-interval');
165 + if (intervalEl) {
166 + intervalEl.addEventListener('change', () => updateSyncSettings());
167 + }
168 +
164 169 // After render: check subscription status and show banner if needed
165 170 if (status.encryptionReady) {
166 171 checkSubscriptionBanner();
@@ -225,7 +225,23 @@
225 225 { value: 0, label: 'Keep all backups' },
226 226 ];
227 227
228 - const renderFormField = GoingsOn.ui.renderFormField;
228 + const f = await GoingsOn.ui.renderFields([
229 + {
230 + kind: 'select',
231 + name: 'backup-frequency',
232 + label: 'Backup Frequency',
233 + value: String(settings.backupFrequencyMinutes),
234 + options: frequencyOptions.map(o => ({ value: String(o.value), label: o.label })),
235 + },
236 + {
237 + kind: 'select',
238 + name: 'backup-retention',
239 + label: 'Retention Policy',
240 + value: String(settings.maxBackupsToKeep),
241 + options: retentionOptions.map(o => ({ value: String(o.value), label: o.label })),
242 + hint: 'Older backups are automatically deleted to save space.',
243 + },
244 + ]);
229 245 // Frequency and retention sit behind a disclosure: the defaults are
230 246 // right for almost everyone, so the section reads as one on/off
231 247 // choice until someone asks for more. Open it when the settings are
@@ -254,31 +270,8 @@
254 270 </div>
255 271 <details class="settings-disclosure"${customized ? ' open' : ''}>
256 272 <summary class="settings-disclosure-toggle">Customize</summary>
257 - ${renderFormField({
258 - kind: 'select',
259 - name: 'backup-frequency',
260 - id: 'backup-frequency',
261 - label: 'Backup Frequency',
262 - value: settings.backupFrequencyMinutes,
263 - options: frequencyOptions.map(o => ({
264 - value: String(o.value),
265 - label: o.label,
266 - selected: settings.backupFrequencyMinutes === o.value,
267 - })),
268 - })}
269 - ${renderFormField({
270 - kind: 'select',
271 - name: 'backup-retention',
272 - id: 'backup-retention',
273 - label: 'Retention Policy',
274 - value: settings.maxBackupsToKeep,
275 - options: retentionOptions.map(o => ({
276 - value: String(o.value),
277 - label: o.label,
278 - selected: settings.maxBackupsToKeep === o.value,
279 - })),
280 - hint: 'Older backups are automatically deleted to save space.',
281 - })}
273 + ${f['backup-frequency']}
274 + ${f['backup-retention']}
282 275 </details>
283 276 <div class="settings-actions-row settings-actions-row--center">
284 277 <button class="button button--primary" data-act="export.saveBackupSettings">Save Backup Settings</button>