Add dirty check to all remaining editor modals

Subclass Modal with snapshotValues() for: value source editor, audio
source editor, add device, profile editor, capture template, stream
editor, and PP template modals. Close/cancel now triggers discard
confirmation when form has unsaved changes. Document the convention
in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 18:12:30 +03:00
parent 053a56eed3
commit e4c4301a7b
6 changed files with 201 additions and 32 deletions

View File

@@ -120,6 +120,35 @@ Add hint text to both `en.json` and `ru.json` locale files using a `.hint` suffi
Do **not** add placeholder options like `-- Select something --`. Populate the `<select>` with real options only and let the first one be selected by default.
### Modal dirty check (discard unsaved changes)
Every editor modal **must** have a dirty check so closing with unsaved changes shows a "Discard unsaved changes?" confirmation. Use the `Modal` base class pattern from `js/core/modal.js`:
1. **Subclass Modal** with `snapshotValues()` returning an object of all tracked field values:
```javascript
class MyEditorModal extends Modal {
constructor() { super('my-modal-id'); }
snapshotValues() {
return {
name: document.getElementById('my-name').value,
// ... all form fields
};
}
onForceClose() {
// Optional: cleanup (reset flags, clear state, etc.)
}
}
const myModal = new MyEditorModal();
```
2. **Call `modal.snapshot()`** after the form is fully populated (after `modal.open()`).
3. **Close/cancel button** calls `await modal.close()` — triggers dirty check + confirmation.
4. **Save function** calls `modal.forceClose()` after successful save — skips dirty check.
5. For complex/dynamic state (filter lists, schedule rows, conditions), serialize to JSON string in `snapshotValues()`.
The base class handles: `isDirty()` comparison, confirmation dialog, backdrop click, ESC key, focus trapping, and body scroll lock.
## General Guidelines
- Always test changes before marking as complete

View File

