Afform - support default values for fields
authorColeman Watts <coleman@civicrm.org>
Wed, 4 Aug 2021 02:32:38 +0000 (22:32 -0400)
committerColeman Watts <coleman@civicrm.org>
Thu, 5 Aug 2021 00:22:48 +0000 (20:22 -0400)
This adds an "afform_default" property to the field definition.
It does not use the "default_value" property from getFields because that's more
to do with Civi's schema and often not appropriate for a form.
Fixes dev/core#2734

css/civicrm.css
ext/afform/admin/ang/afGuiEditor.css
ext/afform/admin/ang/afGuiEditor.js
ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/afform/admin/ang/afGuiEditor/inputType/CheckBox.html
ext/afform/admin/ang/afGuiEditor/inputType/Radio.html
ext/afform/admin/ang/afGuiEditor/inputType/Select.html
ext/afform/core/ang/af/afField.component.js

index 27277e73b756c68062c967179ab21262a8b5e23e..5aa2cf87fbd32cb4a6e10cfe19f1a185f0b1e168 100644 (file)
@@ -3231,7 +3231,7 @@ span.crm-select-item-color {
   top: 4px;
 }
 .crm-container ul.crm-checkbox-list li label {
-  display: block;
+  display: block !important;
   padding: 2px 0 2px 22px;
   margin: 0;
   word-break: break-all;
index 7e5c2f219e6484b147a53c4918f9874608542dff..a18dc7383487eb3920b4ba40fc4c0902c2e06b25 100644 (file)
   text-align: right;
 }
 
-#afGuiEditor .af-gui-bar {
+#afGuiEditor-canvas .af-gui-bar {
   height: 22px;
   width: 100%;
-  opacity: 0;
   transition: opacity .2s;
   position:relative;
   font-family: "Courier New", Courier, monospace;
   font-size: 12px;
 }
-#afGuiEditor [ui-sortable] .af-gui-bar {
+#afGuiEditor-canvas:not(.af-gui-menu-open) .af-gui-bar {
+  opacity: 0;
+}
+#afGuiEditor-canvas [ui-sortable] .af-gui-bar {
   cursor: move;
   background-color: #f2f2f2;
   position: absolute;
   opacity: 1;
   transition: opacity .2s;
 }
-#afGuiEditor-canvas .af-gui-dragtarget > .af-gui-bar {
+#afGuiEditor #afGuiEditor-canvas .af-gui-dragtarget > .af-gui-bar {
   background-color: #d7e6ff;
   opacity: 1;
   transition: opacity .1s;
@@ -218,7 +220,7 @@ body.af-gui-dragging {
   margin-bottom: 20px;
 }
 
-#afGuiEditor .af-gui-container:hover,
+#afGuiEditor-canvas:not(.af-gui-menu-open) .af-gui-container:hover,
 .af-gui-dragging #afGuiEditor .af-gui-container {
   border: 2px dashed #757575;
 }
@@ -456,9 +458,6 @@ body.af-gui-dragging {
   width: 100%;
   right: 0;
 }
-#afGuiEditor .af-gui-field-input-type-select .input-group .dropdown-menu a {
-  cursor: default;
-}
 
 #afGuiEditor .af-gui-text-h1 {
   font-weight: bolder;
index 6fde62204abb32cd5beae1a7b7a63236ddbc6fbc..3ecfdeadc2bdcfc5cf47891c76eee59fe7e6313f 100644 (file)
           .on('show.bs.dropdown', function() {
             $scope.$apply(function() {
               $scope.menu.open = true;
+              element.closest('#afGuiEditor-canvas').addClass('af-gui-menu-open');
             });
           })
           .on('hidden.bs.dropdown', function() {
             $scope.$apply(function() {
               $scope.menu.open = false;
+              element.closest('#afGuiEditor-canvas').removeClass('af-gui-menu-open');
             });
           });
       }
