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;
$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;
+ });
});
};
+<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>
{{:: 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>
<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>
<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 "civicrm/"') }}">
+ <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 "civicrm/"') }}" ng-model-options="editor.debounceMode">
<p class="help-block">{{:: ts('Expose the form as a standalone webpage. (Example: "civicrm/my-form")') }}</p>
</div>
<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>
<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>
<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="" 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="" 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="" 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="" 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>
<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>
<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>
<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') }}">
<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>
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();
}