Afform - Support search-by-range and search-by-multiple-values
authorColeman Watts <coleman@civicrm.org>
Wed, 31 Mar 2021 18:30:12 +0000 (14:30 -0400)
committerColeman Watts <coleman@civicrm.org>
Mon, 5 Apr 2021 19:14:29 +0000 (15:14 -0400)
This adds support for filter operators in SearchKit. It does not expose an operator selector to Afform
but allows an operator to be implied through the type of field configured.
e.g. a multiselect implies the IN operator & a range select implies BETWEEN.

22 files changed:
ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php
ext/afform/admin/ang/afGuiEditor/elements/afGuiButton-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiText-menu.html
ext/afform/admin/ang/afGuiEditor/inputType/CheckBox.html
ext/afform/admin/ang/afGuiEditor/inputType/Date.html
ext/afform/admin/ang/afGuiEditor/inputType/Number.html
ext/afform/admin/ang/afGuiEditor/inputType/Radio.html
ext/afform/admin/ang/afGuiEditor/inputType/Select.html
ext/afform/core/Civi/Afform/AfformMetadataInjector.php
ext/afform/core/ang/af/afField.component.js
ext/afform/core/ang/af/fields/CheckBox.html
ext/afform/core/ang/af/fields/Date.html
ext/afform/core/ang/af/fields/Number.html
ext/afform/core/ang/af/fields/Radio.html
ext/afform/core/ang/af/fields/RichTextEditor.html
ext/afform/core/ang/af/fields/Select.html
ext/afform/core/ang/af/fields/Text.html
ext/search/Civi/Api4/Action/SearchDisplay/Run.php

index 1b20accd47c5bfa5d00a57a755093baf8a81f2c5..2c572eb9b973ee1c7e9d906dc6aeb977ce74d510 100644 (file)
@@ -234,7 +234,8 @@ class AfformAdminMeta {
       ];
     }
 
-    $data['dateRanges'] = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
+    $dateRanges = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
+    $data['dateRanges'] = array_merge([['id' => '{}', 'label' => E::ts('Choose Date Range')]], $dateRanges);
 
     return $data;
   }
index d1069acf94e2c27547cc2fa49c377979bf4c986d..d121b827d61cd21e4d3639a92feaff48069b9c3e 100644 (file)
@@ -7,4 +7,4 @@
   </div>
 </li>
 <li role="separator" class="divider"></li>
-<li><a href ng-click="$ctrl.deleteThis()"><span class="text-danger">{{:: ts('Delete this button') }}</span></a></li>
+<li><a href ng-click="$ctrl.deleteThis()"><span class="text-danger"><i class="crm-i fa-trash"></i> {{:: ts('Delete this button') }}</span></a></li>
index 63d2ff6d946698a3eaef28051d2e8e8bfa5a01bb..9121bfeb7da758428191f2551e777f62dc440aaf 100644 (file)
@@ -33,4 +33,4 @@
 <li><af-gui-menu-item-border node="$ctrl.node"></af-gui-menu-item-border></li>
 <li><af-gui-menu-item-background node="$ctrl.node"></af-gui-menu-item-background></li>
 <li role="separator" class="divider"></li>
-<li><a href ng-click="$ctrl.deleteThis()"><span class="text-danger">{{ !block ? ts('Delete this container') : ts('Delete this block') }}</span></a></li>
+<li><a href ng-click="$ctrl.deleteThis()"><span class="text-danger"><i class="crm-i fa-trash"></i> {{ !block ? ts('Delete this container') : ts('Delete this block') }}</span></a></li>
index 853be65e928b06f79fd81975b3a0e786513e3d30..75652a8bc6f3db774da7e7a56d8e72125dbf8d49 100644 (file)
@@ -8,28 +8,41 @@
 </li>
 <li>
   <a href ng-click="toggleRequired(); $event.stopPropagation();" title="{{:: ts('Require this field') }}">
