Skip to content

Commit

Permalink
Merge pull request #30450 from colemanw/afformConditionalsPlusPlus
Browse files Browse the repository at this point in the history
dev/core#5105 Afform - Support more operators in conditional rules
  • Loading branch information
colemanw authored Jun 19, 2024
2 parents 613773b + e0b9cf7 commit 7dc0a35
Show file tree
Hide file tree
Showing 4 changed files with 132 additions and 49 deletions.
113 changes: 74 additions & 39 deletions ext/afform/admin/ang/afGuiEditor/afGuiCondition.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,33 @@
},
templateUrl: '~/afGuiEditor/afGuiCondition.html',
controller: function ($scope) {
var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'),
let ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'),
ctrl = this;
let conditionValue;
this.operators = [
{
"key": "==",
"value": "=",
},
{
"key": "!=",
"value": "≠",
},
{
"key": ">",
"value": ">",
},
{
"key": "<",
"value": "<",
},
{
"key": ">=",
"value": "≥",
},
{
"key": "<=",
"value": "≤",
}
];
let operatorCache = {};

const allOperators= {
'=': '=',
'!=': '≠',
'>': '>',
'<': '<',
'>=': '≥',
'<=': '≤',
'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'),
'IS EMPTY': ts('Is Empty'),
'IS NOT EMPTY': ts('Not Empty'),
};

this.$onInit = function() {
// Update legacy operator '==' to the new preferred '='
if (getOperator() === '==') {
setOperator('=');
}
$scope.$watch('$ctrl.field', updateOperators);
};

Expand All @@ -52,7 +48,7 @@
function setOperator(op) {
if (op !== getOperator()) {
ctrl.clause[ctrl.offset] = op;
ctrl.changeClauseOperator();
updateOperators();
}
}

Expand All @@ -70,14 +66,6 @@
ctrl.clause[1 + ctrl.offset] = JSON.stringify(val);
}

// Getter/setter for use with ng-model
this.getSetOperator = function(op) {
if (arguments.length) {
setOperator(op);
}
return getOperator();
};