@@ -17,7 +17,22 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { loadPictureSources } from './streams.js';
const audioSourceModal = new Modal('audio-source-modal');
class AudioSourceModal extends Modal {
constructor() { super('audio-source-modal'); }
snapshotValues() {
return {
name: document.getElementById('audio-source-name').value,
description: document.getElementById('audio-source-description').value,
type: document.getElementById('audio-source-type').value,
device: document.getElementById('audio-source-device').value,
parent: document.getElementById('audio-source-parent').value,
channel: document.getElementById('audio-source-channel').value,
};
}
}
const audioSourceModal = new AudioSourceModal();
// ── Modal ─────────────────────────────────────────────────────
@@ -60,10 +75,11 @@ export async function showAudioSourceModal(sourceType, editData) {
}
audioSourceModal.open();
audioSourceModal.snapshot();
}
export function closeAudioSourceModal() {
audioSourceModal.forceClose();
export async function closeAudioSourceModal() {
await audioSourceModal.close();
}
export function onAudioSourceTypeChange() {

View File

@@ -12,7 +12,22 @@ import { showToast } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { _computeMaxFps, _renderFpsHint } from './devices.js';
const addDeviceModal = new Modal('add-device-modal');
class AddDeviceModal extends Modal {
constructor() { super('add-device-modal'); }
snapshotValues() {
return {
name: document.getElementById('device-name').value,
type: document.getElementById('device-type').value,
url: document.getElementById('device-url').value,
serialPort: document.getElementById('device-serial-port').value,
ledCount: document.getElementById('device-led-count').value,
baudRate: document.getElementById('device-baud-rate').value,
};
}
}
const addDeviceModal = new AddDeviceModal();
export function onDeviceTypeChanged() {
const deviceType = document.getElementById('device-type').value;
@@ -175,11 +190,14 @@ export function showAddDevice() {
if (scanBtn) scanBtn.disabled = false;
addDeviceModal.open();
onDeviceTypeChanged();
setTimeout(() => document.getElementById('device-name').focus(), 100);
setTimeout(() => {
document.getElementById('device-name').focus();
addDeviceModal.snapshot();
}, 100);
}
export function closeAddDeviceModal() {
addDeviceModal.forceClose();
export async function closeAddDeviceModal() {
await addDeviceModal.close();
}
export async function scanForDevices(forceType) {
@@ -304,7 +322,7 @@ export async function handleAddDevice(event) {
const result = await response.json();
console.log('Device added successfully:', result);
showToast('Device added successfully', 'success');
closeAddDeviceModal();
addDeviceModal.forceClose();
// Use window.* to avoid circular imports
if (typeof window.loadDevices === 'function') await window.loadDevices();
// Auto-start device tutorial on first device add

View File

@@ -9,7 +9,21 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
const profileModal = new Modal('profile-editor-modal');
class ProfileEditorModal extends Modal {
constructor() { super('profile-editor-modal'); }
snapshotValues() {
return {
name: document.getElementById('profile-editor-name').value,
enabled: document.getElementById('profile-editor-enabled').checked.toString(),
logic: document.getElementById('profile-editor-logic').value,
conditions: JSON.stringify(getProfileEditorConditions()),
targets: JSON.stringify(getProfileEditorTargetIds()),
};
}
}
const profileModal = new ProfileEditorModal();
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()" });
// Re-render profiles when language changes (only if tab is active)
@@ -176,10 +190,11 @@ export async function openProfileEditor(profileId) {
modal.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
profileModal.snapshot();
}
export function closeProfileEditorModal() {
profileModal.forceClose();
export async function closeProfileEditorModal() {
await profileModal.close();
}
async function loadProfileTargetChecklist(selectedIds) {

View File

@@ -45,11 +45,72 @@ document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSour
// ===== Modal instances =====
const templateModal = new Modal('template-modal');
class CaptureTemplateModal extends Modal {
constructor() { super('template-modal'); }
snapshotValues() {
const vals = {
name: document.getElementById('template-name').value,
description: document.getElementById('template-description').value,
engine: document.getElementById('template-engine').value,
};
document.querySelectorAll('[data-config-key]').forEach(field => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
}
}
class StreamEditorModal extends Modal {
constructor() { super('stream-modal'); }
snapshotValues() {
return {
name: document.getElementById('stream-name').value,
description: document.getElementById('stream-description').value,
type: document.getElementById('stream-type').value,
displayIndex: document.getElementById('stream-display-index').value,
captureTemplate: document.getElementById('stream-capture-template').value,
targetFps: document.getElementById('stream-target-fps').value,
source: document.getElementById('stream-source').value,
ppTemplate: document.getElementById('stream-pp-template').value,
imageSource: document.getElementById('stream-image-source').value,
};
}
onForceClose() {
document.getElementById('stream-type').disabled = false;
set_streamNameManuallyEdited(false);
}
}
class PPTemplateEditorModal extends Modal {
constructor() { super('pp-template-modal'); }
snapshotValues() {
return {
name: document.getElementById('pp-template-name').value,
description: document.getElementById('pp-template-description').value,
filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
};
}
onForceClose() {
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
const streamModal = new Modal('stream-modal');
const streamModal = new StreamEditorModal();
const testStreamModal = new Modal('test-stream-modal');
const ppTemplateModal = new Modal('pp-template-modal');
const ppTemplateModal = new PPTemplateEditorModal();
const testPPTemplateModal = new Modal('test-pp-template-modal');
// ===== Capture Templates =====
@@ -94,6 +155,7 @@ export async function showAddTemplateModal(cloneData = null) {
}
templateModal.open();
templateModal.snapshot();
}
export async function editTemplate(templateId) {
@@ -120,16 +182,15 @@ export async function editTemplate(templateId) {
document.getElementById('template-error').style.display = 'none';
templateModal.open();
templateModal.snapshot();
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
export function closeTemplateModal() {
templateModal.forceClose();
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
export async function closeTemplateModal() {
await templateModal.close();
}
function updateCaptureDuration(value) {
@@ -419,7 +480,7 @@ export async function saveTemplate() {
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
closeTemplateModal();
templateModal.forceClose();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
@@ -793,6 +854,7 @@ export async function showAddStreamModal(presetType, cloneData = null) {
}
streamModal.open();
streamModal.snapshot();
}
export async function editStream(streamId) {
@@ -837,6 +899,7 @@ export async function editStream(streamId) {
}
streamModal.open();
streamModal.snapshot();
} catch (error) {
console.error('Error loading stream:', error);
showToast(t('streams.error.load') + ': ' + error.message, 'error');
@@ -996,7 +1059,7 @@ export async function saveStream() {
}
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
closeStreamModal();
streamModal.forceClose();
await loadPictureSources();
} catch (error) {
console.error('Error saving stream:', error);
@@ -1023,10 +1086,8 @@ export async function deleteStream(streamId) {
}
}
export function closeStreamModal() {
streamModal.forceClose();
document.getElementById('stream-type').disabled = false;
set_streamNameManuallyEdited(false);
export async function closeStreamModal() {
await streamModal.close();
}
async function validateStaticImage() {
@@ -1448,6 +1509,7 @@ export async function showAddPPTemplateModal(cloneData = null) {
}
ppTemplateModal.open();
ppTemplateModal.snapshot();
}
export async function editPPTemplate(templateId) {
@@ -1473,6 +1535,7 @@ export async function editPPTemplate(templateId) {
renderModalFilterList();
ppTemplateModal.open();
ppTemplateModal.snapshot();
} catch (error) {
console.error('Error loading PP template:', error);
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
@@ -1503,7 +1566,7 @@ export async function savePPTemplate() {
}
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
closePPTemplateModal();
ppTemplateModal.forceClose();
await loadPPTemplates();
} catch (error) {
console.error('Error saving PP template:', error);
@@ -1571,10 +1634,8 @@ export async function deletePPTemplate(templateId) {
}
}
export function closePPTemplateModal() {
ppTemplateModal.forceClose();
set_modalFilters([]);
set_ppTemplateNameManuallyEdited(false);
export async function closePPTemplateModal() {
await ppTemplateModal.close();
}
// Exported helpers used by other modules

View File

@@ -17,7 +17,36 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { loadPictureSources } from './streams.js';
const valueSourceModal = new Modal('value-source-modal');
class ValueSourceModal extends Modal {
constructor() { super('value-source-modal'); }
snapshotValues() {
const type = document.getElementById('value-source-type').value;
return {
name: document.getElementById('value-source-name').value,
description: document.getElementById('value-source-description').value,
type,
value: document.getElementById('value-source-value').value,
waveform: document.getElementById('value-source-waveform').value,
speed: document.getElementById('value-source-speed').value,
minValue: document.getElementById('value-source-min-value').value,
maxValue: document.getElementById('value-source-max-value').value,
audioSource: document.getElementById('value-source-audio-source').value,
mode: document.getElementById('value-source-mode').value,
sensitivity: document.getElementById('value-source-sensitivity').value,
smoothing: document.getElementById('value-source-smoothing').value,
adaptiveMin: document.getElementById('value-source-adaptive-min-value').value,
adaptiveMax: document.getElementById('value-source-adaptive-max-value').value,
pictureSource: document.getElementById('value-source-picture-source').value,
sceneBehavior: document.getElementById('value-source-scene-behavior').value,
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
schedule: JSON.stringify(_getScheduleFromUI()),
};
}
}
const valueSourceModal = new ValueSourceModal();
// ── Modal ─────────────────────────────────────────────────────
@@ -87,10 +116,11 @@ export async function showValueSourceModal(editData) {
}
valueSourceModal.open();
valueSourceModal.snapshot();
}
export function closeValueSourceModal() {
valueSourceModal.forceClose();
export async function closeValueSourceModal() {
await valueSourceModal.close();
}
export function onValueSourceTypeChange() {