-    <i class="crm-i" ng-class="{'fa-square-o': !getProp('required'), 'fa-check-square-o': getProp('required')}"></i>
+    <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') }}">
-    <i class="crm-i" ng-class="{'fa-square-o': $ctrl.node.defn.title === false, 'fa-check-square-o': $ctrl.node.defn.title !== false}"></i>
+    <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') }}">
-    <i class="crm-i" ng-class="{'fa-square-o': !propIsset('help_pre'), 'fa-check-square-o': propIsset('help_pre')}"></i>
+    <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') }}">
-    <i class="crm-i" ng-class="{'fa-square-o': !propIsset('help_post'), 'fa-check-square-o': propIsset('help_post')}"></i>
+    <i class="crm-i fa-{{ propIsset('help_post') ? 'check-' : '' }}square-o" ></i>
     {{:: ts('Post help text') }}
   </a>
 </li>
+<li role="separator" class="divider" ng-if="$ctrl.canBeRange() || $ctrl.canBeMultiple()"></li>
+<li ng-if="$ctrl.canBeMultiple()" ng-click="$event.stopPropagation()">
+  <a href ng-click="toggleMultiple()" title="{{:: ts('Search multiple values') }}">
+    <i class="crm-i fa-{{ !$ctrl.node.defn.input_attrs.multiple ? '' : 'check-' }}square-o"></i>
+    {{:: ts('Multi-Select') }}
+  </a>
+</li>
+<li ng-if="$ctrl.canBeRange()" ng-click="$event.stopPropagation()">
+  <a href ng-click="toggleSearchRange()" title="{{:: ts('Search between low & high values') }}">
+    <i class="crm-i fa-{{ !$ctrl.node.defn.search_range ? '' : 'check-' }}square-o"></i>
+    {{:: ts('Search by range') }}
+  </a>
+</li>
 <li role="separator" class="divider" ng-if="hasOptions()"></li>
 <li ng-if="hasOptions()" ng-click="$event.stopPropagation()">
   <a href ng-click="resetOptions()" title="{{:: ts('Reset the option list for this field') }}">
@@ -46,6 +59,6 @@
 <li role="separator" class="divider"></li>
 <li>
   <a href ng-click="$ctrl.deleteThis()" title="{{:: ts('Remove field from form') }}">
-    <span class="text-danger">{{:: ts('Delete this field') }}</span>
+    <span class="text-danger"><i class="crm-i fa-trash"></i> {{:: ts('Delete this field') }}</span>
   </a>
 </li>
index 95d71176f01c8342499a2c59bb77f3715a34dc5e..cecb660b0d49f91ce81750f621af829139b13c02 100644 (file)
       var yesNo = [
         {id: '1', label: ts('Yes')},
         {id: '0', label: ts('No')}
-      ];
+      ],
+        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);
 
       this.$onInit = function() {
         $scope.meta = afGui.meta;
         return !_.isEmpty($scope.meta.searchDisplays);
       };
 
+      this.canBeRange = function() {
+        return this.isSearch() &&
+          !ctrl.getDefn().input_attrs.multiple &&
+          _.includes(['Date', 'Timestamp', 'Integer', 'Float'], ctrl.getDefn().data_type) &&
+          _.includes(['Date', 'Number', 'Select'], $scope.getProp('input_type'));
+      };
+
+      this.canBeMultiple = function() {
+        return this.isSearch() &&
+          !_.includes(['Date', 'Timestamp'], ctrl.getDefn().data_type) &&
+          $scope.getProp('input_type') === 'Select';
+      };
+
+      this.getRangeElements = function(type) {
+        if (!$scope.getProp('search_range') || (type === 'Select' && ctrl.getDefn().input_type === 'Date')) {
+          return singleElement;
+        }
+        return type === 'Date' ? dateRangeElements : rangeElements;
+      };
+
       // Returns the original field definition from metadata
       this.getDefn = function() {
         var defn = afGui.getField(ctrl.container.getFieldEntityType(ctrl.node.name), ctrl.node.name);
-        return defn ||  {
+        defn = defn || {
           label: ts('Untitled'),
-          requred: false,
-          input_attrs: []
+          required: false
         };
+        defn.input_attrs = _.isEmpty(defn.input_attrs) ? {} : defn.input_attrs;
+        return defn;
       };
 
       $scope.getOriginalLabel = function() {
         return _.contains(['CheckBox', 'Radio', 'Select'], inputType) && !(inputType === 'CheckBox' && !ctrl.getDefn().options);
       };
 
-      $scope.getOptions = this.getOptions = function() {
+      this.getOptions = function() {
         if (ctrl.node.defn && ctrl.node.defn.options) {
           return ctrl.node.defn.options;
         }
         if (_.includes(['Date', 'Timestamp'], $scope.getProp('data_type'))) {
-          return CRM.afGuiEditor.dateRanges;
+          return $scope.getProp('search_range') ? relativeDatesWithPickRange : relativeDatesWithoutPickRange;
         }
         return ctrl.getDefn().options || ($scope.getProp('input_type') === 'CheckBox' ? null : yesNo);
       };
         }
       };
 
