Skip to content

Commit

Permalink
Afform - Support search-by-range and search-by-multiple-values
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
colemanw committed Mar 31, 2021
1 parent 0ef6eaa commit e5ef054
Show file tree
Hide file tree
Showing 22 changed files with 249 additions and 69 deletions.
3 changes: 2 additions & 1 deletion ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ public static function getGuiSettings() {
];
}

$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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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>
23 changes: 18 additions & 5 deletions ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -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') }}">
Expand All @@ -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>
64 changes: 57 additions & 7 deletions ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@
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;
Expand All @@ -30,14 +36,35 @@
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() {
Expand All @@ -52,12 +79,12 @@
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);
};
Expand Down Expand Up @@ -122,6 +149,20 @@
}
};

$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;
Expand Down Expand Up @@ -151,6 +192,10 @@
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);
Expand All @@ -171,10 +216,15 @@
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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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>
8 changes: 4 additions & 4 deletions ext/afform/admin/ang/afGuiEditor/inputType/CheckBox.html
Original file line number Diff line number Diff line change
@@ -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()" >
9 changes: 6 additions & 3 deletions ext/afform/admin/ang/afGuiEditor/inputType/Date.html
Original file line number Diff line number Diff line change
@@ -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>
7 changes: 6 additions & 1 deletion ext/afform/admin/ang/afGuiEditor/inputType/Number.html
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 1 addition & 1 deletion ext/afform/admin/ang/afGuiEditor/inputType/Radio.html
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
22 changes: 13 additions & 9 deletions ext/afform/admin/ang/afGuiEditor/inputType/Select.html
Original file line number Diff line number Diff line change
@@ -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>
29 changes: 23 additions & 6 deletions ext/afform/core/Civi/Afform/AfformMetadataInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,38 @@ private static function fillFieldMetadata($entityName, $action, \DOMElement $afF
// 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) {
Expand Down
56 changes: 56 additions & 0 deletions ext/afform/core/ang/af/afField.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,37 @@
// 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;
$scope.fieldId = ctrl.fieldName + '-' + id++;

$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) {
Expand Down Expand Up @@ -76,6 +100,38 @@
};
};

// 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 search_range, this select is the "low" value (the high value uses ng-model without a getterSetter fn)
if (ctrl.defn.search_range) {
return currentVal['>='];
}
return ctrl.defn.is_date && _.isPlainObject(currentVal) ? '{}' : currentVal;
};

}
});
})(angular, CRM.$, CRM._);
Loading

0 comments on commit e5ef054

Please sign in to comment.