323 lines
13 KiB
JavaScript
323 lines
13 KiB
JavaScript
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 ? '!' : uiIcon('check')}</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'))}">${uiIcon('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 = $('.sticky-form-actions', 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_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;
|
|
const configuredEntity = entity?.value?.trim() || '';
|
|
if (source !== 'device' && !configuredEntity) {
|
|
showFieldError(entity, tr('zones.sensorRequired'));
|
|
valid = false;
|
|
} else if (source !== 'device' && !entityIdValid(configuredEntity)) {
|
|
showFieldError(entity, tr('validation.entityId'));
|
|
valid = false;
|
|
}
|
|
const outdoorEntity = form.elements.ha_outdoor_entity_id;
|
|
const configuredOutdoorEntity = outdoorEntity?.value?.trim() || '';
|
|
if (configuredOutdoorEntity && !entityIdValid(configuredOutdoorEntity)) {
|
|
showFieldError(outdoorEntity, tr('validation.entityId'));
|
|
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.config-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;
|
|
closeChartPreview(dialog.querySelector?.('.chart-card.chart-fullscreen-fallback'));
|
|
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();
|
|
}
|
|
|