Files
gree-controller/web/js/forms.js
T
2026-09-14 23:10:17 +02:00

312 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<span class="toast-icon">${error ? '!' : '✓'}</span><div class="toast-copy"><strong>${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}</strong><span>${esc(message)}</span></div><button class="toast-close" type="button" aria-label="${esc(tr('actions.close'))}">×</button><i class="toast-progress"></i>`;
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 = 'inline-alert error form-error-summary';
summary.setAttribute('role', 'alert');
summary.innerHTML = `<span>${esc(tr('devices.lastError'))}</span><strong>${esc(message)}</strong>`;
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 = `<span class="button-spinner" aria-hidden="true"></span><span>${esc(tr(busyKey))}</span>`;
});
} 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();
}