Afform - Add undo/redo buttons to the Admin UI
authorColeman Watts <coleman@civicrm.org>
Wed, 1 Jun 2022 02:17:23 +0000 (22:17 -0400)
committerColeman Watts <coleman@civicrm.org>
Wed, 1 Jun 2022 16:30:06 +0000 (12:30 -0400)
ext/afform/admin/ang/afGuiEditor.css
ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js
ext/afform/admin/ang/afGuiEditor/afGuiEditorCanvas.html
ext/afform/admin/ang/afGuiEditor/config-form.html
ext/afform/admin/ang/afGuiEditor/inputType/ChainSelect.html
ext/afform/admin/ang/afGuiEditor/inputType/Date.html
ext/afform/admin/ang/afGuiEditor/inputType/EntityRef.html
ext/afform/admin/ang/afGuiEditor/inputType/Number.html
ext/afform/admin/ang/afGuiEditor/inputType/Select.html
ext/afform/admin/ang/afGuiEditor/inputType/Text.html
js/crm.menubar.js

index efb2c82151dac60fabcf33a29a6a983473175df5..585a397845953e61974d11bcbaa1ec5c6066747e 100644 (file)
@@ -41,6 +41,7 @@
   height: 44px;
   padding: 5px 10px 10px 10px;
   border-bottom: 1px solid #ddd;
+  position: relative;
 }
 #afGuiEditor .panel-heading ul.nav-tabs {
   border-bottom: 0 none;
   font-size: 12px;
   margin: 0;
 }
+#afGuiEditor .form-inline.af-gui-canvas-control-buttons {
+  position: absolute;
+  left: 48%;
+}
 
 #afGuiEditor .panel-body {
   padding: 5px 12px;
index 52fc975d7fed4b026f033187e23304fa1f458192..18283b58a5d9f7116977eb2a8485f5bc78ee768e 100644 (file)
       $scope.searchDisplayListFilter = {};
       this.meta = afGui.meta;
       var editor = this,
+        undoHistory = [],
+        undoPosition = 0,
+        undoAction = null,
         sortableOptions = {};
 
+      // ngModelOptions to debounce input
+      // Used to prevent cluttering the undo history with every keystroke
+      this.debounceMode = {
+        updateOn: 'default blur',
+        debounce: {
+          default: 2000,
+          blur: 0
+        }
+      };
+
+      // Above mode for use with getterSetter
+      this.debounceWithGetterSetter = _.assign({getterSetter: true}, this.debounceMode);
+
       this.$onInit = function() {
         // Load the current form plus blocks & fields
         afGui.resetMeta();
         $timeout(fixEditorHeight);
         $timeout(editor.adjustTabWidths);
         $(window)
+          .off('.afGuiEditor')
           .on('resize.afGuiEditor', fixEditorHeight)
-          .on('resize.afGuiEditor', editor.adjustTabWidths);
+          .on('resize.afGuiEditor', editor.adjustTabWidths)
+          .on('keyup.afGuiEditor', editor.onKeyup);
+
+        // Warn of unsaved changes
+        window.onbeforeunload = function(e) {
+          if (!editor.isSaved()) {
+            e.returnValue = ts("Form has not been saved.");
+            return e.returnValue;
+          }
+        };
       };
 
       this.$onDestroy = function() {
         $(window).off('.afGuiEditor');
+        window.onbeforeunload = null;
       };
 
+      function setEditorLayout() {
+        editor.layout = {};
+        if (editor.getFormType() === 'form') {
+          editor.layout['#children'] = afGui.findRecursive(editor.afform.layout, {'#tag': 'af-form'})[0]['#children'];
+        }
+        else {
+          editor.layout['#children'] = editor.afform.layout;
+        }
+      }
+
       // Initialize the current form
       function initializeForm() {
         editor.afform = editor.data.definition;
         }
         $scope.canvasTab = 'layout';
         $scope.layoutHtml = '';
-        editor.layout = {'#children': []};
         $scope.entities = {};
+        setEditorLayout();
 
         if (editor.getFormType() === 'form') {
           editor.allowEntityConfig = true;
-          editor.layout['#children'] = afGui.findRecursive(editor.afform.layout, {'#tag': 'af-form'})[0]['#children'];
           $scope.entities = _.mapValues(afGui.findRecursive(editor.layout['#children'], {'#tag': 'af-entity'}, 'name'), backfillEntityDefaults);
 
           if (editor.mode === 'create') {
             editor.layout['#children'].push(afGui.meta.elements.submit.element);
           }
         }