index 9ca8f4c98be5cc21b042a584d89ad5c1cf255020..ba3a108592d95d07d890496b8200829faf5788f4 100644 (file)
@@ -5,34 +5,29 @@
   // Cribbed from the Api4 Explorer
   angular.module('afGuiEditor').directive('afGuiFieldValue', function(afGui) {
     return {
-      scope: {
-        field: '=afGuiFieldValue'
+      bindToController: {
+        field: '<afGuiFieldValue'
       },
       require: {
         ngModel: 'ngModel',
         editor: '^^afGuiEditor'
       },
-      link: function (scope, element, attrs, ctrl) {
-        var ts = scope.ts = CRM.ts('org.civicrm.afform_admin'),
+      controller: function ($element, $timeout) {
+        var ts = CRM.ts('org.civicrm.afform_admin'),
+          ctrl = this,
           multi;
 
-        function destroyWidget() {
-          var $el = $(element);
-          if ($el.is('.crm-form-date-wrapper .crm-hidden-date')) {
-            $el.crmDatepicker('destroy');
-          }
-          if ($el.is('.select2-container + input')) {
-            $el.crmEntityRef('destroy');
-          }
-          $(element).removeData().removeAttr('type').removeAttr('placeholder').show();
-        }
-
         function makeWidget(field) {
           var options,
-            $el = $(element),
+            $el = $($element),
             inputType = field.input_type,
             dataType = field.data_type;
           multi = field.serialize || dataType === 'Array';
+          // Allow input_type to override dataType
+          if (inputType) {
+            multi = (dataType !== 'Boolean' &&
+              (inputType === 'CheckBox' || (field.input_attrs && field.input_attrs.multiple)));
+          }
           if (inputType === 'Date') {
             $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false});
           }
           return list;
         };
 
-        // Copied from ng-list
-        ctrl.ngModel.$parsers.push(parseList);
-        ctrl.ngModel.$formatters.push(function(value) {
-          return _.isArray(value) ? value.join(', ') : value;
-        });
+        this.$onInit = function() {
+          // Copied from ng-list
+          ctrl.ngModel.$parsers.push(parseList);
+          ctrl.ngModel.$formatters.push(function(value) {
+            return _.isArray(value) ? value.join(',') : value;
+          });
 
-        // Copied from ng-list
-        ctrl.ngModel.$isEmpty = function(value) {
-          return !value || !value.length;
-        };
+          // Copied from ng-list
+          ctrl.ngModel.$isEmpty = function(value) {
+            return !value || !value.length;
+          };
 
-        scope.$watchCollection('field', function(field) {
-          destroyWidget();
-          if (field) {
-            makeWidget(field);
-          }
-        });
+          $timeout(function() {
+            makeWidget(ctrl.field);
+          });
+        };
       }
     };
   });
index 01d7acc7196fbfdc6aa7fbe2e881d26349412a03..0739242f2c6b25baa5b5a00b5da5a4b42df87769 100644 (file)
@@ -7,25 +7,36 @@
   </div>
 </li>
 <li>
-  <a href ng-click="toggleRequired(); $event.stopPropagation();" title="{{:: ts('Require this field') }}">
+  <a href ng-click="toggleRequired(); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Require this field') }}">
     <i class="crm-i fa-{{ getProp('required') ? 'check-' : '' }}square-o"></i>
     {{:: ts('Required') }}
   </a>
 </li>
 <li>
-  <a href ng-click="toggleLabel(); $event.stopPropagation();" title="{{:: ts('Show field label') }}">
+  <a href ng-click="toggleDefaultValue(); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Pre-fill this field with a value') }}">
+    <i class="crm-i fa-{{ $ctrl.hasDefaultValue ? 'check-' : '' }}square-o"></i>
+    {{:: ts('Default value') }}
+  </a>
+</li>
+<li ng-if="$ctrl.hasDefaultValue">
+  <div ng-click="$event.stopPropagation()" class="af-gui-field-select-in-dropdown form-inline">
+    <input class="form-control" af-gui-field-value="$ctrl.fieldDefn" ng-model="getSet('afform_default')" ng-model-options="{getterSetter: true}" >
+  </div>
+</li>
+<li>
+  <a href ng-click="toggleLabel(); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Show field label') }}">
     <i class="crm-i fa-{{ $ctrl.node.defn.label === false ? '' : 'check-' }}square-o"></i>
     {{:: ts('Label') }}
   </a>
 </li>
 <li>
-  <a href ng-click="toggleHelp('pre'); $event.stopPropagation();" title="{{:: ts('Show help text above this field') }}">
+  <a href ng-click="toggleHelp('pre'); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Show help text above this field') }}">
     <i class="crm-i fa-{{ propIsset('help_pre') ? 'check-' : '' }}square-o"></i>
     {{:: ts('Pre help text') }}
   </a>
 </li>
 <li>
-  <a href ng-click="toggleHelp('post'); $event.stopPropagation();" title="{{:: ts('Show help text below this field') }}">
+  <a href ng-click="toggleHelp('post'); $event.stopPropagation(); $event.target.blur();" title="{{:: ts('Show help text below this field') }}">
     <i class="crm-i fa-{{ propIsset('help_post') ? 'check-' : '' }}square-o" ></i>
     {{:: ts('Post help text') }}
   </a>
