Repository: ZoneMinder/zoneminder
Pull request #4259: Feat: Added the ability to save monitor data without closing the page when editing a monitor
Author: login: IgorA100
Created: 2025-03-15T15:06:25Z | Merged: 2025-03-29T20:32:51Z

And also added the appearance of a fashionable window with a request to save data when switching to zone management pages.

<img width="504" alt="11" src="https://github.com/user-attachments/assets/7d1afcdb-47aa-462a-a1d2-c7e09b66b0d3" />
<img width="507" alt="12" src="https://github.com/user-attachments/assets/0076f265-db5b-4a51-9101-2a75282a0133" />
<img width="514" alt="14" src="https://github.com/user-attachments/assets/f1d075aa-11c0-462e-a31a-dcc790c73bae" />



Diff:
diff --git a/web/ajax/modals/saveconfirm.php b/web/ajax/modals/saveconfirm.php
new file mode 100644
index 00000000000..d4ec09bebd6
--- /dev/null
+++ b/web/ajax/modals/saveconfirm.php
@@ -0,0 +1,26 @@
+<?php
+// This is the HTML representing the Save data confirmation modal on the Monitor page and other pages
+
+$saveTextKey = isset($_REQUEST['key']) ? $_REQUEST['key'] : 'ConfirmAction';
+
+?>  
+<div id="saveConfirm" class="modal fade" class="modal" tabindex="-1">
+  <div class="modal-dialog">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h5 class="modal-title"><?php echo translate('ConfirmAction') ?></h5>
+        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+          <span aria-hidden="true">&times;</span>
+        </button>
+      </div>
+      <div class="modal-body">
+        <p><?php echo translate($saveTextKey) ?></p>
+      </div>
+      <div class="modal-footer">
+        <button id="saveCancelBtn" type="button" class="btn btn-secondary" data-dismiss="modal"><?php echo translate('Cancel') ?></button>
+        <button id ="dontSaveBtn" type="button" class="btn btn-danger"><?php echo translate('DontSave') ?></button>
+        <button id ="saveConfirmBtn" type="button" class="btn btn-success"><?php echo translate('Save') ?></button>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php
index c701abaaf5f..49809493bf9 100644
--- a/web/lang/en_gb.php
+++ b/web/lang/en_gb.php
@@ -217,6 +217,7 @@
     'ConfigType'            => 'Config Type',
     'ConfiguredFor'         => 'Configured for',
     'ConfigURL'             => 'Config Base URL',
+    'ConfirmAction'         => 'Action Confirmation',
     'ConfirmDeleteControl'  => 'Warning, deleting a control will reset all monitors that use it to be uncontrollable.<br><br>Are you sure you wish to delete?',
     'ConfirmDeleteDevices'  => 'Are you sure you wish to delete the selected devices?',
     'ConfirmDeleteEvents'   => 'Are you sure you wish to delete the selected events?',
@@ -259,6 +260,7 @@
     'DonateRemindWeek'      => 'Not yet, remind again in 1 week',
     'DonateYes'             => 'Yes, I\'d like to donate now',
     'DoNativeMotionDetection'=> 'Do Native Motion Detection',
+    'DontSave'              => 'Do not save',
     'DuplicateMonitorName'  => 'Duplicate Monitor Name',
     'DuplicateRTSPStreamName' =>  'Duplicate RTSP Stream Name',
     'EditControl'           => 'Edit Control',
@@ -373,6 +375,7 @@
     'MaxZoomSpeed'          => 'Max Zoom Speed',
     'MaxZoomStep'           => 'Max Zoom Step',
     'MediumBW'              => 'Medium&nbsp;B/W',
+    'MessageSavingDataWhenLeavingPage' => 'You are leaving the page.<br>Want to save data?',
     'MetaConfig'            => 'Meta Config',
     'MinAlarmAreaLtMax'     => 'Minimum alarm area should be less than maximum',
     'MinAlarmAreaUnset'     => 'You must specify the minimum alarm pixel count',
@@ -409,6 +412,7 @@
     'MinZoomSpeed'          => 'Min Zoom Speed',
     'MinZoomStep'           => 'Min Zoom Step',
     'ModectDuringPTZ'       => 'Do motion detection during PTZ motion',
