feat(modal): closeIfPristine save-guard + per-editor adoption

Modal gains closeIfPristine(entityId): when editing an existing entity
and no tracked field has changed, the helper force-closes the modal
silently and returns true so the caller can skip the PUT and the
misleading "updated" toast. Each editor's save handler now early-returns
on the no-op edit path: advanced-calibration, assets,
audio-processing-templates, audio-sources, calibration, devices,
game-integration, ha-light-targets, home-assistant-sources,
mqtt-sources, pattern-templates, scene-presets, sync-clocks, targets,
weather-sources.
This commit is contained in:
2026-05-23 00:48:00 +03:00
parent 9ff83bd6ca
commit f03cb303c3
16 changed files with 48 additions and 0 deletions
@@ -156,6 +156,24 @@ export class Modal {
return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]); return Object.keys(this._initialValues).some(k => this._initialValues[k] !== cur[k]);
} }
/**
* No-op save guard for edit modals. When `entityId` is truthy (we are
* editing an existing entity), snapshot tracking is configured, and no
* tracked field has changed, force-close the modal silently and return
* `true` so the caller can early-return — skipping the network request
* and the misleading "updated" toast.
*
* Returns `false` when the save flow must continue (create flow, no
* snapshot taken, or at least one tracked field changed).
*/
closeIfPristine(entityId: unknown): boolean {
if (!entityId) return false;
if (Object.keys(this._initialValues).length === 0) return false;
if (this.isDirty()) return false;
this.forceClose();
return true;
}
showError(msg: string) { showError(msg: string) {
if (this.errorEl) { if (this.errorEl) {
this.errorEl.textContent = msg; this.errorEl.textContent = msg;
@@ -199,6 +199,8 @@ export async function saveAdvancedCalibration(): Promise<void> {
const cssId = _state.cssId; const cssId = _state.cssId;
if (!cssId) return; if (!cssId) return;
if (_modal.closeIfPristine(cssId)) return;
if (_state.lines.length === 0) { if (_state.lines.length === 0) {
showToast(t('calibration.advanced.no_lines_warning') || 'Add at least one line', 'error'); showToast(t('calibration.advanced.no_lines_warning') || 'Add at least one line', 'error');
return; return;
@@ -380,6 +380,8 @@ export async function showAssetEditor(editId: string): Promise<void> {
export async function saveAssetMetadata(): Promise<void> { export async function saveAssetMetadata(): Promise<void> {
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value; const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
if (assetEditorModal.closeIfPristine(id)) return;
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim(); const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('asset-editor-error')!; const errorEl = document.getElementById('asset-editor-error')!;
@@ -194,6 +194,8 @@ export async function editAudioProcessingTemplate(templateId: string) {
export async function saveAudioProcessingTemplate() { export async function saveAudioProcessingTemplate() {
const templateId = (document.getElementById('apt-id') as HTMLInputElement).value; const templateId = (document.getElementById('apt-id') as HTMLInputElement).value;
if (aptModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('apt-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('apt-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('apt-description') as HTMLInputElement).value.trim(); const description = (document.getElementById('apt-description') as HTMLInputElement).value.trim();
@@ -153,6 +153,8 @@ export function onAudioSourceTypeChange() {
export async function saveAudioSource() { export async function saveAudioSource() {
const id = (document.getElementById('audio-source-id') as HTMLInputElement).value; const id = (document.getElementById('audio-source-id') as HTMLInputElement).value;
if (audioSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('audio-source-name') as HTMLInputElement).value.trim();
const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value; const sourceType = (document.getElementById('audio-source-type') as HTMLSelectElement).value;
const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null; const description = (document.getElementById('audio-source-description') as HTMLInputElement).value.trim() || null;
@@ -914,6 +914,8 @@ export async function saveCalibration() {
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value;
const error = document.getElementById('calibration-error') as HTMLElement; const error = document.getElementById('calibration-error') as HTMLElement;
if (calibModal.closeIfPristine(cssMode ? cssId : deviceId)) return;
if (cssMode) { if (cssMode) {
await _clearCSSTestMode(); await _clearCSSTestMode();
} else { } else {
@@ -843,6 +843,8 @@ export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() { export async function saveDeviceSettings() {
const deviceId = (document.getElementById('settings-device-id') as HTMLInputElement).value; const deviceId = (document.getElementById('settings-device-id') as HTMLInputElement).value;
if (settingsModal.closeIfPristine(deviceId)) return;
const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('settings-device-name') as HTMLInputElement).value.trim();
const url = settingsModal._getUrl(); const url = settingsModal._getUrl();
@@ -707,6 +707,8 @@ export async function showGameIntegrationEditor(editId: string | null = null) {
export async function saveGameIntegration() { export async function saveGameIntegration() {
const id = (document.getElementById('gi-id') as HTMLInputElement).value; const id = (document.getElementById('gi-id') as HTMLInputElement).value;
if (giModal.closeIfPristine(id)) return;
const name = (document.getElementById('gi-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('gi-name') as HTMLInputElement).value.trim();
if (!name) { giModal.showError(t('game_integration.error.name_required')); return; } if (!name) { giModal.showError(t('game_integration.error.name_required')); return; }
@@ -487,6 +487,8 @@ export async function closeHALightEditor(): Promise<void> {
export async function saveHALightEditor(): Promise<void> { export async function saveHALightEditor(): Promise<void> {
const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value; const targetId = (document.getElementById('ha-light-editor-id') as HTMLInputElement).value;
if (haLightEditorModal.closeIfPristine(targetId)) return;
const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('ha-light-editor-name') as HTMLInputElement).value.trim();
const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value; const haSourceId = (document.getElementById('ha-light-editor-ha-source') as HTMLSelectElement).value;
const colorSourceRaw = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value; const colorSourceRaw = (document.getElementById('ha-light-editor-css-source') as HTMLSelectElement).value;
@@ -142,6 +142,8 @@ export async function closeHASourceModal(): Promise<void> {
export async function saveHASource(): Promise<void> { export async function saveHASource(): Promise<void> {
const id = (document.getElementById('ha-source-id') as HTMLInputElement).value; const id = (document.getElementById('ha-source-id') as HTMLInputElement).value;
if (haSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('ha-source-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('ha-source-name') as HTMLInputElement).value.trim();
const host = (document.getElementById('ha-source-host') as HTMLInputElement).value.trim(); const host = (document.getElementById('ha-source-host') as HTMLInputElement).value.trim();
const token = (document.getElementById('ha-source-token') as HTMLInputElement).value.trim(); const token = (document.getElementById('ha-source-token') as HTMLInputElement).value.trim();
@@ -115,6 +115,8 @@ export async function closeMQTTSourceModal(): Promise<void> {
export async function saveMQTTSource(): Promise<void> { export async function saveMQTTSource(): Promise<void> {
const id = (document.getElementById('mqtt-source-id') as HTMLInputElement).value; const id = (document.getElementById('mqtt-source-id') as HTMLInputElement).value;
if (mqttSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('mqtt-source-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('mqtt-source-name') as HTMLInputElement).value.trim();
const broker_host = (document.getElementById('mqtt-source-host') as HTMLInputElement).value.trim(); const broker_host = (document.getElementById('mqtt-source-host') as HTMLInputElement).value.trim();
const broker_port = parseInt((document.getElementById('mqtt-source-port') as HTMLInputElement).value, 10) || 1883; const broker_port = parseInt((document.getElementById('mqtt-source-port') as HTMLInputElement).value, 10) || 1883;
@@ -240,6 +240,8 @@ export async function savePatternTemplate(): Promise<void> {
} }
const templateId = (document.getElementById('pattern-template-id') as HTMLInputElement).value; const templateId = (document.getElementById('pattern-template-id') as HTMLInputElement).value;
if (patternModal.closeIfPristine(templateId)) return;
const name = (document.getElementById('pattern-template-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('pattern-template-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('pattern-template-description') as HTMLInputElement).value.trim(); const description = (document.getElementById('pattern-template-description') as HTMLInputElement).value.trim();
@@ -369,6 +369,8 @@ export async function editScenePreset(presetId: string): Promise<void> {
// ===== Save (create or update) ===== // ===== Save (create or update) =====
export async function saveScenePreset(): Promise<void> { export async function saveScenePreset(): Promise<void> {
if (scenePresetModal.closeIfPristine(_editingId)) return;
const name = (document.getElementById('scene-preset-editor-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('scene-preset-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('scene-preset-editor-description') as HTMLInputElement).value.trim(); const description = (document.getElementById('scene-preset-editor-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('scene-preset-editor-error')!; const errorEl = document.getElementById('scene-preset-editor-error')!;
@@ -110,6 +110,8 @@ export async function closeSyncClockModal(): Promise<void> {
export async function saveSyncClock(): Promise<void> { export async function saveSyncClock(): Promise<void> {
const id = (document.getElementById('sync-clock-id') as HTMLInputElement).value; const id = (document.getElementById('sync-clock-id') as HTMLInputElement).value;
if (syncClockModal.closeIfPristine(id)) return;
const name = (document.getElementById('sync-clock-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('sync-clock-name') as HTMLInputElement).value.trim();
const speed = parseFloat((document.getElementById('sync-clock-speed') as HTMLInputElement).value); const speed = parseFloat((document.getElementById('sync-clock-speed') as HTMLInputElement).value);
const description = (document.getElementById('sync-clock-description') as HTMLInputElement).value.trim() || null; const description = (document.getElementById('sync-clock-description') as HTMLInputElement).value.trim() || null;
@@ -496,6 +496,8 @@ export function forceCloseTargetEditorModal() {
export async function saveTargetEditor() { export async function saveTargetEditor() {
const targetId = (document.getElementById('target-editor-id') as HTMLInputElement).value; const targetId = (document.getElementById('target-editor-id') as HTMLInputElement).value;
if (targetEditorModal.closeIfPristine(targetId)) return;
const name = (document.getElementById('target-editor-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('target-editor-name') as HTMLInputElement).value.trim();
const deviceId = (document.getElementById('target-editor-device') as HTMLSelectElement).value; const deviceId = (document.getElementById('target-editor-device') as HTMLSelectElement).value;
const standbyInterval = parseFloat((document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value); const standbyInterval = parseFloat((document.getElementById('target-editor-keepalive-interval') as HTMLInputElement).value);
@@ -146,6 +146,8 @@ export async function closeWeatherSourceModal(): Promise<void> {
export async function saveWeatherSource(): Promise<void> { export async function saveWeatherSource(): Promise<void> {
const id = (document.getElementById('weather-source-id') as HTMLInputElement).value; const id = (document.getElementById('weather-source-id') as HTMLInputElement).value;
if (weatherSourceModal.closeIfPristine(id)) return;
const name = (document.getElementById('weather-source-name') as HTMLInputElement).value.trim(); const name = (document.getElementById('weather-source-name') as HTMLInputElement).value.trim();
const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value; const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value;
const latitude = parseFloat((document.getElementById('weather-source-latitude') as HTMLInputElement).value) || 50.0; const latitude = parseFloat((document.getElementById('weather-source-latitude') as HTMLInputElement).value) || 50.0;