From 835aeacb505caa3c9c6f1539a6a3eb7a8a305e76 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Tue, 31 May 2022 22:17:23 -0400 Subject: [PATCH] Afform - Add undo/redo buttons to the Admin UI --- ext/afform/admin/ang/afGuiEditor.css | 5 + .../ang/afGuiEditor/afGuiEditor.component.js | 127 ++++++++++++++++-- .../ang/afGuiEditor/afGuiEditorCanvas.html | 17 ++- .../admin/ang/afGuiEditor/config-form.html | 10 +- .../afGuiEditor/inputType/ChainSelect.html | 2 +- .../admin/ang/afGuiEditor/inputType/Date.html | 4 +- .../ang/afGuiEditor/inputType/EntityRef.html | 2 +- .../ang/afGuiEditor/inputType/Number.html | 2 +- .../ang/afGuiEditor/inputType/Select.html | 2 +- .../admin/ang/afGuiEditor/inputType/Text.html | 2 +- js/crm.menubar.js | 6 +- 11 files changed, 147 insertions(+), 32 deletions(-) diff --git a/ext/afform/admin/ang/afGuiEditor.css b/ext/afform/admin/ang/afGuiEditor.css index efb2c82151..585a397845 100644 --- a/ext/afform/admin/ang/afGuiEditor.css +++ b/ext/afform/admin/ang/afGuiEditor.css @@ -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; @@ -63,6 +64,10 @@ font-size: 12px; margin: 0; } +#afGuiEditor .form-inline.af-gui-canvas-control-buttons { + position: absolute; + left: 48%; +} #afGuiEditor .panel-body { padding: 5px 12px; diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js index 52fc975d7f..18283b58a5 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js @@ -27,8 +27,24 @@ $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(); @@ -38,14 +54,35 @@ $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; @@ -60,12 +97,11 @@ } $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') { @@ -74,9 +110,6 @@ 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 || '*'; @@ -91,13 +124,73 @@ 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; }; @@ -414,14 +507,20 @@ 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; + }); }); }; diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiEditorCanvas.html b/ext/afform/admin/ang/afGuiEditor/afGuiEditorCanvas.html index 4321a8851a..1a7212666d 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiEditorCanvas.html +++ b/ext/afform/admin/ang/afGuiEditor/afGuiEditorCanvas.html @@ -1,18 +1,27 @@ +
+
+ + +
-
+
-
diff --git a/ext/afform/admin/ang/afGuiEditor/config-form.html b/ext/afform/admin/ang/afGuiEditor/config-form.html index 4e594bb614..cb49a6f4fb 100644 --- a/ext/afform/admin/ang/afGuiEditor/config-form.html +++ b/ext/afform/admin/ang/afGuiEditor/config-form.html @@ -5,14 +5,14 @@ {{:: ts('Title') }} *

{{:: ts('Public title (usually displayed at the top of the form).') }}

- +
- +

{{:: ts("Internal note about the form's purpose (not displayed on form).") }}

@@ -22,7 +22,7 @@ - +

{{:: ts('What permission is required to use this form?') }}

@@ -34,7 +34,7 @@ - +

{{:: ts('Expose the form as a standalone webpage. (Example: "civicrm/my-form")') }}

@@ -92,7 +92,7 @@ - +

{{:: ts('Enter a URL or path that the form should redirect to following a successful submission.') }}

diff --git a/ext/afform/admin/ang/afGuiEditor/inputType/ChainSelect.html b/ext/afform/admin/ang/afGuiEditor/inputType/ChainSelect.html index 2bdbc9ff16..b9b2d631ec 100644 --- a/ext/afform/admin/ang/afGuiEditor/inputType/ChainSelect.html +++ b/ext/afform/admin/ang/afGuiEditor/inputType/ChainSelect.html @@ -1,6 +1,6 @@
- +
diff --git a/ext/afform/admin/ang/afGuiEditor/inputType/Date.html b/ext/afform/admin/ang/afGuiEditor/inputType/Date.html index 0772a24ed1..9d956452cd 100644 --- a/ext/afform/admin/ang/afGuiEditor/inputType/Date.html +++ b/ext/afform/admin/ang/afGuiEditor/inputType/Date.html @@ -1,8 +1,8 @@
- - + - +
diff --git a/ext/afform/admin/ang/afGuiEditor/inputType/EntityRef.html b/ext/afform/admin/ang/afGuiEditor/inputType/EntityRef.html index 738be8efce..d2538363fd 100644 --- a/ext/afform/admin/ang/afGuiEditor/inputType/EntityRef.html +++ b/ext/afform/admin/ang/afGuiEditor/inputType/EntityRef.html @@ -1,6 +1,6 @@
- +
diff --git a/ext/afform/admin/ang/afGuiEditor/inputType/Number.html b/ext/afform/admin/ang/afGuiEditor/inputType/Number.html index cf7ce8b934..de62c538b8 100644 --- a/ext/afform/admin/ang/afGuiEditor/inputType/Number.html +++ b/ext/afform/admin/ang/afGuiEditor/inputType/Number.html @@ -1,6 +1,6 @@
- - +
diff --git a/ext/afform/admin/ang/afGuiEditor/inputType/Select.html b/ext/afform/admin/ang/afGuiEditor/inputType/Select.html index 1f5e849f20..c5b279630f 100644 --- a/ext/afform/admin/ang/afGuiEditor/inputType/Select.html +++ b/ext/afform/admin/ang/afGuiEditor/inputType/Select.html @@ -2,7 +2,7 @@
-
- +