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:
29
CLAUDE.md
29
CLAUDE.md
@@ -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.
|
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
|
## General Guidelines
|
||||||
|
|
||||||
- Always test changes before marking as complete
|
- Always test changes before marking as complete
|
||||||
|
|||||||
@@ -17,7 +17,22 @@ import { showToast, showConfirm } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { loadPictureSources } from './streams.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 ─────────────────────────────────────────────────────
|
// ── Modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -60,10 +75,11 @@ export async function showAudioSourceModal(sourceType, editData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
audioSourceModal.open();
|
audioSourceModal.open();
|
||||||
|
audioSourceModal.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeAudioSourceModal() {
|
export async function closeAudioSourceModal() {
|
||||||
audioSourceModal.forceClose();
|
await audioSourceModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onAudioSourceTypeChange() {
|
export function onAudioSourceTypeChange() {
|
||||||
|
|||||||
@@ -12,7 +12,22 @@ import { showToast } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { _computeMaxFps, _renderFpsHint } from './devices.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() {
|
export function onDeviceTypeChanged() {
|
||||||
const deviceType = document.getElementById('device-type').value;
|
const deviceType = document.getElementById('device-type').value;
|
||||||
@@ -175,11 +190,14 @@ export function showAddDevice() {
|
|||||||
if (scanBtn) scanBtn.disabled = false;
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
addDeviceModal.open();
|
addDeviceModal.open();
|
||||||
onDeviceTypeChanged();
|
onDeviceTypeChanged();
|
||||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
setTimeout(() => {
|
||||||
|
document.getElementById('device-name').focus();
|
||||||
|
addDeviceModal.snapshot();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeAddDeviceModal() {
|
export async function closeAddDeviceModal() {
|
||||||
addDeviceModal.forceClose();
|
await addDeviceModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scanForDevices(forceType) {
|
export async function scanForDevices(forceType) {
|
||||||
@@ -304,7 +322,7 @@ export async function handleAddDevice(event) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log('Device added successfully:', result);
|
console.log('Device added successfully:', result);
|
||||||
showToast('Device added successfully', 'success');
|
showToast('Device added successfully', 'success');
|
||||||
closeAddDeviceModal();
|
addDeviceModal.forceClose();
|
||||||
// Use window.* to avoid circular imports
|
// Use window.* to avoid circular imports
|
||||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||||
// Auto-start device tutorial on first device add
|
// Auto-start device tutorial on first device add
|
||||||
|
|||||||
@@ -9,7 +9,21 @@ import { showToast, showConfirm } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { CardSection } from '../core/card-sections.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()" });
|
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()" });
|
||||||
|
|
||||||
// Re-render profiles when language changes (only if tab is active)
|
// 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 => {
|
modal.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
el.textContent = t(el.getAttribute('data-i18n'));
|
el.textContent = t(el.getAttribute('data-i18n'));
|
||||||
});
|
});
|
||||||
|
profileModal.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeProfileEditorModal() {
|
export async function closeProfileEditorModal() {
|
||||||
profileModal.forceClose();
|
await profileModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProfileTargetChecklist(selectedIds) {
|
async function loadProfileTargetChecklist(selectedIds) {
|
||||||
|
|||||||
@@ -45,11 +45,72 @@ document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSour
|
|||||||
|
|
||||||
// ===== Modal instances =====
|
// ===== 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 testTemplateModal = new Modal('test-template-modal');
|
||||||
const streamModal = new Modal('stream-modal');
|
const streamModal = new StreamEditorModal();
|
||||||
const testStreamModal = new Modal('test-stream-modal');
|
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');
|
const testPPTemplateModal = new Modal('test-pp-template-modal');
|
||||||
|
|
||||||
// ===== Capture Templates =====
|
// ===== Capture Templates =====
|
||||||
@@ -94,6 +155,7 @@ export async function showAddTemplateModal(cloneData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templateModal.open();
|
templateModal.open();
|
||||||
|
templateModal.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editTemplate(templateId) {
|
export async function editTemplate(templateId) {
|
||||||
@@ -120,16 +182,15 @@ export async function editTemplate(templateId) {
|
|||||||
document.getElementById('template-error').style.display = 'none';
|
document.getElementById('template-error').style.display = 'none';
|
||||||
|
|
||||||
templateModal.open();
|
templateModal.open();
|
||||||
|
templateModal.snapshot();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading template:', error);
|
console.error('Error loading template:', error);
|
||||||
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeTemplateModal() {
|
export async function closeTemplateModal() {
|
||||||
templateModal.forceClose();
|
await templateModal.close();
|
||||||
setCurrentEditingTemplateId(null);
|
|
||||||
set_templateNameManuallyEdited(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCaptureDuration(value) {
|
function updateCaptureDuration(value) {
|
||||||
@@ -419,7 +480,7 @@ export async function saveTemplate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
||||||
closeTemplateModal();
|
templateModal.forceClose();
|
||||||
await loadCaptureTemplates();
|
await loadCaptureTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving template:', error);
|
console.error('Error saving template:', error);
|
||||||
@@ -793,6 +854,7 @@ export async function showAddStreamModal(presetType, cloneData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
streamModal.open();
|
streamModal.open();
|
||||||
|
streamModal.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editStream(streamId) {
|
export async function editStream(streamId) {
|
||||||
@@ -837,6 +899,7 @@ export async function editStream(streamId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
streamModal.open();
|
streamModal.open();
|
||||||
|
streamModal.snapshot();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading stream:', error);
|
console.error('Error loading stream:', error);
|
||||||
showToast(t('streams.error.load') + ': ' + error.message, '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');
|
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
||||||
closeStreamModal();
|
streamModal.forceClose();
|
||||||
await loadPictureSources();
|
await loadPictureSources();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving stream:', error);
|
console.error('Error saving stream:', error);
|
||||||
@@ -1023,10 +1086,8 @@ export async function deleteStream(streamId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeStreamModal() {
|
export async function closeStreamModal() {
|
||||||
streamModal.forceClose();
|
await streamModal.close();
|
||||||
document.getElementById('stream-type').disabled = false;
|
|
||||||
set_streamNameManuallyEdited(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateStaticImage() {
|
async function validateStaticImage() {
|
||||||
@@ -1448,6 +1509,7 @@ export async function showAddPPTemplateModal(cloneData = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ppTemplateModal.open();
|
ppTemplateModal.open();
|
||||||
|
ppTemplateModal.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editPPTemplate(templateId) {
|
export async function editPPTemplate(templateId) {
|
||||||
@@ -1473,6 +1535,7 @@ export async function editPPTemplate(templateId) {
|
|||||||
renderModalFilterList();
|
renderModalFilterList();
|
||||||
|
|
||||||
ppTemplateModal.open();
|
ppTemplateModal.open();
|
||||||
|
ppTemplateModal.snapshot();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading PP template:', error);
|
console.error('Error loading PP template:', error);
|
||||||
showToast(t('postprocessing.error.load') + ': ' + error.message, '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');
|
showToast(templateId ? t('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||||||
closePPTemplateModal();
|
ppTemplateModal.forceClose();
|
||||||
await loadPPTemplates();
|
await loadPPTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving PP template:', error);
|
console.error('Error saving PP template:', error);
|
||||||
@@ -1571,10 +1634,8 @@ export async function deletePPTemplate(templateId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closePPTemplateModal() {
|
export async function closePPTemplateModal() {
|
||||||
ppTemplateModal.forceClose();
|
await ppTemplateModal.close();
|
||||||
set_modalFilters([]);
|
|
||||||
set_ppTemplateNameManuallyEdited(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exported helpers used by other modules
|
// Exported helpers used by other modules
|
||||||
|
|||||||
@@ -17,7 +17,36 @@ import { showToast, showConfirm } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { loadPictureSources } from './streams.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 ─────────────────────────────────────────────────────
|
// ── Modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -87,10 +116,11 @@ export async function showValueSourceModal(editData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
valueSourceModal.open();
|
valueSourceModal.open();
|
||||||
|
valueSourceModal.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeValueSourceModal() {
|
export async function closeValueSourceModal() {
|
||||||
valueSourceModal.forceClose();
|
await valueSourceModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onValueSourceTypeChange() {
|
export function onValueSourceTypeChange() {
|
||||||
|
|||||||
Reference in New Issue
Block a user