function toast(message, error = false) { const host = $('#toastStack'); if (!host) return; const item = document.createElement('div'); item.className = `toast-item ${error ? 'error' : 'success'}`; item.innerHTML = `${error ? '!' : '✓'}
${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}${esc(message)}
`; host.appendChild(item); requestAnimationFrame(() => item.classList.add('show')); const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); }; item.querySelector('.toast-close').addEventListener('click', remove); item._timer = setTimeout(remove, error ? 5200 : 3600); } const cleanFormSnapshots = new WeakMap(); function formSnapshot(form) { if (!form) return ''; const rows = []; $$('input, select, textarea', form).forEach((field, index) => { if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return; const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`; const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? ''); rows.push([key, value, String(field.disabled)]); }); return JSON.stringify(rows); } function markFormClean(form) { if (!form) return; cleanFormSnapshots.set(form, formSnapshot(form)); form.classList.remove('has-unsaved-changes'); } function updateDirtyIndicator(form) { if (!form || !cleanFormSnapshots.has(form)) return; form.classList.toggle('has-unsaved-changes', isFormDirty(form)); } function isFormDirty(form) { if (!form || !cleanFormSnapshots.has(form)) return false; return cleanFormSnapshots.get(form) !== formSnapshot(form); } function clearFormErrors(form) { if (!form) return; $$('.field-error, .form-error-summary', form).forEach(node => node.remove()); $$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid')); $$('.has-error', form).forEach(node => node.classList.remove('has-error')); } function showFieldError(field, message) { if (!field || !message) return; field.setAttribute('aria-invalid', 'true'); const label = field.closest('label'); if (label) label.classList.add('has-error'); const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null; if (existing) { existing.textContent = message; return; } const note = document.createElement('small'); note.className = 'field-error'; note.textContent = message; field.insertAdjacentElement('afterend', note); } function showFormError(form, message) { if (!form || !message) return; const summary = document.createElement('div'); summary.className = 'form-error-summary'; summary.setAttribute('role', 'alert'); summary.textContent = message; const anchor = $('.settings-save-bar', form) || $('.form-actions', form); if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor); else form.appendChild(summary); } function validationMessageFor(field) { if (field.validity?.valueMissing) return tr('validation.required'); if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid'); if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) { return tr('validation.range', { min: field.min || '—', max: field.max || '—' }); } if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid'); return field.validity?.valid === false ? tr('validation.invalid') : ''; } function httpUrlValid(value) { if (!String(value || '').trim()) return true; try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; } } function entityIdValid(value) { return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim()); } function validateDecimalField(form, name, min, max) { const field = form?.elements?.[name]; if (!field || field.disabled) return true; const value = parseDecimal(field.value); if (!Number.isFinite(value) || value < min || value > max) { showFieldError(field, tr('validation.range', { min, max })); return false; } return true; } function validateForm(form) { if (!form) return true; clearFormErrors(form); let valid = true; $$('input, select, textarea', form).forEach(field => { if (field.disabled || field.type === 'hidden' || field.type === 'file') return; const message = validationMessageFor(field); if (message) { showFieldError(field, message); valid = false; } }); if (form.id === 'homeAssistantForm') { const url = form.elements.ha_url; if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } ['ha_entity_id', 'ha_outdoor_entity_id'].forEach(name => { const field = form.elements[name]; if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; } }); } if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) { const url = form.elements.influx_url; if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; } else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; } } if (form.id === 'temporaryThermostatForm') { if (!validateDecimalField(form, 'target_temperature', 8, 30)) valid = false; } if (form.id === 'zoneForm') { const source = form.elements.sensor_source?.value; const entity = form.elements.ha_entity_id; if (source !== 'device' && (!entity?.value?.trim() || !entityIdValid(entity.value))) { showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required')); valid = false; } const zoneDecimalFields = [ ['cool_comfort_setpoint', 8, 30], ['cool_sleep_setpoint', 8, 30], ['cool_away_setpoint', 8, 30], ['heat_comfort_setpoint', 8, 30], ['heat_sleep_setpoint', 8, 30], ['heat_away_setpoint', 8, 30], ['max_sensor_difference', 0.1, 20], ['standby_offset_c', 0.5, 8], ...(form.elements.separate_hysteresis?.checked ? [['cool_hysteresis', 0.1, 5], ['heat_hysteresis', 0.1, 5]] : [['hysteresis', 0.1, 5]]) ]; zoneDecimalFields.forEach(([name, min, max]) => { if (!validateDecimalField(form, name, min, max)) valid = false; }); } if (!valid) { showFormError(form, tr('validation.fixFields')); const first = $('[aria-invalid="true"]', form); const pane = first?.closest?.('[data-settings-pane]'); if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); first?.focus({ preventScroll: true }); first?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } return valid; } function apiErrorField(form, message) { const text = String(message || '').toLowerCase(); const candidates = []; if (text.includes('influx')) candidates.push('influx_url'); if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url'); if (text.includes('entity')) candidates.push('ha_entity_id', 'ha_outdoor_entity_id'); if (text.includes('device')) candidates.push('device_id', 'action_device_id', 'trigger_device_id'); if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature', 'cool_comfort_setpoint', 'action_target_temperature'); if (text.includes('name')) candidates.push('name', 'controller_id'); if (text.includes('token')) candidates.push('ha_token', 'influx_token', 'pushover_app_token'); if (text.includes('url')) candidates.push('ha_url', 'influx_url', 'slack_webhook_url', 'discord_webhook_url'); return candidates.map(name => form?.elements?.[name]).find(Boolean) || null; } function presentFormError(form, error) { clearFormErrors(form); const message = error?.message || tr('validation.invalid'); const field = apiErrorField(form, message); if (field) showFieldError(field, message); showFormError(form, message); if (field) { const pane = field.closest?.('[data-settings-pane]'); if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane); field.focus({ preventScroll: true }); field.scrollIntoView({ behavior: 'smooth', block: 'center' }); } toast(message, true); } function setFormBusy(form, busy, busyKey = 'actions.saving') { if (!form) return; const buttons = $$('button[type="submit"]', form); if (busy) { form.dataset.submitting = 'true'; form.setAttribute('aria-busy', 'true'); form.classList.add('is-saving'); buttons.forEach(button => { if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML; button.disabled = true; button.innerHTML = `${esc(tr(busyKey))}`; }); } else { delete form.dataset.submitting; form.removeAttribute('aria-busy'); form.classList.remove('is-saving'); buttons.forEach(button => { button.disabled = false; if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; } }); } } async function runFormTask(form, task, { busyKey = 'actions.saving', validate = true } = {}) { if (!form || form.dataset.submitting === 'true') return { ok: false, duplicate: true }; if (validate && !validateForm(form)) return { ok: false, validation: true }; clearFormErrors(form); setFormBusy(form, true, busyKey); try { const value = await task(); markFormClean(form); return { ok: true, value }; } catch (error) { presentFormError(form, error); return { ok: false, error }; } finally { setFormBusy(form, false); } } function restoreTrackedForm(form) { if (!form) return; if (form.id === 'settingsForm') renderSettings(); else if (form.id === 'nightModeForm') renderNightSettings(); else if (form.id === 'homeAssistantForm') { app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); renderHomeAssistantSettings(); } else markFormClean(form); } function confirmDiscardForm(form) { if (!isFormDirty(form)) return true; if (!confirm(tr('confirm.discardChanges'))) return false; restoreTrackedForm(form); return true; } function activeDirtySettingsForm() { const active = $('.view.active'); if (!active) return null; const form = $('form.settings-form', active); return form && isFormDirty(form) ? form : null; } function canLeaveCurrentView(nextName) { if (nextName === app.currentView) return true; const form = activeDirtySettingsForm(); return !form || confirmDiscardForm(form); } function requestDialogClose(dialog) { if (!dialog) return true; const form = $('form', dialog); if (form && !confirmDiscardForm(form)) return false; dialog.close(); return true; } function setupFormUx() { $$('form').forEach(form => { form.noValidate = true; if (!cleanFormSnapshots.has(form)) markFormClean(form); }); } document.addEventListener('input', event => { const field = event.target.closest?.('input, select, textarea'); const form = field?.form; if (!field || !form) return; if (field.getAttribute('aria-invalid') === 'true') { field.removeAttribute('aria-invalid'); field.closest('label')?.classList.remove('has-error'); if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove(); $('.form-error-summary', form)?.remove(); } updateDirtyIndicator(form); }); document.addEventListener('change', event => updateDirtyIndicator(event.target?.form)); window.addEventListener('beforeunload', event => { const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form)); if (!dirty) return; event.preventDefault(); event.returnValue = ''; }); document.addEventListener('cancel', event => { const dialog = event.target.closest?.('dialog'); if (!dialog) return; const form = $('form', dialog); if (!form || !isFormDirty(form)) return; event.preventDefault(); if (confirmDiscardForm(form)) dialog.close(); }, true); function showTokenDialog() { const dialog = $('#tokenDialog'); if (!dialog.open) dialog.showModal(); }