index 07408c989d6b140ec683d751b7c948765437f715..b20511ae612bf2d0a6594d1e23a7be4eb1fdf61d 100644 (file)
       editor: '^^afGuiEditor',
       container: '^^afGuiContainer'
     },
-    controller: function($scope, afGui) {
+    controller: function($scope, afGui, $timeout) {
       var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'),
-        ctrl = this;
-
-      $scope.editingOptions = false;
-      var yesNo = [
-        {id: '1', label: ts('Yes')},
-        {id: '0', label: ts('No')}
-      ],
+        ctrl = this,
         entityRefOptions = [],
         singleElement = [''],
         // When search-by-range is enabled the second element gets a suffix for some properties like "placeholder2"
         rangeElements = ['', '2'],
         dateRangeElements = ['1', '2'],
         relativeDatesWithPickRange = CRM.afGuiEditor.dateRanges,
-        relativeDatesWithoutPickRange = relativeDatesWithPickRange.slice(1);
+        relativeDatesWithoutPickRange = relativeDatesWithPickRange.slice(1),
+        yesNo = [
+          {id: '1', label: ts('Yes')},
+          {id: '0', label: ts('No')}
+        ];
+      $scope.editingOptions = false;
 
       this.$onInit = function() {
+        ctrl.hasDefaultValue = !!getSet('afform_default');
+        ctrl.fieldDefn = angular.extend({}, ctrl.getDefn(), ctrl.node.defn);
         ctrl.inputTypes = _.transform(_.cloneDeep(afGui.meta.inputType), function(inputTypes, type) {
           if (inputTypeCanBe(type.name)) {
             // Change labels for EntityRef fields
@@ -92,7 +93,9 @@
           label: ts('Untitled'),
           required: false
         };
-        defn.input_attrs = _.isEmpty(defn.input_attrs) ? {} : defn.input_attrs;
+        if (_.isEmpty(defn.input_attrs)) {
+          defn.input_attrs = {};
+        }
         return defn;
       };
 
 
       $scope.toggleRequired = function() {
         getSet('required', !getSet('required'));
-        return false;
       };
 
       $scope.toggleHelp = function(position) {
         getSet('help_' + position, $scope.propIsset('help_' + position) ? null : (ctrl.getDefn()['help_' + position] || ts('Enter text')));
-        return false;
+      };
+
+      function defaultValueShouldBeArray() {
+        return ($scope.getProp('data_type') !== 'Boolean' &&
+          ($scope.getProp('input_type') === 'CheckBox' || $scope.getProp('input_attrs.multiple')));
+      }
+
+
+      $scope.toggleDefaultValue = function() {
+        if (ctrl.hasDefaultValue) {
+          getSet('afform_default', undefined);
+          ctrl.hasDefaultValue = false;
+        } else {
+          ctrl.hasDefaultValue = true;
+        }
+      };
+
+      $scope.defaultValueContains = function(val) {
+        var defaultVal = getSet('afform_default');
+        return defaultVal === val || (_.isArray(defaultVal) && _.includes(defaultVal, val));
+      };
+
+      $scope.toggleDefaultValueItem = function(val) {
+        if (defaultValueShouldBeArray()) {
+          if (!_.isArray(getSet('afform_default'))) {
+            ctrl.node.defn.afform_default = [];
+          }
+          if (_.includes(ctrl.node.defn.afform_default, val)) {
+            var newVal = _.without(ctrl.node.defn.afform_default, val);
+            getSet('afform_default', newVal.length ? newVal : undefined);
+            ctrl.hasDefaultValue = !!newVal.length;
+          } else {
+            ctrl.node.defn.afform_default.push(val);
+            ctrl.hasDefaultValue = true;
+          }
+        } else if (getSet('afform_default') === val) {
+          getSet('afform_default', undefined);
+          ctrl.hasDefaultValue = false;
+        } else {
+          getSet('afform_default', val);
+          ctrl.hasDefaultValue = true;
+        }
       };
 
       // Getter/setter for definition props
             clearOut(ctrl.node, ['defn'].concat(path));
           }
           // When changing input_type
-          if (propName === 'input_type' && ctrl.node.defn && ctrl.node.defn.search_range && !ctrl.canBeRange()) {
-            delete ctrl.node.defn.search_range;
+          if (propName === 'input_type') {
+            if (ctrl.node.defn && ctrl.node.defn.search_range && !ctrl.canBeRange()) {
+              delete ctrl.node.defn.search_range;
+              clearOut(ctrl.node, ['defn']);
+            }
+            if (ctrl.node.defn && ctrl.node.defn.input_attrs && 'multiple' in ctrl.node.defn.input_attrs && !ctrl.canBeMultiple()) {
+              delete ctrl.node.defn.input_attrs.multiple;
+              clearOut(ctrl.node, ['defn', 'input_attrs']);
+            }
+          }
+          ctrl.fieldDefn = angular.extend({}, ctrl.getDefn(), ctrl.node.defn);
+
+          // When changing the multiple property, force-reset the default value widget
+          if (ctrl.hasDefaultValue && _.includes(['input_type', 'input_attrs.multiple'], propName)) {
+            ctrl.hasDefaultValue = false;
+            if (!defaultValueShouldBeArray() && _.isArray(getSet('afform_default'))) {
+              ctrl.node.defn.afform_default = ctrl.node.defn.afform_default[0];
+            } else if (defaultValueShouldBeArray() && _.isString(getSet('afform_default')) && ctrl.node.defn.afform_default.length) {
+              ctrl.node.defn.afform_default = ctrl.node.defn.afform_default.split(',');
+            }
+            $timeout(function() {
+              ctrl.hasDefaultValue = true;
+            });
           }
           return val;
         }
index 5bc73d186c1e395f3e3f09d2b78b470d56017bc8..2f7935f7b51c36f5b1ead09b3e9c3501746d0038 100644 (file)
@@ -1,6 +1,6 @@
-<ul class="crm-checkbox-list" ng-if="$ctrl.getOptions()">
+<ul class="crm-checkbox-list" ng-if="$ctrl.getOptions()" title="{{:: ts('Set default value') }}">
   <li ng-repeat="opt in $ctrl.getOptions()" >
-    <input type="checkbox" disabled >
+    <input type="checkbox" ng-checked="defaultValueContains(opt.id)" ng-click="toggleDefaultValueItem(opt.id)" >
     <label>{{ opt.label }}</label>
   </li>
 </ul>
index 729d0ea22574a4282c2d3b9005d563a093eb8e54..b0042d68f7765aa12082213ba94a40a413e9290b 100644 (file)
@@ -1,6 +1,6 @@
-<div class="form-inline">
+<div class="form-inline" title="{{:: ts('Set default value') }}">
   <label ng-repeat="opt in $ctrl.getOptions()" class="radio" >
-    <input class="crm-form-radio" type="radio" disabled />
+    <input class="crm-form-radio" type="radio" ng-checked="defaultValueContains(opt.id)" ng-click="toggleDefaultValueItem(opt.id)" >
     {{ opt.label }}
   </label>
 </div>
index 542a304c487f0014f6933a8ac39e545aeccb3b8a..33dd6f932bbbb060cb61f1d82ca3d36ac925bdda 100644 (file)
@@ -5,9 +5,12 @@
       <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" />
       <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">
+        <ul class="dropdown-menu" ng-if="menu.open" title="{{:: ts('Set default value') }}">
           <li ng-repeat="opt in $ctrl.getOptions()" >
-            <a href>{{ opt.label }}</a>
+            <a href ng-click="toggleDefaultValueItem(opt.id); $event.stopPropagation(); $event.target.blur();">
+              <i class="crm-i fa-{{defaultValueContains(opt.id) ? 'check-' : ''}}circle-o"></i>
+              {{ opt.label }}
+            </a>
           </li>
         </ul>
       </div>
index 3453ac7682b4e918d3f8c4c850f13b45e0befa87..545351ca4aa3ca228988857b3453bd640425f2ca 100644 (file)
@@ -12,7 +12,7 @@
       fieldName: '@name',
       defn: '='
     },
-    controller: function($scope, $element, crmApi4) {
+    controller: function($scope, $element, crmApi4, $timeout) {
       var ts = $scope.ts = CRM.ts('org.civicrm.afform'),
         ctrl = this,
         boolOptions = [{id: true, label: ts('Yes')}, {id: false, label: ts('No')}],
           });
         }
 
+        // Set default value
+        if (ctrl.defn.afform_default) {
+          // Wait for parent controllers to initialize
+          $timeout(function() {
+            $scope.dataProvider.getFieldData()[ctrl.fieldName] = ctrl.defn.afform_default;
+          });
+        }
+
       };
 
       $scope.getOptions = function () {