-        else {
-          editor.layout['#children'] = editor.afform.layout;
-        }
 
         if (editor.getFormType() === 'block') {
           editor.blockEntity = editor.afform.join_entity || editor.afform.entity_type || '*';
           editor.searchDisplays = getSearchDisplaysOnForm();
         }
 
-        // Set changesSaved to true on initial load, false thereafter whenever changes are made to the model
-        $scope.changesSaved = editor.mode === 'edit' ? 1 : false;
-        $scope.$watch('editor.afform', function () {
-          $scope.changesSaved = $scope.changesSaved === 1;
+        // Initialize undo history
+        undoAction = 'initialLoad';
+        undoHistory = [{
+          afform: _.cloneDeep(editor.afform),
+          saved: editor.mode === 'edit',
+          selectedEntityName: null
+        }];
+        $scope.$watch('editor.afform', function(newValue, oldValue) {
+          if (!undoAction && newValue && oldValue) {
+            // Clear "redo" history
+            if (undoPosition) {
+              undoHistory.splice(0, undoPosition);
+              undoPosition = 0;
+            }
+            undoHistory.unshift({
+              afform: _.cloneDeep(editor.afform),
+              saved: false,
+              selectedEntityName: $scope.selectedEntityName
+            });
+            // Trim to a total length of 20
+            if (undoHistory.length > 20) {
+              undoHistory.splice(20, undoHistory.length - 20);
+            }
+          }
+          undoAction = null;
         }, true);
       }
 
+      // Undo/redo keys (ctrl-z, ctrl-shift-z)
+      this.onKeyup = function(e) {
+        if (e.key === 'z' && e.ctrlKey && e.shiftKey) {
+          editor.redo();
+        }
+        else if (e.key === 'z' && e.ctrlKey) {
+          editor.undo();
+        }
+      };
+
+      this.canUndo = function() {
+        return !!undoHistory[undoPosition + 1];
+      };
+
+      this.canRedo = function() {
+        return !!undoHistory[undoPosition - 1];
+      };
+
+      // Revert to a previous/next revision in the undo history
+      function changeHistory(direction) {
+        if (!undoHistory[undoPosition + direction]) {
+          return;
+        }
+        undoPosition += direction;
+        undoAction = 'change';
+        editor.afform = _.cloneDeep(undoHistory[undoPosition].afform);
+        setEditorLayout();
+        $scope.canvasTab = 'layout';
+        $scope.selectedEntityName = undoHistory[undoPosition].selectedEntityName;
+      }
+
+      this.undo = _.wrap(1, changeHistory);
+
+      this.redo = _.wrap(-1, changeHistory);
+
+      this.isSaved = function() {
+        return undoHistory[undoPosition].saved;
+      };
+
       this.getFormType = function() {
         return editor.afform.type;
       };
         var afform = JSON.parse(angular.toJson(editor.afform));
         // This might be set to undefined by validation
         afform.server_route = afform.server_route || '';
-        $scope.saving = $scope.changesSaved = true;
+        $scope.saving = true;
         crmApi4('Afform', 'save', {formatWhitespace: true, records: [afform]})
           .then(function (data) {
             $scope.saving = false;
-            editor.afform.name = data[0].name;
-            if (editor.mode !== 'edit') {
-              $location.url('/edit/' + data[0].name);
+            // When saving a new form for the first time
+            if (!editor.afform.name) {
+              undoAction = 'save';
+              editor.afform.name = data[0].name;
             }
+            // Update undo history - mark current snapshot as "saved"
+            _.each(undoHistory, function(snapshot, index) {
+              snapshot.saved = index === undoPosition;
+              snapshot.afform.name = data[0].name;
+            });
           });
       };
 
index 4321a8851aa332c49d938b6e96d237e54dcac5e3..1a7212666df32282d037dd3c1e505e13e26b65e0 100644 (file)
@@ -1,18 +1,27 @@
+<div crm-ui-debug="editor.afform"></div>
 <div class="panel panel-default">
   <div class="panel-heading">
 
+    <div class="form-inline af-gui-canvas-control-buttons">
+      <button class="btn btn-default" type="button" title="{{:: ts('Undo (Ctrl-Z)') }}" ng-disabled="!editor.canUndo()" ng-click="editor.undo()">
+        <i class="crm-i fa-undo"></i>
+      </button>
+      <button class="btn btn-default" type="button" title="{{:: ts('Redo (Ctrl-Shift-Z)') }}" ng-disabled="!editor.canRedo()" ng-click="editor.redo()">
+        <i class="crm-i fa-repeat"></i>
+      </button>
+    </div>
     <div class="form-inline pull-right">
-      <div class="form-group" ng-if="changesSaved && !saving && editor.afform.server_route">
+      <div class="form-group" ng-if="editor.isSaved() && !saving && editor.afform.server_route">
         <a target="_blank" href="{{ editor.getLink() }}">
           <i class="crm-i fa-external-link"></i>
           {{:: ts('View Page') }}
         </a>
       </div>
       <div class="btn-group btn-group-md">
-        <button type="submit" class="btn" ng-class="{'btn-primary': !changesSaved && !saving, 'btn-warning': saving, 'btn-success': changesSaved}" ng-disabled="changesSaved || saving || !editor.afform.title" ng-click="save()">
+        <button type="submit" class="btn" ng-class="{'btn-primary': !editor.isSaved() && !saving, 'btn-warning': saving, 'btn-success': editor.isSaved()}" ng-disabled="editor.isSaved() || saving || !editor.afform.title" ng-click="save()">
           <i class="crm-i" ng-class="{'fa-check': !saving, 'fa-spin fa-spinner': saving}"></i>
-          <span ng-if="changesSaved && !saving">{{:: ts('Saved') }}</span>
-          <span ng-if="!changesSaved && !saving">{{:: ts('Save') }}</span>
+          <span ng-if="editor.isSaved() && !saving">{{:: ts('Saved') }}</span>
+          <span ng-if="!editor.isSaved() && !saving">{{:: ts('Save') }}</span>
           <span ng-if="saving">{{:: ts('Saving...') }}</span>
         </button>
       </div>
index 4e594bb614c258c10d6545b153762b864d9979fb..cb49a6f4fbf286295fb0bcd9dc30409e5edcfdc6 100644 (file)
@@ -5,14 +5,14 @@
       {{:: ts('Title') }} <span class="crm-marker">*</span>
     </label>
     <p class="help-block" ng-if=":: editor.afform.type !== 'block'">{{:: ts('Public title (usually displayed at the top of the form).') }}</p>
-    <input ng-model="editor.afform.title" class="form-control" id="af_config_form_title" required title="{{:: ts('Required') }}" />
+    <input ng-model="editor.afform.title" class="form-control" id="af_config_form_title" required title="{{:: ts('Required') }}" ng-model-options="editor.debounceMode" >
   </div>
 
   <div class="form-group">
     <label for="af_config_form_description">
       {{:: ts('Description') }}
     </label>
-    <textarea ng-model="editor.afform.description" class="form-control" id="af_config_form_description"></textarea>
+    <textarea ng-model="editor.afform.description" class="form-control" id="af_config_form_description" ng-model-options="editor.debounceMode"></textarea>
     <p class="help-block">{{:: ts("Internal note about the form's purpose (not displayed on form).") }}</p>
     <!-- Description is "semi-private": not generally public, but not audited for secrecy -->
   </div>
@@ -22,7 +22,7 @@
     <label for="af_config_form_permission">
       {{:: ts('Permission') }}
     </label>
-    <input ng-model="editor.afform.permission" class="form-control" id="af_config_form_permission" crm-ui-select="{data: editor.meta.permissions}" />
+    <input ng-model="editor.afform.permission" class="form-control" id="af_config_form_permission" crm-ui-select="{data: editor.meta.permissions}" >
     <p class="help-block">{{:: ts('What permission is required to use this form?') }}</p>
   </div>
 
@@ -34,7 +34,7 @@
       <label for="af_config_form_server_route">
         {{:: ts('Page') }}
       </label>
-      <input ng-model="editor.afform.server_route" name="server_route" class="form-control" id="af_config_form_server_route" pattern="^civicrm\/[-0-9a-zA-Z\/_]+$" onfocus="this.value = this.value || 'civicrm/'" onblur="if (this.value === 'civicrm/') this.value = ''" title="{{:: ts('Path must begin with &quot;civicrm/&quot;') }}">
+      <input ng-model="editor.afform.server_route" name="server_route" class="form-control" id="af_config_form_server_route" pattern="^civicrm\/[-0-9a-zA-Z\/_]+$" onfocus="this.value = this.value || 'civicrm/'" onblur="if (this.value === 'civicrm/') this.value = ''" title="{{:: ts('Path must begin with &quot;civicrm/&quot;') }}" ng-model-options="editor.debounceMode">
       <p class="help-block">{{:: ts('Expose the form as a standalone webpage. (Example: "civicrm/my-form")') }}</p>
     </div>
 
@@ -92,7 +92,7 @@
       <label for="af_config_redirect">
         {{:: ts('Post-Submit Page') }}
       </label>
-      <input ng-model="editor.afform.redirect" name="redirect" class="form-control" id="af_config_redirect" title="{{:: ts('Post-Submit Page') }}" pattern="^((http|https):\/\/|\/|civicrm\/)[-0-9a-zA-Z\/_.]\S+$" title="{{:: ts('Post-Submit Page must be either an absolute url, a relative url or a path starting with CiviCRM') }}"/>
+      <input ng-model="editor.afform.redirect" name="redirect" class="form-control" id="af_config_redirect" title="{{:: ts('Post-Submit Page') }}" pattern="^((http|https):\/\/|\/|civicrm\/)[-0-9a-zA-Z\/_.]\S+$" title="{{:: ts('Post-Submit Page must be either an absolute url, a relative url or a path starting with CiviCRM') }}" ng-model-options="editor.debounceMode" >
       <p class="help-block">{{:: ts('Enter a URL or path that the form should redirect to following a successful submission.') }}</p>
     </div>
   </fieldset>
index 2bdbc9ff164e6d3cfad59880bf6d46363d5de737..b9b2d631ecc5e09d24f36c3c98804477b1b4d81b 100644 (file)
@@ -1,6 +1,6 @@
 <div class="form-inline">
   <div class="input-group">
-    <input autocomplete="off" class="form-control" placeholder="{{:: ts('Select') }}" title="{{:: ts('Click to add placeholder text') }}" ng-model="getSet('input_attrs.placeholder')" ng-model-options="{getterSetter: true}" type="text" />
+    <input autocomplete="off" class="form-control" placeholder="{{:: ts('Select') }}" title="{{:: ts('Click to add placeholder text') }}" ng-model="getSet('input_attrs.placeholder')" ng-model-options="$ctrl.editor.debounceWithGetterSetter" type="text" />
     <div class="input-group-btn">
       <button type="button" class="btn btn-default disabled dropdown-toggle"><i class="crm-i fa-caret-down"></i></button>
     </div>
index 0772a24ed12ecc42b2eb66dbd8233a67bf478808..9d956452cd81d057f7cbf2acf20d44de92285994 100644 (file)
@@ -1,8 +1,8 @@
 <div class="form-inline">
   <div class="form-group" ng-repeat="i in $ctrl.getRangeElements('Date')">
     <span class="af-field-range-sep" ng-if="i">-</span>
-    <input autocomplete="off" class="form-control crm-form-date crm-placeholder-icon" placeholder="&#xF073" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="{getterSetter: true}" type="text" title="{{:: ts('Click to add placeholder text') }}" />
+    <input autocomplete="off" class="form-control crm-form-date crm-placeholder-icon" placeholder="&#xF073" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="$ctrl.editor.debounceWithGetterSetter" type="text" title="{{:: ts('Click to add placeholder text') }}" />
     <span class="addon fa fa-calendar"></span>
-    <input autocomplete="off" ng-if="getProp('input_attrs.time')" placeholder="&#xF017" class="form-control crm-form-time crm-placeholder-icon" ng-model="getSet('input_attrs.timePlaceholder' + i)" ng-model-options="{getterSetter: true}" type="text" title="{{:: ts('Click to add placeholder text') }}" />
+    <input autocomplete="off" ng-if="getProp('input_attrs.time')" placeholder="&#xF017" class="form-control crm-form-time crm-placeholder-icon" ng-model="getSet('input_attrs.timePlaceholder' + i)" ng-model-options="$ctrl.editor.debounceWithGetterSetter" type="text" title="{{:: ts('Click to add placeholder text') }}" />
   </div>
 </div>
index 738be8efce165b28880215d551045ab1c2b66234..d2538363fd30eee1801ef55882a6ebc26732bd3d 100644 (file)
@@ -1,6 +1,6 @@
 <div class="form-inline">
   <div class="input-group">
-    <input autocomplete="off" type="text" class="form-control" placeholder="{{:: ts('Select %1', {1: $ctrl.getFkEntity().label}) }}" title="{{:: ts('Click to add placeholder text') }}" ng-model="getSet('input_attrs.placeholder')" ng-model-options="{getterSetter: true}">
+    <input autocomplete="off" type="text" class="form-control" placeholder="{{:: ts('Select %1', {1: $ctrl.getFkEntity().label}) }}" title="{{:: ts('Click to add placeholder text') }}" ng-model="getSet('input_attrs.placeholder')" ng-model-options="$ctrl.editor.debounceWithGetterSetter">
     <div class="input-group-btn">
       <button type="button" class="btn btn-default" disabled><i class="crm-i fa-search"></i></button>
     </div>
index cf7ce8b934bb7dd16acd2a58db7403bafb993d24..de62c538b89c569c883f8ac6b3ce51dd69ae2d14 100644 (file)
@@ -1,6 +1,6 @@
 <div class="form-inline">
   <div class="form-group" ng-repeat="i in $ctrl.getRangeElements('Number')">
     <span class="af-field-range-sep" ng-if="i">-</span>
-    <input autocomplete="off" class="form-control" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="{getterSetter: true}" type="text" title="{{:: ts('Click to add placeholder text') }}"/>
+    <input autocomplete="off" class="form-control" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="$ctrl.editor.debounceWithGetterSetter" type="text" title="{{:: ts('Click to add placeholder text') }}"/>
   </div>
 </div>
index 1f5e849f20a8f1b158335eb1c254be412570ca72..c5b279630f5fbec0915dd1bf9d3cf7a4b73a870f 100644 (file)
@@ -2,7 +2,7 @@
   <div class="form-group" ng-repeat="i in $ctrl.getRangeElements('Select')">
     <span class="af-field-range-sep" ng-if="i">-</span>
     <div class="input-group">
-      <input autocomplete="off" class="form-control" placeholder="{{:: ts('Select') }}" title="{{:: ts('Click to add placeholder text') }}" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="{getterSetter: true}" type="text" />
+      <input autocomplete="off" class="form-control" placeholder="{{:: ts('Select') }}" title="{{:: ts('Click to add placeholder text') }}" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="$ctrl.editor.debounceWithGetterSetter" type="text" />
       <div class="input-group-btn" af-gui-menu>
         <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i class="crm-i fa-caret-down"></i></button>
         <ul class="dropdown-menu" ng-if="menu.open" title="{{:: ts('Set default value') }}">
index 5a3192f35ae07bdf2e34bb8c13af79324a3a320c..e515ba3c46010c34ba5cd5864ebf2a7d79aa9bdf 100644 (file)
@@ -1,6 +1,6 @@
 <div class="form-inline">
   <div class="form-group" ng-repeat="i in $ctrl.getRangeElements('Text')">
     <span class="af-field-range-sep" ng-if="i">-</span>
-    <input autocomplete="off" class="form-control" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="{getterSetter: true}" type="text" title="{{:: ts('Click to add placeholder text') }}"/>
+    <input autocomplete="off" class="form-control" ng-model="getSet('input_attrs.placeholder' + i)" ng-model-options="$ctrl.editor.debounceWithGetterSetter" type="text" title="{{:: ts('Click to add placeholder text') }}"/>
   </div>
 </div>
index c75e38c5e5ba0f1e711433ebc4a747846d0f277d..8360557f05daf8b5cb1ea4b7c60b990c0cabeaed 100644 (file)
     initializeResponsive: function() {
       var $mainMenuState = $('#crm-menubar-state');
       // hide mobile menu beforeunload
-      $(window).on('beforeunload unload', function() {
-        CRM.menubar.spin(true);
+      $(window).on('beforeunload unload', function(e) {
+        if (!e.originalEvent.returnValue) {
+          CRM.menubar.spin(true);
+        }
         if ($mainMenuState[0].checked) {
           $mainMenuState[0].click();
         }