+      $scope.toggleMultiple = function() {
+        var newVal = getSet('input_attrs.multiple', !getSet('input_attrs.multiple'));
+        if (newVal && getSet('search_range')) {
+          getSet('search_range', false);
+        }
+      };
+
+      $scope.toggleSearchRange = function() {
+        var newVal = getSet('search_range', !getSet('search_range'));
+        if (newVal && getSet('input_attrs.multiple')) {
+          getSet('input_attrs.multiple', false);
+        }
+      };
+
       $scope.toggleRequired = function() {
         getSet('required', !getSet('required'));
         return false;
             delete localDefn[item];
             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;
+          }
           return val;
         }
         return $scope.getProp(propName);
         return container;
       }
 
+      // Returns true only if value is [], {}, '', null, or undefined.
+      function isEmpty(val) {
+        return typeof val !== 'boolean' && typeof val !== 'number' && _.isEmpty(val);
+      }
+
       // Recursively clears out empty arrays and objects
       function clearOut(parent, path) {
         var item;
-        while (path.length && _.every(drillDown(parent, path), _.isEmpty)) {
+        while (path.length && _.every(drillDown(parent, path), isEmpty)) {
           item = path.pop();
           delete drillDown(parent, path)[item];
         }
index e4e5c71f06ba3af7300919268c63bfdff290aa27..6537d36de844649ec08504589ca1cbf27428b694 100644 (file)
@@ -6,5 +6,5 @@
 <li><af-gui-menu-item-background node="$ctrl.node"></af-gui-menu-item-background></li>
 <li role="separator" class="divider"></li>
 <li>
-  <a href ng-click="$ctrl.deleteThis()"><span class="text-danger">{{:: ts('Delete this content') }}</span></a>
+  <a href ng-click="$ctrl.deleteThis()"><span class="text-danger"><i class="crm-i fa-trash"></i> {{:: ts('Delete this content') }}</span></a>
 </li>
index eaef3b97811d3dd3cb60a2ef57ebe1f1574aa931..2678f5abee4fcbffc311c0d11a5ab17d8068c82f 100644 (file)
@@ -25,5 +25,5 @@
 </li>
 <li role="separator" class="divider"></li>
 <li>
-  <a href ng-click="$ctrl.deleteThis()"><span class="text-danger">{{:: ts('Delete this text') }}</span></a>
+  <a href ng-click="$ctrl.deleteThis()"><span class="text-danger"><i class="crm-i fa-trash"></i> {{:: ts('Delete this text') }}</span></a>
 </li>
index 08baba1adec8c55bf39c690c95700fa55626db5b..5bc73d186c1e395f3e3f09d2b78b470d56017bc8 100644 (file)
@@ -1,7 +1,7 @@
-<ul class="crm-checkbox-list" id="{{ fieldId }}" ng-if="getOptions()">
-  <li ng-repeat="opt in getOptions()" >
-    <input type="checkbox" disabled />
+<ul class="crm-checkbox-list" ng-if="$ctrl.getOptions()">
+  <li ng-repeat="opt in $ctrl.getOptions()" >
+    <input type="checkbox" disabled >
     <label>{{ opt.label }}</label>
   </li>
 </ul>
-<input type="checkbox" disabled ng-if="!getOptions()" />
+<input type="checkbox" disabled ng-if="!$ctrl.getOptions()" >
index 43d00a6f0d4b83d6f9deb975c8afb62ed449cb38..0772a24ed12ecc42b2eb66dbd8233a67bf478808 100644 (file)
@@ -1,5 +1,8 @@
 <div class="form-inline">
-  <input autocomplete="off" class="form-control crm-form-date crm-placeholder-icon" placeholder="&#xF073" ng-model="getSet('input_attrs.placeholder')" ng-model-options="{getterSetter: true}" 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')" ng-model-options="{getterSetter: true}" type="text" title="{{:: ts('Click to add placeholder text') }}" />
+  <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') }}" />
+    <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') }}" />
+  </div>
 </div>
index f65172c3d0e5cd070896749bb3d6241ec814c724..cf7ce8b934bb7dd16acd2a58db7403bafb993d24 100644 (file)
@@ -1 +1,6 @@
-<input autocomplete="off" class="form-control" ng-model="getSet('input_attrs.placeholder')" ng-model-options="{getterSetter: true}" type="text" title="{{:: ts('Click to add placeholder text') }}" />
+<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') }}"/>
+  </div>
+</div>
index d1c47b073e2a8d3f485d3f24f840ad8ce2b72d56..729d0ea22574a4282c2d3b9005d563a093eb8e54 100644 (file)
@@ -1,5 +1,5 @@
 <div class="form-inline">
-  <label ng-repeat="opt in getOptions()" class="radio" >
+  <label ng-repeat="opt in $ctrl.getOptions()" class="radio" >
     <input class="crm-form-radio" type="radio" disabled />
     {{ opt.label }}
   </label>
index 127bbb03c72f23e29c2c2fabc6b26b86665bc056..542a304c487f0014f6933a8ac39e545aeccb3b8a 100644 (file)
@@ -1,13 +1,17 @@
 <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" />
-    <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">
-        <li ng-repeat="opt in getOptions()" >
-          <a href>{{ opt.label }}</a>
-        </li>
-      </ul>
+  <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" />
+      <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">
+          <li ng-repeat="opt in $ctrl.getOptions()" >
+            <a href>{{ opt.label }}</a>
+          </li>
+        </ul>
+      </div>
     </div>
   </div>
+  <div ng-if="getProp('search_range') && $ctrl.getDefn().input_type === 'Date'" class="form-group" ng-include="'~/afGuiEditor/inputType/Date.html'"></div>
 </div>
index f24b1bc16c4976b7986b932b761a700fe63566f2..10b965484dc58dfec078ee58ba6b863347751361 100644 (file)
@@ -104,21 +104,38 @@ class AfformMetadataInjector {
     // Merge field definition data with whatever's already in the markup.
     $deep = ['input_attrs'];
     if ($fieldInfo) {
+      // Defaults for attributes not in spec
+      $fieldInfo['search_range'] = FALSE;
+
       $existingFieldDefn = trim(pq($afField)->attr('defn') ?: '');
       if ($existingFieldDefn && $existingFieldDefn[0] != '{') {
         // If it's not an object, don't mess with it.
         return;
       }
-      // Default placeholder for select inputs
-      if ($fieldInfo['input_type'] === 'Select') {
-        $fieldInfo['input_attrs'] = ($fieldInfo['input_attrs'] ?? []) + ['placeholder' => E::ts('Select')];
-      }
 
+      // Get field defn from afform markup
       $fieldDefn = $existingFieldDefn ? \CRM_Utils_JS::getRawProps($existingFieldDefn) : [];
+      // This is the input type set on the form (may be different from the default input type in the field spec)
+      $inputType = !empty($fieldDefn['input_type']) ? \CRM_Utils_JS::decode($fieldDefn['input_type']) : $fieldInfo['input_type'];
+      // 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']);
 
-      if ('Date' === $fieldInfo['input_type'] && !empty($fieldDefn['input_type']) && \CRM_Utils_JS::decode($fieldDefn['input_type']) === 'Select') {
+      // Default placeholder for select inputs
+      if ($inputType === 'Select') {
         $fieldInfo['input_attrs']['placeholder'] = E::ts('Select');
-        $fieldInfo['options'] = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
+      }
+
+      if ($fieldInfo['input_type'] === 'Date') {
+        // This flag gets used by the afField controller
+        $fieldDefn['is_date'] = TRUE;
+        // For date fields that have been converted to Select
+        if ($inputType === 'Select') {
+          $dateOptions = \CRM_Utils_Array::makeNonAssociative(\CRM_Core_OptionGroup::values('relative_date_filters'), 'id', 'label');
+          if ($isSearchRange) {
+            $dateOptions = array_merge([['id' => '{}', 'label' => E::ts('Choose Date Range')]], $dateOptions);
+          }
+          $fieldInfo['options'] = $dateOptions;
+        }
       }
 
       foreach ($fieldInfo as $name => $prop) {
index 4264f45b661cc18cf6fbfd695172a265293040ee..8a18811f21e745b7acae9d4b0729ce2ecaf6a2db 100644 (file)
@@ -19,6 +19,9 @@
         // Only used for is_primary radio button
         noOptions = [{id: true, label: ''}];
 
+      // Attributes for each of the low & high date fields when using search_range
+      this.inputAttrs = [];
+
       this.$onInit = function() {
         var closestController = $($element).closest('[af-fieldset],[af-join],[af-repeat-item]');
         $scope.dataProvider = closestController.is('[af-repeat-item]') ? ctrl.afRepeatItem : ctrl.afJoin || ctrl.afFieldset;
 
         $element.addClass('af-field-type-' + _.kebabCase(ctrl.defn.input_type));
 
+
+        if (ctrl.defn.search_range) {
+          // Initialize value as object unless using relative date select
+          var initialVal = $scope.dataProvider.getFieldData()[ctrl.fieldName];
+          if (!_.isArray($scope.dataProvider.getFieldData()[ctrl.fieldName]) &&
+            (ctrl.defn.input_type !== 'Select' || !ctrl.defn.is_date || initialVal !== '{}')
+          ) {
+            $scope.dataProvider.getFieldData()[ctrl.fieldName] = {};
+          }
+          // Initialize inputAttrs (only used for datePickers at the moment)
+          if (ctrl.defn.is_date) {
+            this.inputAttrs.push(ctrl.defn.input_attrs || {});
+            for (var i = 1; i <= 2; ++i) {
+              var attrs = _.cloneDeep(ctrl.defn.input_attrs || {});
+              attrs.placeholder = attrs['placeholder' + i];
+              attrs.timePlaceholder = attrs['timePlaceholder' + i];
+              ctrl.inputAttrs.push(attrs);
+            }
+          }
+        }
+
         // 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) {
         };
       };
 
+      // Getter/Setter function for fields of type select.
+      $scope.getSetSelect = function(val) {
+        var currentVal = $scope.dataProvider.getFieldData()[ctrl.fieldName];
+        // Setter
+        if (arguments.length) {
+          if (ctrl.defn.is_date) {
+            // The '{}' string is a placeholder for "choose date range"
+            if (val === '{}') {
+              val = !_.isPlainObject(currentVal) ? {} : currentVal;
+            }
+          }
+          // If search_range, this select is the "low" value (the high value uses ng-model without a getterSetter fn)
+          else if (ctrl.defn.search_range) {
+            return ($scope.dataProvider.getFieldData()[ctrl.fieldName]['>='] = val);
+          }
+          // A multi-select needs to split string value into an array
+          if (ctrl.defn.input_attrs && ctrl.defn.input_attrs.multiple) {
+            val = val ? val.split(',') : [];
+          }
+          return ($scope.dataProvider.getFieldData()[ctrl.fieldName] = val);
+        }
+        // Getter
+        if (_.isArray(currentVal)) {
+          return currentVal.join(',');
+        }
+        if (ctrl.defn.is_date) {
+          return _.isPlainObject(currentVal) ? '{}' : currentVal;
+        }
+        // If search_range, this select is the "low" value (the high value uses ng-model without a getterSetter fn)
+        else if (ctrl.defn.search_range) {
+          return currentVal['>='];
+        }
+        return currentVal;
+      };
+
     }
   });
 })(angular, CRM.$, CRM._);