+    'MonitorDataIsSaved'    => 'Monitor data is saved',
     'MonitorIds'            => 'Monitor&nbsp;Ids',
     'MonitorPresetIntro'    => 'Select an appropriate preset from the list below.<br/><br/>Please note that this may overwrite any values you already have configured for the current monitor.<br/><br/>',
     'MonitorPreset'         => 'Monitor Preset',
@@ -551,6 +555,7 @@
     'RunStats'              => 'Run Stats Process',
     'RunTrigger'            => 'Run Trigger Process',
     'RunEventNotification'  => 'Run Event Notification Process',
+    'SaveAndClose'          => 'Save and close',
     'SaveAs'                => 'Save as',
     'SaveFilter'            => 'Save Filter',
     'SaveJPEGs'             => 'Save JPEGs',
diff --git a/web/lang/ru_ru.php b/web/lang/ru_ru.php
index 56ed7a13fdc..31ce9d71d03 100644
--- a/web/lang/ru_ru.php
+++ b/web/lang/ru_ru.php
@@ -245,6 +245,9 @@
     'ConcurrentFilter'     => 'Запуск фильтра', // Added - 2018-08-30
     'Config'               => 'Конфигурация',
     'ConfiguredFor'        => 'настроен на',
+    'ConfirmAction'        => 'Подтвердите действие',
+    'ConfirmDeleteControl' => 'Внимание! Удаление элемента управления приведет к тому, что все мониторы, которые его используют, станут неуправляемыми.<br><br>Вы уверены, что хотите удалить?',
+    'ConfirmDeleteDevices' => 'Вы действительно хотите удалить выбранные устройства?',
     'ConfirmDeleteEvents'  => 'Вы действительно хотите удалить выбранные события?',
     'ConfirmDeleteLayout'  => 'Вы действительно хотите удалить текущий шаблон?',
     'ConfirmPassword'      => 'Подтвердите пароль',
@@ -292,6 +295,7 @@
     'Display'              => 'Дисплей',                // Added - 2011-01-30
     'Displaying'           => 'Отображено',             // Added - 2011-06-16
     'DoNativeMotionDetection'=> 'Использовать встроенное обнаружение движения', // Edited - 2019-03-25
+    'DontSave'             => 'Не сохранять',
     'Donate'               => 'Поддержите проект', 
     'DonateAlready'        => 'Нет, я уже сделал пожертвование', 
     'DonateEnticement'     => 'Вы какое-то время используете ZoneMinder и, надеемся, находите его полезным дополнением к вашей домашней или рабочей безопасности. Хотя ZoneMinder есть и будет оставаться свободным и бесплатным, он требует денег на разработку и поддержку. Если Вы хотите поддержать его будущее развитие и новые функции, пожалуйста сделайте пожертвование. Это, конечно, необязательно, но очень высоко ценится. Вы можете пожертвовать любую сумму.<br><br>Если Вы хотите сделать пожертвование, то выберите соответствующий вариант ниже или перейдите по адресу https://www.bountysource.com/teams/zoneminder в вашем браузере.<br><br>Спасибо за использование ZoneMinder, и не забывайте посетить форум на ZoneMinder.com для поддержки и пожеланий как сделать ZoneMinder еще лучше.', 
@@ -473,6 +477,7 @@
     'Medium'               => 'средний',
     'MediumBW'             => 'Обычный канал',
     'Message'              => 'Сообщение',                // Added - 2011-06-16
+    'MessageSavingDataWhenLeavingPage' => 'Вы покидаете страницу.<br>Хотите сохранить данные?',
     'MinAlarmAreaLtMax'    => 'Минимум зоны тревоги должен быть меньше максимума',
     'MinAlarmAreaUnset'    => 'Укажите минимальное число пикселей зоны тревоги',
     'MinBlobAreaLtMax'     => 'Минимальная площадь объекта должна быть меньше чем максимальная площадь объекта',
@@ -509,6 +514,7 @@
     'Misc'                 => 'Разное',
     'Mode'                 => 'Режим',                   // Added - 2015-04-18
     'Monitor'              => 'Монитор',
