Afform - Enable search operators to be exposed on the form
authorcolemanw <coleman@civicrm.org>
Fri, 9 Jun 2023 12:06:13 +0000 (08:06 -0400)
committercolemanw <coleman@civicrm.org>
Fri, 9 Jun 2023 18:55:27 +0000 (14:55 -0400)
ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php
ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/afform/core/Civi/Afform/AfformMetadataInjector.php
ext/afform/core/Civi/Afform/FormDataModel.php
ext/afform/core/Civi/Afform/Utils.php
ext/afform/core/ang/af/afField.component.js
ext/afform/core/ang/af/afField.html
ext/afform/core/ang/af/afFieldWithSearchOperator.html [new file with mode: 0644]
ext/afform/core/ang/afCore.css

index ed1ef31e7806e54d1197e21e10906d9ae4796cc0..e788dfa74f88fadd740e095669c15136e543dcbd 100644 (file)
@@ -31,6 +31,7 @@ class AfformAdminMeta {
     }
     return [
       'afform_type' => $afformTypes,
+      'search_operators' => \Civi\Afform\Utils::getSearchOperators(),
     ];
   }
 
@@ -88,7 +89,7 @@ class AfformAdminMeta {
       'checkPermissions' => FALSE,
       'loadOptions' => ['id', 'label'],
       'action' => 'create',
-      'select' => ['name', 'label', 'input_type', 'input_attrs', 'required', 'options', 'help_pre', 'help_post', 'serialize', 'data_type', 'entity', 'fk_entity', 'readonly'],
+      'select' => ['name', 'label', 'input_type', 'input_attrs', 'required', 'options', 'help_pre', 'help_post', 'serialize', 'data_type', 'entity', 'fk_entity', 'readonly', 'operators'],
       'where' => [['deprecated', '=', FALSE], ['input_type', 'IS NOT NULL']],
     ];
     if (in_array($entityName, \CRM_Contact_BAO_ContactType::basicTypes(TRUE), TRUE)) {
index 61a5a76c14763c6409a0c4e314c8e0169b093f73..7e729c184241b618fa606971f74e039d29ede1b5 100644 (file)
     {{:: ts('Search by range') }}
   </a>
 </li>
-<li ng-if="$ctrl.isSearch()">
-  <div href ng-click="$event.stopPropagation()" class="af-gui-field-select-in-dropdown">
+<li ng-if="$ctrl.isSearch()" ng-click="$event.stopPropagation()">
+  <div href class="af-gui-field-select-in-dropdown">
     <label>{{:: ts('Operator:') }}</label>
-    <select class="form-control" ng-model="getSet('search_operator')" ng-model-options="{getterSetter: true}" title="{{:: ts('Field type') }}">
+    <select class="form-control" ng-model="getSetOperator" ng-model-options="{getterSetter: true}" title="{{:: ts('Set the search operator for this field or allow the user to select it on the form') }}">
+      <option value="">{{:: ts('Auto') }}</option>
+      <option value="_EXPOSE_">{{:: ts('User Select') }}</option>
+      <option ng-repeat="(name, label) in $ctrl.searchOperators" value="{{ name }}">{{ label }}</option>
+    </select>
+  </div>
+  <div href class="af-gui-field-select-in-dropdown" ng-if="$ctrl.getSet('expose_operator')">
+    <label>{{:: ts('Default:') }}</label>
+    <select class="form-control" ng-model="getSet('search_operator')" ng-model-options="{getterSetter: true}" title="{{:: ts('Default search operator for the user to select') }}">
       <option ng-repeat="(name, label) in $ctrl.searchOperators" value="{{ name }}">{{ label }}</option>
     </select>
   </div>
index 087b27832ea61fe216b6b32022314089cfa0c4a7..69c304e5ab5058fcf9a5de41d7130c533d8e88c0 100644 (file)
             inputTypes.push(type);
           }
         });
+        this.searchOperators = CRM.afAdmin.search_operators;
+        // If field has limited operators, set appropriately
+        if (ctrl.fieldDefn.operators && ctrl.fieldDefn.operators.length) {
+          this.searchOperators = _.pick(this.searchOperators, ctrl.fieldDefn.operators);
+        }
         setDateOptions();
       };
 
         }
       };
 
-      // Getter/setter for definition props
+      // Getter/setter for search_operator and expose_operator combo-field
+      // The expose_operator flag changes the behavior of the search_operator field
+      // to either set the value on the backend, or set the default value for the user-select list on the form
+      $scope.getSetOperator = function(val) {
+        if (arguments.length) {
+          // _EXPOSE_ is not a real option for search_operator, instead it sets the expose_operator boolean
+          getSet('expose_operator', val === '_EXPOSE_');
+          if (val === '_EXPOSE_') {
+            getSet('search_operator', _.keys(ctrl.searchOperators)[0]);
+          } else {
+            getSet('search_operator', val);
+          }
+          return val;
+        }
+        return getSet('expose_operator') ? '_EXPOSE_' : getSet('search_operator');
+      };
+
+      // Generic getter/setter for definition props
       $scope.getSet = function(propName) {
         return _.wrap(propName, getSet);
       };
         $scope.editingOptions = val;
       };
 