index 8b1f8b21579f6312c610dfcfbbc4e8dee2754d4c..f2edac2a4e5f552040899bac96f73028e367b690 100644 (file)
@@ -1,7 +1,7 @@
 <ul class="crm-checkbox-list" id="{{ fieldId }}" ng-if="$ctrl.defn.options">
   <li ng-repeat="opt in $ctrl.defn.options track by opt.id" >
     <input type="checkbox" checklist-model="dataProvider.getFieldData()[$ctrl.fieldName]" id="{{ fieldId + opt.id }}" checklist-value="opt.id" />
-    <label for="{{ fieldId + opt.id }}">{{ opt.label }}</label>
+    <label for="{{ fieldId + opt.id }}">{{:: opt.label }}</label>
   </li>
 </ul>
-<input type="checkbox" ng-if="!$ctrl.defn.options" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" />
+<input type="checkbox" ng-if="!$ctrl.defn.options" id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" />
index ecd7fcdc6f118d20e04dffd5f77ddb94e0b36848..2707b64a4aa1954c6611542154ee219063c390cd 100644 (file)
@@ -1 +1,6 @@
-<input class="form-control" crm-ui-datepicker="$ctrl.defn.input_attrs" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" />
+<input ng-if=":: !$ctrl.defn.search_range" class="form-control" crm-ui-datepicker=":: $ctrl.defn.input_attrs" id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" />
+<div ng-if=":: $ctrl.defn.search_range" class="form-inline">
+  <input class="form-control" crm-ui-datepicker=":: $ctrl.inputAttrs[1]" id="{{:: fieldId }}1" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]['>=']" />
+  <span class="af-field-range-sep">-</span>
+  <input class="form-control" crm-ui-datepicker=":: $ctrl.inputAttrs[2]" id="{{:: fieldId }}2" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]['<=']" />
+</div>
index f38d4d0dd9a76805e8a1a6cc9f85394a0a0cda0f..2675bcc8652bb63bda42c5bfa1b60f2bd1571d3c 100644 (file)
@@ -1 +1,6 @@
-<input class="form-control" type="number" id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" placeholder="{{ $ctrl.defn.input_attrs.placeholder }}" />
+<input ng-if=":: !$ctrl.defn.search_range" class="form-control" type="number" id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" placeholder="{{:: $ctrl.defn.input_attrs.placeholder }}" >
+<div ng-if=":: $ctrl.defn.search_range" class="form-inline">
+  <input class="form-control" type="number" id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]['>=']" placeholder="{{:: $ctrl.defn.input_attrs.placeholder }}" >
+  <span class="af-field-range-sep">-</span>
+  <input class="form-control" type="number" id="{{:: fieldId }}2" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]['<=']" placeholder="{{:: $ctrl.defn.input_attrs.placeholder2 }}" >
+</div>
index ea17957eb2fccc91096ca9d17909390b49054afa..225250918a308ed990a4c43be6cc4b9fbde3bfa3 100644 (file)
@@ -1,4 +1,4 @@
 <label ng-repeat="opt in getOptions() track by opt.id" >
   <input class="crm-form-radio" type="radio" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" ng-value="opt.id" />
