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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user