-      this.searchOperators = {
-        '': ts('Auto'),
-        '=': '=',
-        '!=': '≠',
-        '>': '>',
-        '<': '<',
-        '>=': '≥',
-        '<=': '≤',
-        'CONTAINS': ts('Contains'),
-        'NOT CONTAINS': ts("Doesn't Contain"),
-        'IN': ts('Is One Of'),
-        'NOT IN': ts('Not One Of'),
-        'LIKE': ts('Is Like'),
-        'NOT LIKE': ts('Not Like'),
-        'REGEXP': ts('Matches Pattern'),
-        'NOT REGEXP': ts("Doesn't Match Pattern"),
-      };
-
       // Returns a reference to a path n-levels deep within an object
       function drillDown(parent, path) {
         var container = parent;
index 0d6df28eb296c410fe7cdeef88a135db4e8dde9a..d793f590826e491a7341271d6b24049a293fbdc0 100644 (file)
@@ -121,6 +121,19 @@ class AfformMetadataInjector {
     // On a search form, search_range will present a pair of fields (or possibly 3 fields for date select + range)
     $isSearchRange = !empty($fieldDefn['search_range']) && \CRM_Utils_JS::decode($fieldDefn['search_range']);
 
+    // On a search form, the exposed operator requires a list of options.
+    if (!empty($fieldDefn['expose_operator'])) {
+      $operators = Utils::getSearchOperators();
+      // If 'operators' is present in the field definition, use it as a limiter
+      // Afform expects 'operators' in the fieldDefn to be associative key/label, not just a flat array
+      // like it is in the schema.
+      if (!empty($fieldInfo['operators'])) {
+        $operators = array_intersect_key($operators, array_flip($fieldInfo['operators']));
+      }
+      $fieldDefn['operators'] = \CRM_Utils_JS::encode($operators);
+    }
+    unset($fieldInfo['operators']);
+
     // Default placeholder for select inputs
     if ($inputType === 'Select' || $inputType === 'ChainSelect') {
       $fieldInfo['input_attrs']['placeholder'] = E::ts('Select');
index 646f2641f5bba3ad50d86344defd752735cb05e8..a5e07402eae41a305cb73fce872373a14d61c9a3 100644 (file)
@@ -207,10 +207,14 @@ class FormDataModel {
     if ($action === 'get' && strpos($fieldName, '.')) {
       $namesToMatch[] = substr($fieldName, 0, strrpos($fieldName, '.'));
     }
+    $select = ['name', 'label', 'input_type', 'input_attrs', 'help_pre', 'help_post', 'options', 'fk_entity', 'required'];
+    if ($action === 'get') {
+      $select[] = 'operators';
+    }
     $params = [
       'action' => $action,
       'where' => [['name', 'IN', $namesToMatch]],
-      'select' => ['name', 'label', 'input_type', 'input_attrs', 'help_pre', 'help_post', 'options', 'fk_entity', 'required'],
+      'select' => $select,
       'loadOptions' => ['id', 'label'],
       // If the admin included this field on the form, then it's OK to get metadata about the field regardless of user permissions.
       'checkPermissions' => FALSE,
index c183ae6794258814a09ff3a7e1175181e6126503..2dc219cb757286e9ec3d0f53632dc08ca71627fc 100644 (file)
@@ -49,4 +49,31 @@ class Utils {
     return $sorter->sort();
   }
 
+  /**
+   * Subset of APIv4 operators that are appropriate for use on Afforms
+   *
+   * This list may be further reduced by fields which declare a limited number of
+   * operators in their metadata.
+   *
+   * @return array
+   */
+  public static function getSearchOperators() {
+    return [
+      '=' => '=',
+      '!=' => '≠',
+      '>' => '>',
+      '<' => '<',
+      '>=' => '≥',
+      '<=' => '≤',
+      'CONTAINS' => ts('Contains'),
+      'NOT CONTAINS' => ts("Doesn't Contain"),
+      'IN' => ts('Is One Of'),
+      'NOT IN' => ts('Not One Of'),
+      'LIKE' => ts('Is Like'),
+      'NOT LIKE' => ts('Not Like'),
+      'REGEXP' => ts('Matches Pattern'),
+      'NOT REGEXP' => ts("Doesn't Match Pattern"),
+    ];
+  }
+
 }
index 1e6173d58a5c6e3c2fc16b3747c0346d68aaaded..f579eedb7987fa5149f08918b98f6e715be133dd 100644 (file)
           namePrefix = this.fieldName.substr(0, this.fieldName.length - this.defn.name.length);
         }
 
+        if (this.defn.search_operator) {
+          this.search_operator = this.defn.search_operator;
+        }
+
         // is_primary field - watch others in this afRepeat block to ensure only one is selected
         if (ctrl.fieldName === 'is_primary' && 'repeatIndex' in $scope.dataProvider) {
           $scope.$watch('dataProvider.afRepeat.getEntityController().getData()', function (items, prev) {
         };
       };
 
+      this.onChangeOperator = function() {
+        $scope.dataProvider.getFieldData()[ctrl.fieldName] = {};
+      };
+
       // Getter/Setter function for most fields (except select & entityRef)
       $scope.getSetValue = function(val) {
         var currentVal = $scope.dataProvider.getFieldData()[ctrl.fieldName];
         // Setter
         if (arguments.length) {
-          if (ctrl.defn.search_operator) {
+          if (ctrl.search_operator) {
             if (typeof currentVal !== 'object') {
               $scope.dataProvider.getFieldData()[ctrl.fieldName] = {};
             }
-            return ($scope.dataProvider.getFieldData()[ctrl.fieldName][ctrl.defn.search_operator] = val);
+            return ($scope.dataProvider.getFieldData()[ctrl.fieldName][ctrl.search_operator] = val);
           }
           return ($scope.dataProvider.getFieldData()[ctrl.fieldName] = val);
         }
         // Getter
-        if (ctrl.defn.search_operator) {
-          return (currentVal || {})[ctrl.defn.search_operator];
+        if (ctrl.search_operator) {
+          return (currentVal || {})[ctrl.search_operator];
         }
         return currentVal;
       };
           else if (ctrl.defn.search_range) {
             return ($scope.dataProvider.getFieldData()[ctrl.fieldName]['>='] = val);
           }
-          else if (ctrl.defn.search_operator) {
+          else if (ctrl.search_operator) {
             if (typeof currentVal !== 'object') {
               $scope.dataProvider.getFieldData()[ctrl.fieldName] = {};
             }
-            return ($scope.dataProvider.getFieldData()[ctrl.fieldName][ctrl.defn.search_operator] = val);
+            return ($scope.dataProvider.getFieldData()[ctrl.fieldName][ctrl.search_operator] = val);
           }
           return ($scope.dataProvider.getFieldData()[ctrl.fieldName] = val);
         }
         else if (ctrl.defn.search_range) {
           return currentVal['>='];
         }
-        else if (ctrl.defn.search_operator) {
-          return (currentVal || {})[ctrl.defn.search_operator];
+        else if (ctrl.search_operator) {
+          return (currentVal || {})[ctrl.search_operator];
         }
         return currentVal;
       };
index 1965f74c7f629d868deac78b5aa62343ea6eac91..d7e6b7a165bf14c7ff92b9056bcdc4a7348813af 100644 (file)
@@ -3,5 +3,6 @@
   <span class="crm-marker" title="{{:: ts('Required') }}" ng-if=":: $ctrl.defn.required">*</span>
 </label>
 <p class="crm-af-field-help-pre" ng-if=":: $ctrl.defn.help_pre">{{:: $ctrl.defn.help_pre }}</p>
-<div class="crm-af-field" ng-include="'~/af/fields/' + $ctrl.defn.input_type + '.html'"></div>
+<div class="crm-af-field" ng-if="!$ctrl.defn.expose_operator" ng-include="'~/af/fields/' + $ctrl.defn.input_type + '.html'"></div>
+<div class="input-group" ng-include="'~/af/afFieldWithSearchOperator.html'"></div>
 <p class="crm-af-field-help-post" ng-if=":: $ctrl.defn.help_post">{{:: $ctrl.defn.help_post }}</p>
diff --git a/ext/afform/core/ang/af/afFieldWithSearchOperator.html b/ext/afform/core/ang/af/afFieldWithSearchOperator.html
new file mode 100644 (file)
index 0000000..3260716
--- /dev/null
@@ -0,0 +1,4 @@
+<select class="form-control" crm-ui-select ng-model="$ctrl.search_operator" ng-change="$ctrl.onChangeOperator()">
+  <option ng-repeat="(name, label) in $ctrl.defn.operators" value="{{ name }}">{{ label }}</option>
+</select>
+<div class="crm-af-field" ng-include="'~/af/fields/' + $ctrl.defn.input_type + '.html'"></div>
index f0ce53c95147d3b2ca7f3dc1933ea150343506cb..35e095925c3c728418582c398991a5a11779cbf1 100644 (file)
@@ -30,6 +30,10 @@ af-form {
   display: block;
 }
 
+#bootstrap-theme .input-group .crm-af-field {
+  display: inline-block;
+}
+
 [af-repeat-item] {
   position: relative;
 }