-  {{ opt.label }}
+  {{:: opt.label }}
 </label>
index 4b5e52952a97359a44a2a294a1e8a62a6b31f3bb..af4d4aabaa86befbbe7c96fda39bba6550618aba 100644 (file)
@@ -1 +1 @@
-<textarea crm-ui-richtext id="{{ fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" ></textarea>
+<textarea crm-ui-richtext id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" ></textarea>
index d1763602ff3f5b056513ba6f8a9383e98057b669..c7edf4850a46ceb2a65626a4defb73aaf864ab40 100644 (file)
@@ -1 +1,5 @@
-<input class="form-control" crm-ui-select="{data: select2Options, multiple: $ctrl.defn.input_attrs.multiple, placeholder: $ctrl.defn.input_attrs.placeholder}" id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" />
+<div class="{{:: $ctrl.defn.search_range ? 'form-inline' : 'form-group' }}">
+  <input class="form-control" id="{{:: fieldId }}" crm-ui-select="{data: select2Options, multiple: $ctrl.defn.input_attrs.multiple, placeholder: $ctrl.defn.input_attrs.placeholder}" ng-model="getSetSelect" ng-model-options="{getterSetter: true}" >
+  <input class="form-control" ng-if=":: $ctrl.defn.search_range && !$ctrl.defn.is_date" id="{{:: fieldId }}2" crm-ui-select="{data: select2Options, placeholder: $ctrl.defn.input_attrs.placeholder2 || $ctrl.defn.input_attrs.placeholder}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]['<=']" >
+  <div ng-if="$ctrl.defn.search_range && $ctrl.defn.is_date && getSetSelect() === '{}'" class="form-group" ng-include="'~/af/fields/Date.html'"></div>
+</div>
index 71be54197e4126fb15762033340db496818dc912..7b3982482ce238c8881760af1ce755df6aa7da18 100644 (file)
@@ -1 +1 @@
-<input class="form-control" type="text" id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" placeholder="{{ $ctrl.defn.input_attrs.placeholder }}" />
+<input class="form-control" type="text" id="{{:: fieldId }}" ng-model="dataProvider.getFieldData()[$ctrl.fieldName]" placeholder="{{:: $ctrl.defn.input_attrs.placeholder }}" >
index 7ad2c72c9c1f69848639b5f897c0adbd84d221ab..1d736be0c85027d114999df4873eea21ad1ed11f 100644 (file)
@@ -5,6 +5,7 @@ namespace Civi\Api4\Action\SearchDisplay;
 use Civi\API\Exception\UnauthorizedException;
 use Civi\Api4\SavedSearch;
 use Civi\Api4\SearchDisplay;
