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.
];
}
- $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;
}
</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>
<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>
</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') }}">
<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>
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];
}
<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>
</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>
-<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()" >
<div class="form-inline">
- <input autocomplete="off" class="form-control crm-form-date crm-placeholder-icon" placeholder="" 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="" 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="" 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="" 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>
-<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>
<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>
<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>
// 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) {
// 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._);
<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]" />
-<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>
-<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>
<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>
-<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>
-<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>
-<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 }}" >
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.
$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;
}
/**
* @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 . '%'];
}
}