+    'MonitorDataIsSaved'   => 'Данные монитора сохраняются',
     'MonitorIds'           => 'ИД&nbsp;Мониторов',
     'MonitorPreset'        => 'Предустановки монитора',//The list below shows detected analog and network cameras and whether they are already being used or available for selection.
     'MonitorPresetIntro'   => 'Выберите подходящий вариант из списка ниже.<br><br>Обратите внимание, что это может переписать настройки определенные для этого монитора.<br><br>',
@@ -654,6 +660,7 @@
     'RunState'             => 'Состояние',
     'Running'              => 'Выполняется',
     'Save'                 => 'Сохранить',
+    'SaveAndClose'         => 'Сохранить и закрыть',
     'SaveAs'               => 'Сохранить как',
     'SaveFilter'           => 'Сохранить фильтр',
     'SaveJPEGs'            => 'Сохранить JPEG-и',             // Edited - 2019-03-24
diff --git a/web/skins/classic/css/base/skin.css b/web/skins/classic/css/base/skin.css
index 563bf5afb6f..1ba56469243 100644
--- a/web/skins/classic/css/base/skin.css
+++ b/web/skins/classic/css/base/skin.css
@@ -1212,6 +1212,22 @@ button.btn.btn-view-watch:hover {
 
 /* --- Control button block in the Stream image*/
 
+.fixed-t-r {
+  position: fixed;
+  top: 50px;
+  right: 50px;
+  min-width: 250px;
+}
+
+@media screen and (max-width:767px) {
+  .fixed-t-r {
+    position: fixed;
+    top: 10px;
+    right: 10px;
+    min-width: 200px;
+  }
+}
+
 /* +++ This block should always be located at the end! */
 .hidden {
     display: none;
diff --git a/web/skins/classic/views/js/monitor.js b/web/skins/classic/views/js/monitor.js
index 3675736fe95..8c7b4d550be 100644
--- a/web/skins/classic/views/js/monitor.js
+++ b/web/skins/classic/views/js/monitor.js
@@ -275,6 +275,60 @@ function initPage() {
     window.location.assign('?view=console');
   });
 
+  // Manage the ZONES Button
+  document.getElementById("zones-tab").addEventListener("click", function onZonesClick(evt) {
+    evt.preventDefault();
+    const data = {
+      request: "modal",
+      modal: "saveconfirm",
+      key: messageSavingDataWhenLeavingPage
+    };
+
+    if (!document.getElementById('saveConfirm')) {
+      // Load the save confirmation modal into the DOM
+      $j.getJSON(thisUrl, data)
+          .done(function(data) {
+            insertModalHtml('saveConfirm', data.html);
+            manageSaveConfirmModalBtns();
+            $j('#saveConfirm').modal('show');
+          })
+          .fail(function(jqXHR) {
+            console.log('error getting saveconfirm', jqXHR);
+            logAjaxFail(jqXHR);
+          });
+      return;
+    } else {
+      document.getElementById('saveConfirmBtn').disabled = false; // re-enable the button
+      $j('#saveConfirm').modal('show');
+    }
+  });
+
+  // Manage the SAVE CONFIRMATION modal button
+  function manageSaveConfirmModalBtns() {
+    const href = '?view=zones&mid='+mid;
+    document.getElementById('saveConfirmBtn').addEventListener('click', function onSaveConfirmClick(evt) {
+      document.getElementById('saveConfirmBtn').disabled = true; // prevent double click
+      evt.preventDefault();
+      saveMonitorData(href);
+    });
+
+    // Manage the Don't SAVE modal button
+    document.getElementById('dontSaveBtn').addEventListener('click', function onSaveConfirmClick(evt) {
+      evt.preventDefault();
+      window.location.assign(href);
+    });
+
+    // Manage the CANCEL modal button
+    document.getElementById('saveCancelBtn').addEventListener('click', function onSaveCancelClick(evt) {
+      $j('#saveConfirm').modal('hide');
+    });
+  }
+
+  // Manage the SAVE Button
+  document.getElementById("saveBtn").addEventListener("click", function onZonesClick(evt) {
+    saveMonitorData();
+  });
+
   const form = document.getElementById('contentForm');
 
   //manage the Janus settings div
@@ -420,6 +474,28 @@ function initPage() {
   if (!isMobile()) initThumbAnimation();
 } // end function initPage()
 
+function saveMonitorData(href = '') {
+  const alertBlock = $j("#alertSaveMonitorData");
+  const form_data = $j("#contentForm").serializeArray();
+  alertBlock.fadeIn({duration: 'fast'});
+  form_data.push({name: "action", value: "save"});
+  $j.ajax({
+    type: "POST",
+    url: "?view=monitor",
+    data: form_data,
+    success: function() {
+      alertBlock.fadeOut({duration: 'fast'});
+      if (href) window.location.assign(href);
+      //document.getElementById('zones-tab').classList.remove("disabled");
+    },
+    error: function() {
+      alertBlock.fadeOut({duration: 'fast'});
+      alert('An error occurred while saving data.'); /* Proper formatting required! */
+    }
+  });
+  $j('#saveConfirm').modal('hide');
+}
+
 function ll2dms(input) {
   const latitude = document.getElementById('newMonitor[Latitude]');
   if (latitude.value === '') return;
diff --git a/web/skins/classic/views/js/monitor.js.php b/web/skins/classic/views/js/monitor.js.php
index c57b2d1ed4d..f9000b34046 100644
--- a/web/skins/classic/views/js/monitor.js.php
+++ b/web/skins/classic/views/js/monitor.js.php
@@ -1,5 +1,6 @@
 const hasOnvif = <?php echo ZM_HAS_ONVIF ?>;
 const defaultAspectRatio = '<?php echo ZM_DEFAULT_ASPECT_RATIO ?>';
+const messageSavingDataWhenLeavingPage = '<?php echo translate('MessageSavingDataWhenLeavingPage') ?>';
 
 <?php
 if (ZM_OPT_CONTROL and canView('Control')) {
diff --git a/web/skins/classic/views/monitor.php b/web/skins/classic/views/monitor.php
index 7617a1dcad9..e1fda304de1 100644
--- a/web/skins/classic/views/monitor.php
+++ b/web/skins/classic/views/monitor.php
@@ -45,6 +45,7 @@
 
 $mid = null;
 $monitor = null;
+$thisNewMonitor = false;
 if (!empty($_REQUEST['mid'])) {
   $mid = validInt($_REQUEST['mid']);
   $monitor = new ZM\Monitor($mid);
@@ -59,6 +60,7 @@
 }
 
 if (!$monitor) {
+  $thisNewMonitor = true;
   $monitor = new ZM\Monitor();
   $monitor->Name(translate('Monitor').'-'.getTableAutoInc('Monitors'));
   while (isset($monitors_by_name[$monitor->Name()])) {
@@ -403,10 +405,11 @@ function fourcc($a, $b, $c, $d) {
     <li class="nav-item form-control-sm my-1">
       <a 
         id="<?php echo $name?>-tab"
-        class="nav-link<?php echo $tab == $name ? ' active' : '' ?>"
+        class="nav-link<?php echo ($tab == $name ? ' active' : '') . ' ' . (($name == 'zones' && $thisNewMonitor === true) ? 'disabled' : '')?>"
         <?php 
         if ($name == 'zones') {
-          echo 'href="index.php?view=zones&mid=' . $monitor->Id() . '" ';
+          //echo 'href="index.php?view=zones&mid=' . $monitor->Id() . '" ';
+          echo 'href="#"';
         } else {
           echo 'href="#pills-' . $name . '" '; 
           echo 'role="tab" '; 
@@ -1611,7 +1614,8 @@ class="nav-link<?php echo $tab == $name ? ' active' : '' ?>"
 ?>
 </div><!--tab-content-->
         <div id="contentButtons" class="pr-3">
-          <button type="submit" name="action" value="save"<?php echo canEdit('Monitors', $mid) ? '' : ' disabled="disabled"' ?>><?php echo translate('Save') ?></button>
+          <button type="button" id="saveBtn" name="action" value="save"<?php echo canEdit('Monitors', $mid) ? ($thisNewMonitor === true ? ' disabled="disabled"' : '') : ' disabled="disabled"' ?>><?php echo translate('Save') ?></button>
+          <button type="submit" name="action" value="save"<?php echo canEdit('Monitors', $mid) ? '' : ' disabled="disabled"' ?>><?php echo translate('SaveAndClose') ?></button>
           <button type="button" id="cancelBtn"><?php echo translate('Cancel') ?></button>
         </div>
       </form>
@@ -1619,6 +1623,11 @@ class="nav-link<?php echo $tab == $name ? ' active' : '' ?>"
 </div><!-- flex column container-->
     </div><!--content-->
   </div><!--page-->
+  <div id="alertSaveMonitorData" class="fixed-t-r alert alert-info" role="alert" style="display: none;">
+    <h2 class="alert-heading"><?php echo translate('PleaseWait') ?></h2>
+    <?php echo translate('MonitorDataIsSaved') ?>
+  </div>
+
   <script src="<?php echo cache_bust('js/MonitorLinkExpression.js') ?>"></script>
 <script type="module" nonce="<?php echo $cspNonce ?>">
   import DmsCoordinates, {parseDms} from "./js/dms.js";


Reviews:
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-15T17:13:33Z

Review comments:
diff_hunk: @@ -715,11 +715,17 @@
   // Manage the BACK button
   document.getElementById("backBtn").addEventListener("click", function onBackClick(evt) {
     evt.preventDefault();
-    window.history.back();
+    if (document.referrer.length) {
+      window.history.back();
+    } else {
+      window.location.assign((new URL(window.location.href)).searchParams.get('referer'));
path: web/skins/classic/views/js/zone.js
user: login: github-advanced-security[bot]
body: ## Client-side cross-site scripting

Cross-site scripting vulnerability due to [user-provided value](1).

[Show more details](https://github.com/ZoneMinder/zoneminder/security/code-scanning/231)
created_at: 2025-03-15T17:13:32Z
author_association: NONE
diff_hunk: @@ -715,11 +715,17 @@
   // Manage the BACK button
   document.getElementById("backBtn").addEventListener("click", function onBackClick(evt) {
     evt.preventDefault();
-    window.history.back();
+    if (document.referrer.length) {
+      window.history.back();
+    } else {
+      window.location.assign((new URL(window.location.href)).searchParams.get('referer'));
path: web/skins/classic/views/js/zone.js
user: login: github-advanced-security[bot]
body: ## Client-side URL redirect

Untrusted URL redirection depends on a [user-provided value](1).

[Show more details](https://github.com/ZoneMinder/zoneminder/security/code-scanning/232)
created_at: 2025-03-15T17:13:33Z
author_association: NONE

PR comments:
user: login: IgorA100
created_at: 2025-03-15T16:10:59Z
body: @connortechnology 
Closed https://zoneminder-chat.slack.com/archives/CC2T6P1KQ/p1741275291089969
author_association: CONTRIBUTOR
user: login: IgorA100
created_at: 2025-03-16T09:33:44Z
body: As far as I understand, when switching from Monitor page to Zone page using Java, the referer attribute is not lost. Although it should be lost and the "<-" button should not work, but it does.
Please check this point.
author_association: CONTRIBUTOR
user: login: connortechnology
created_at: 2025-03-29T20:32:47Z
body: Works well. One thought is that when going to Zones, if nothing was changed, we don't need the warning. 

On another project I had input elements track their initial value so it was easy to check for changes..  Could also set a changed flag on the form element.  Makes it easy to check.

Eventually perhaps zones shouldn't actually leave the monitor edit. Something to think about long term.  
author_association: MEMBER
user: login: IgorA100
created_at: 2025-03-29T21:15:05Z
body: > if nothing was changed, we don't need the warning.

I thought about it..... But for correct work it is necessary to track the change of each form element and compare when leaving the form. I put this idea aside for now...
Oh, here is an idea to serialize the form after opening the form and then compare with the serialization before closing.
The idea should work...
https://stackoverflow.com/questions/194101/what-is-the-best-way-to-track-changes-in-a-form-via-javascript
I'll check when I have time...

> Eventually perhaps zones shouldn't actually leave the monitor edit

I thought about this a few months ago :)
But I couldn't implement it quickly and I temporarily put this idea aside.
author_association: CONTRIBUTOR
user: login: IgorA100
created_at: 2025-03-29T21:22:04Z
body: Maybe it is necessary to implement this for all forms?
I think the idea with seralization is very interesting...
author_association: CONTRIBUTOR
user: login: IgorA100
created_at: 2025-03-30T19:16:07Z
body: PR #4277
author_association: CONTRIBUTOR