+use Civi\Api4\Utils\CoreUtil;
 
 /**
  * Load the results for rendering a SearchDisplay.
@@ -150,14 +151,25 @@ class Run extends \Civi\Api4\Generic\AbstractAction {
     $result->exchangeArray($apiResult->getArrayCopy());
   }
 
+  /**
+   * Checks if a filter contains a non-empty value
+   *
+   * "Empty" search values are [], '', and NULL.
+   * Also recursively checks arrays to ensure they contain at least one non-empty value.
+   *
+   * @param $value
+   * @return bool
+   */
+  private function hasValue($value) {
+    return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue']));
+  }
+
   /**
    * Applies supplied filters to the where clause
    */
   private function applyFilters() {
     // Ignore empty strings
-    $filters = array_filter($this->filters, function($value) {
-      return isset($value) && (strlen($value) || !is_string($value));
-    });
+    $filters = array_filter($this->filters, [$this, 'hasValue']);
     if (!$filters) {
       return;
     }
@@ -184,37 +196,42 @@ class Run extends \Civi\Api4\Generic\AbstractAction {
 
   /**
    * @param string $fieldName
-   * @param string $value
+   * @param mixed $value
    */
-  private function applyFilter(string $fieldName, string $value) {
+  private function applyFilter(string $fieldName, $value) {
+    // Global setting determines if % wildcard should be added to both sides (default) or only the end of a search string
+    $prefixWithWildcard = \Civi::settings()->get('includeWildCardInName');
+
     $field = $this->getField($fieldName);
+    // If field is not found it must be an aggregated column & belongs in the HAVING clause.
+    $clause = $field ? 'where' : 'having';
 
-    // Global setting determines if % wildcard should be added to both sides (default) or only the end of the search term
-    $prefixWithWildcard = \Civi::settings()->get('includeWildCardInName');
+    $dataType = $field['data_type'] ?? NULL;
 
-    // Not a real field. It must be an aggregated column. Add to HAVING clause.
-    if (!$field) {
-      if ($prefixWithWildcard) {
-        $this->savedSearch['api_params']['having'][] = [$fieldName, 'CONTAINS', $value];
+    // Array is either associative `OP => VAL` or sequential `IN (...)`
+    if (is_array($value)) {
+      $value = array_filter($value, [$this, 'hasValue']);
+      // Use IN if array does not contain operators as keys
+      if (array_diff_key($value, array_flip(CoreUtil::getOperators()))) {
+        $this->savedSearch['api_params'][$clause][] = [$fieldName, 'IN', $value];
       }
       else {
-        $this->savedSearch['api_params']['having'][] = [$fieldName, 'LIKE', $value . '%'];
+        foreach ($value as $operator => $val) {
+          $this->savedSearch['api_params'][$clause][] = [$fieldName, $operator, $val];
+        }
       }
-      return;
     }
-
-    $dataType = $field['data_type'];
-    if (!empty($field['serialize'])) {
-      $this->savedSearch['api_params']['where'][] = [$fieldName, 'CONTAINS', $value];
+    elseif (!empty($field['serialize'])) {
+      $this->savedSearch['api_params'][$clause][] = [$fieldName, 'CONTAINS', $value];
     }
     elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
-      $this->savedSearch['api_params']['where'][] = [$fieldName, '=', $value];
+      $this->savedSearch['api_params'][$clause][] = [$fieldName, '=', $value];
     }
     elseif ($prefixWithWildcard) {
-      $this->savedSearch['api_params']['where'][] = [$fieldName, 'CONTAINS', $value];
+      $this->savedSearch['api_params'][$clause][] = [$fieldName, 'CONTAINS', $value];
     }
     else {
-      $this->savedSearch['api_params']['where'][] = [$fieldName, 'LIKE', $value . '%'];
+      $this->savedSearch['api_params'][$clause][] = [$fieldName, 'LIKE', $value . '%'];
     }
   }