// Getter/setter for use with ng-model
this.getSetValue = function(val) {
if (arguments.length) {
Expand All @@ -88,17 +76,64 @@

// Return a list of operators allowed for the current field
this.getOperators = function() {
return ctrl.operators;
var field = ctrl.field || {},
allowedOps = field.operators;
if (!allowedOps && field.data_type === 'Boolean') {
allowedOps = ['=', '!=', 'IS EMPTY', 'IS NOT EMPTY'];
}
if (!allowedOps && _.includes(['Boolean', 'Float', 'Date'], field.data_type)) {
allowedOps = ['=', '!=', '<', '>', '<=', '>=', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'IS EMPTY', 'IS NOT EMPTY'];
}
if (!allowedOps && (field.data_type === 'Array' || field.serialize)) {
allowedOps = ['CONTAINS', 'NOT CONTAINS', 'IS EMPTY', 'IS NOT EMPTY'];
}
if (!allowedOps) {
return allOperators;
}
var opKey = allowedOps.join();
if (!operatorCache[opKey]) {
operatorCache[opKey] = filterObjectByKeys(allOperators, allowedOps);
}
return operatorCache[opKey];
};

function filterObjectByKeys(obj, whitelist) {
return Object.keys(obj)
.filter(key => whitelist.includes(key))
.reduce((filteredObj, key) => {
filteredObj[key] = obj[key];
return filteredObj;
}, {});
}

// Ensures clause is using an operator that is allowed for the field
function updateOperators() {
if ((!getOperator() || !_.includes(_.pluck(ctrl.getOperators(), 'key'), getOperator()))) {
setOperator(ctrl.getOperators()[0].key);
if (!getOperator() || !(getOperator() in ctrl.getOperators())) {
setOperator(Object.keys(ctrl.getOperators())[0]);
}
}

// Returns false for 'IS NULL', 'IS EMPTY', etc. true otherwise.
this.operatorTakesInput = function() {
return getOperator().indexOf('IS ') !== 0;
};

this.changeClauseOperator = function() {
// Add/remove value depending on whether operator allows for one
if (!ctrl.operatorTakesInput()) {
ctrl.clause.length = ctrl.offset + 1;
} else {
if (ctrl.clause.length === ctrl.offset + 1) {
ctrl.clause.push('');
}
// Change multi/single value to/from an array
var shouldBeArray = _.includes(['IN', 'NOT IN'], getOperator());
if (!_.isArray(getValue()) && shouldBeArray) {
setValue([]);
} else if (_.isArray(getValue()) && !shouldBeArray) {
setValue('');
}
}
};

}
Expand Down
8 changes: 6 additions & 2 deletions ext/afform/admin/ang/afGuiEditor/afGuiCondition.html
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
<select class="form-control api4-operator" ng-model="$ctrl.getSetOperator" ng-if="$ctrl.getOperators().length > 1" ng-model-options="{getterSetter: true}" ng-options="o.key as o.value for o in $ctrl.getOperators()" ng-change="$ctrl.changeClauseOperator()" ></select>
<input af-gui-field-value="$ctrl.field" ng-model="$ctrl.getSetValue" ng-model-options="{getterSetter: true}" class="form-control"></input>
<select class="form-control api4-operator" ng-model="$ctrl.clause[$ctrl.offset]" ng-change="$ctrl.changeClauseOperator()" >
<option ng-repeat="(name, label) in $ctrl.getOperators()" value="{{:: name }}">{{:: label }}</option>
</select>
<div class="form-group" ng-if="$ctrl.operatorTakesInput()">
<input af-gui-field-value="$ctrl.field" ng-model="$ctrl.getSetValue" op="$ctrl.clause[$ctrl.offset]" ng-model-options="{getterSetter: true}" class="form-control"></input>
</div>
14 changes: 10 additions & 4 deletions ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
angular.module('afGuiEditor').directive('afGuiFieldValue', function(afGui) {
return {
bindToController: {
op: '<?',
field: '<afGuiFieldValue'
},
require: {
Expand All @@ -23,13 +24,18 @@
$el = $($element),
inputType = field.input_type,
dataType = field.data_type;
multi = field.serialize || dataType === 'Array';
$el.crmAutocomplete('destroy').crmDatepicker('destroy');
// Allow input_type to override dataType
if (inputType) {

// Decide whether the input should be multivalued
if (ctrl.op) {
multi = ['IN', 'NOT IN'].includes(ctrl.op);
} else if (inputType) {
multi = (dataType !== 'Boolean' &&
(inputType === 'CheckBox' || (field.input_attrs && field.input_attrs.multiple)));
} else {
multi = field.serialize || dataType === 'Array';
}
$el.crmAutocomplete('destroy').crmDatepicker('destroy');
// Allow input_type to override dataType
if (inputType === 'Date') {
$el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false});
}
Expand Down
46 changes: 42 additions & 4 deletions ext/afform/core/ang/af/afForm.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,14 @@
};

function compareConditions(val1, op, val2) {
const yes = (op !== '!=' && !op.includes('NOT '));

switch (op) {
case '=':
case '==':
return angular.equals(val1, val2);

case '!=':
return !angular.equals(val1, val2);
// Legacy operator, changed to '=', but may still exist on older forms.
case '==':
return angular.equals(val1, val2) === yes;

case '>':
return val1 > val2;
Expand All @@ -165,9 +166,46 @@

case '<=':
return val1 <= val2;

case 'IS EMPTY':
return !val1;

case 'IS NOT EMPTY':
return !!val1;

case 'CONTAINS':
case 'NOT CONTAINS':
if (typeof val1 === 'string' || Array.isArray(val1)) {
return val1.includes(val2);
}
return !yes;

case 'IN':
case 'NOT IN':
if (Array.isArray(val2)) {
return val2.includes(val1);
}
return !yes;

case 'LIKE':
case 'NOT LIKE':
if (typeof val1 === 'string' && typeof val2 === 'string') {
return likeCompare(val1, val2) === yes;
}
return !yes;
}
}

function likeCompare(str, pattern) {
// Escape regex special characters in the pattern, except for % and _
const regexPattern = pattern
.replace(/([.+?^=!:${}()|\[\]\/\\])/g, "\\$1")
.replace(/%/g, '.*') // Convert % to .*
.replace(/_/g, '.'); // Convert _ to .
const regex = new RegExp(`^${regexPattern}$`, 'i');
return regex.test(str);
}

// Called after form is submitted and files are uploaded
function postProcess() {
var metaData = ctrl.getFormMeta(),
Expand Down

0 comments on commit 7dc0a35

Please sign in to comment.