From 9f75d9511e959480cf54ebba6de42ef8192b86d8 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Fri, 7 Oct 2022 21:38:26 -0400 Subject: [PATCH] Afform - Support conditional logic Adds client-side support for conditional logic to hide fields and containers based on rules using and/or/not logic. --- ext/afform/admin/ang/afGuiEditor.ang.php | 2 +- ext/afform/admin/ang/afGuiEditor.css | 98 +++++++++++++++++ ext/afform/admin/ang/afGuiEditor.js | 23 ++++ .../ang/afGuiEditor/afGuiClause.component.js | 78 ++++++++++++++ .../admin/ang/afGuiEditor/afGuiClause.html | 43 ++++++++ .../afGuiEditor/afGuiCondition.component.js | 100 ++++++++++++++++++ .../admin/ang/afGuiEditor/afGuiCondition.html | 2 + .../afGuiConditionalDialog.ctrl.js | 59 +++++++++++ .../afGuiEditor/afGuiConditionalDialog.html | 7 ++ .../ang/afGuiEditor/afGuiEditor.component.js | 41 ++++++- .../afGuiEditor/afGuiFieldValue.directive.js | 2 +- .../elements/afGuiButton-menu.html | 2 + .../elements/afGuiButton.component.js | 3 + .../afGuiConditionalMenu.directive.js | 18 ++++ .../elements/afGuiConditionalMenu.html | 9 ++ .../elements/afGuiContainer-menu.html | 2 + .../afGuiEditor/elements/afGuiField-menu.html | 5 + .../elements/afGuiMarkup-menu.html | 3 + .../elements/afGuiMarkup.component.js | 3 + .../afGuiEditor/elements/afGuiText-menu.html | 2 + .../elements/afGuiText.component.js | 3 + ext/afform/core/ang/af/afForm.component.js | 30 ++++++ ext/afform/core/ang/af/afIf.directive.js | 80 ++++++++++++++ 23 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 ext/afform/admin/ang/afGuiEditor/afGuiClause.component.js create mode 100644 ext/afform/admin/ang/afGuiEditor/afGuiClause.html create mode 100644 ext/afform/admin/ang/afGuiEditor/afGuiCondition.component.js create mode 100644 ext/afform/admin/ang/afGuiEditor/afGuiCondition.html create mode 100644 ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.ctrl.js create mode 100644 ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.html create mode 100644 ext/afform/admin/ang/afGuiEditor/elements/afGuiConditionalMenu.directive.js create mode 100644 ext/afform/admin/ang/afGuiEditor/elements/afGuiConditionalMenu.html create mode 100644 ext/afform/core/ang/af/afIf.directive.js diff --git a/ext/afform/admin/ang/afGuiEditor.ang.php b/ext/afform/admin/ang/afGuiEditor.ang.php index f2953b222e..1104e92ab9 100644 --- a/ext/afform/admin/ang/afGuiEditor.ang.php +++ b/ext/afform/admin/ang/afGuiEditor.ang.php @@ -8,7 +8,7 @@ return [ ], 'css' => ['ang/afGuiEditor.css'], 'partials' => ['ang/afGuiEditor'], - 'requires' => ['crmUi', 'crmUtil', 'dialogService', 'api4', 'crmMonaco', 'ui.sortable'], + 'requires' => ['crmUi', 'crmUtil', 'crmDialog', 'api4', 'crmMonaco', 'ui.sortable'], 'settingsFactory' => ['Civi\AfformAdmin\AfformAdminMeta', 'getMetadata'], 'basePages' => [], 'exports' => [ diff --git a/ext/afform/admin/ang/afGuiEditor.css b/ext/afform/admin/ang/afGuiEditor.css index 3081736bd4..82b87a66fb 100644 --- a/ext/afform/admin/ang/afGuiEditor.css +++ b/ext/afform/admin/ang/afGuiEditor.css @@ -610,3 +610,101 @@ body.af-gui-dragging { border: 2px solid #0071bd; min-height: 30px; } + +/* Rules for Conditional dialog */ +#bootstrap-theme.af-gui-conditional-dialog fieldset { + padding: 6px; + border-top: 1px solid lightgrey; + margin-top: 10px; + margin-bottom: 10px; + position: relative; +} + +#bootstrap-theme.af-gui-conditional-dialog fieldset fieldset { + padding-top: 0; + border-left: 1px solid lightgrey; + border-right: 1px solid lightgrey; + border-bottom: 1px solid lightgrey; +} + +#bootstrap-theme.af-gui-conditional-dialog fieldset legend { + background-color: white; + font-size: 13px; + margin: 0; + width: auto; + border: 0 none; + padding: 2px 5px; + text-transform: none; +} + +#bootstrap-theme.af-gui-conditional-dialog af-gui-clause > .btn-group { + position: absolute; + right: 0; + top: 0; +} + +#bootstrap-theme.af-gui-conditional-dialog fieldset div.api4-input { + margin-bottom: 10px; +} + +#bootstrap-theme.af-gui-conditional-dialog fieldset div.api4-input.ui-sortable-helper { + background-color: rgba(255, 255, 255, .9); +} + +#bootstrap-theme.af-gui-conditional-dialog fieldset div.api4-input.ui-sortable-helper { + background-color: rgba(255, 255, 255, .9); +} + +#bootstrap-theme.af-gui-conditional-dialog .api4-clause-fieldset fieldset { + float: right; + width: calc(100% - 58px); + margin-top: -8px; +} + +#bootstrap-theme.af-gui-conditional-dialog .api4-clause-fieldset.api4-sorting fieldset .api4-clause-group-sortable { + min-height: 3.5em; +} + +#bootstrap-theme.af-gui-conditional-dialog legend[ng-click] { + cursor: pointer; +} + +#bootstrap-theme.af-gui-conditional-dialog .api4-input-group { + display: inline-block; +} + +#bootstrap-theme i.crm-i.af-gui-conditional-dialog-move-icon { + opacity: .5; +} +#bootstrap-theme .crm-draggable:hover > i.crm-i.af-gui-conditional-dialog-move-icon, +#bootstrap-theme .crm-draggable:hover > * > i.crm-i.af-gui-conditional-dialog-move-icon { + opacity: 1; +} + +#bootstrap-theme.af-gui-conditional-dialog .api4-clause-badge { + width: 55px; + display: inline-block; + cursor: move; +} +#bootstrap-theme.af-gui-conditional-dialog .api4-clause-badge .badge { + opacity: .5; + position: relative; +} +#bootstrap-theme.af-gui-conditional-dialog .api4-clause-badge .caret { + margin: 0; +} +/* Icon only shown while dragging */ +#bootstrap-theme.af-gui-conditional-dialog .api4-clause-badge .crm-i { + display: none; + padding: 0 6px; +} +#bootstrap-theme.af-gui-conditional-dialog .ui-sortable-helper .api4-clause-badge .badge span { + display: none; +} +#bootstrap-theme.af-gui-conditional-dialog .ui-sortable-helper .api4-clause-badge .crm-i { + display: inline-block; +} + +#bootstrap-theme.af-gui-conditional-dialog .api4-operator { + width: 110px; +} diff --git a/ext/afform/admin/ang/afGuiEditor.js b/ext/afform/admin/ang/afGuiEditor.js index 6b52f6228d..c93ca01cfa 100644 --- a/ext/afform/admin/ang/afGuiEditor.js +++ b/ext/afform/admin/ang/afGuiEditor.js @@ -210,6 +210,29 @@ return indexBy ? _.indexBy(items, indexBy) : items; }, + // Recursively searches part of a form and returns all elements matching predicate + // Will recurse into block elements + // Will stop recursing when it encounters an element matching 'exclude' + getFormElements: function getFormElements(collection, predicate, exclude) { + var childMatches = [], + items = _.filter(collection, predicate), + isExcluded = exclude ? (_.isFunction(exclude) ? exclude : _.matches(exclude)) : _.constant(false); + function isIncluded(item) { + return !isExcluded(item); + } + _.each(_.filter(collection, isIncluded), function(item) { + if (_.isPlainObject(item) && item['#children']) { + childMatches = getFormElements(item['#children'], predicate, exclude); + } else if (item['#tag'] && item['#tag'] in CRM.afGuiEditor.blocks) { + childMatches = getFormElements(CRM.afGuiEditor.blocks[item['#tag']].layout, predicate, exclude); + } + if (childMatches.length) { + Array.prototype.push.apply(items, childMatches); + } + }); + return items; + }, + // Applies _.remove() to an item and its children removeRecursive: function removeRecursive(collection, removeParams) { _.remove(collection, removeParams); diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiClause.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiClause.component.js new file mode 100644 index 0000000000..8424579c83 --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/afGuiClause.component.js @@ -0,0 +1,78 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('afGuiEditor').component('afGuiClause', { + bindings: { + fields: '<', + fieldDefns: '<', + clauses: '<', + skip: '<', + op: '@', + label: '@', + hideLabel: '@', + placeholder: '<', + deleteGroup: '&' + }, + templateUrl: '~/afGuiEditor/afGuiClause.html', + controller: function ($scope, $element) { + var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'), + ctrl = this, + meta = {}; + this.conjunctions = {AND: ts('And'), OR: ts('Or'), NOT: ts('Not')}; + this.sortOptions = { + axis: 'y', + connectWith: '.api4-clause-group-sortable', + containment: $element.closest('.api4-clause-fieldset'), + over: onSortOver, + start: onSort, + stop: onSort + }; + + this.$onInit = function() { + ctrl.hasParent = !!$element.attr('delete-group'); + }; + + this.getField = function(expr) { + return ctrl.fieldDefns[expr]; + }; + + this.addGroup = function(op) { + ctrl.clauses.push([op, []]); + }; + + function onSort(event, ui) { + $($element).closest('.api4-clause-fieldset').toggleClass('api4-sorting', event.type === 'sortstart'); + $('.api4-input.form-inline').css('margin-left', ''); + } + + // Indent clause while dragging between nested groups + function onSortOver(event, ui) { + var offset = 0; + if (ui.sender) { + offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left; + } + $('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset + 'px'); + } + + this.addClause = function(value) { + if (value) { + var newIndex = ctrl.clauses.length; + ctrl.clauses.push([value, '=', '""']); + } + }; + + this.deleteRow = function(index) { + ctrl.clauses.splice(index, 1); + }; + + // Remove empty values + this.changeClauseField = function(clause, index) { + if (clause[0] === '') { + ctrl.deleteRow(index); + } + }; + + } + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiClause.html b/ext/afform/admin/ang/afGuiEditor/afGuiClause.html new file mode 100644 index 0000000000..2aa86d6c09 --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/afGuiClause.html @@ -0,0 +1,43 @@ +{{ $ctrl.label || ts('%1 group', {1: $ctrl.conjunctions[$ctrl.op]}) }} +
+ +
+
+
+
+
+ + {{ $ctrl.label }} + {{ $ctrl.conjunctions[$ctrl.op] }} + + +
+
+ + +
+
+ +
+
+
+
+
+
+
+ + +
+
+ +
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiCondition.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiCondition.component.js new file mode 100644 index 0000000000..15045f5b7e --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/afGuiCondition.component.js @@ -0,0 +1,100 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('afGuiEditor').component('afGuiCondition', { + bindings: { + field: '<', + clause: '<', + format: '<', + optionKey: '<', + offset: '<' + }, + templateUrl: '~/afGuiEditor/afGuiCondition.html', + controller: function ($scope) { + var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'), + ctrl = this; + this.operators = [ + { + "key": "==", + "value": "=", + }, + { + "key": "!=", + "value": "≠", + }, + { + "key": ">", + "value": ">", + }, + { + "key": "<", + "value": "<", + }, + { + "key": ">=", + "value": "≥", + }, + { + "key": "<=", + "value": "≤", + } + ]; + + this.$onInit = function() { + $scope.$watch('$ctrl.field', updateOperators); + }; + + function getOperator() { + return ctrl.clause[ctrl.offset]; + } + + function setOperator(op) { + if (op !== getOperator()) { + ctrl.clause[ctrl.offset] = op; + ctrl.changeClauseOperator(); + } + } + + function getValue() { + return JSON.parse(ctrl.clause[1 + ctrl.offset]); + } + + function setValue(val) { + 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) { + setValue(val); + } + return getValue(); + }; + + // Return a list of operators allowed for the current field + this.getOperators = function() { + return ctrl.operators; + }; + + // 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); + } + } + + this.changeClauseOperator = function() { + }; + + } + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiCondition.html b/ext/afform/admin/ang/afGuiEditor/afGuiCondition.html new file mode 100644 index 0000000000..8ce63c5aca --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/afGuiCondition.html @@ -0,0 +1,2 @@ + + diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.ctrl.js b/ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.ctrl.js new file mode 100644 index 0000000000..379b8b1894 --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.ctrl.js @@ -0,0 +1,59 @@ +// https://civicrm.org/licensing +(function(angular, $, _) { + "use strict"; + + angular.module('afGuiEditor').controller('AfGuiConditionalDialog', function($scope, $parse, afGui, dialogService) { + var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'), + ctrl = $scope.$ctrl = this; + this.node = $scope.model.node; + this.editor = $scope.model.editor; + this.conditions = parseConditions(); + loadAllFields(); + + this.save = function() { + if (!ctrl.conditions.length) { + delete ctrl.node['af-if']; + } else { + ctrl.node['af-if'] = '(' + JSON.stringify(ctrl.conditions) + ')'; + } + dialogService.close('afformGuiConditionalDialog'); + }; + + function parseConditions() { + var ngIf = _.trim(ctrl.node['af-if']); + if (!_.startsWith(ngIf, '(')) { + return []; + } + return $parse(ngIf.slice(1, -1))(); + } + + function loadAllFields() { + ctrl.fieldSelector = []; + ctrl.fieldDefns = {}; + _.each(ctrl.editor.getEntities(), function(entity) { + var entityFields = ctrl.editor.getEntityFields(entity.name), + items = _.transform(entityFields.fields, function(items, field) { + var key = entity.name + "[0][fields][" + field.name + "]"; + ctrl.fieldDefns[key] = field; + items.push({id: key, text: field.label}); + }); + _.each(entityFields.joins, function(join) { + items.push({ + text: afGui.getEntity(join.entity).label, + children: _.transform(join.fields, function(items, field) { + var key = entity.name + "[0][joins][" + join.entity + "][0][" + field.name + "]"; + ctrl.fieldDefns[key] = field; + items.push({id: key, text: field.label}); + }) + }); + }); + ctrl.fieldSelector.push({ + text: entity.label, + children: items + }); + }); + } + + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.html b/ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.html new file mode 100644 index 0000000000..e03f87cc13 --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/afGuiConditionalDialog.html @@ -0,0 +1,7 @@ +
+
+ + +
+ +
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js index d2279c950f..7d33889d37 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js @@ -18,7 +18,7 @@ mode: '@' }, controllerAs: 'editor', - controller: function($scope, crmApi4, afGui, $parse, $timeout, $location) { + controller: function($scope, crmApi4, afGui, $parse, $timeout) { var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'); this.afform = null; @@ -328,6 +328,7 @@ return editor.afform; }; + // Get all entities or a filtered list this.getEntities = function(filter) { return filter ? _.filter($scope.entities, filter) : _.toArray($scope.entities); }; @@ -351,6 +352,44 @@ } }; + // Gets complete field defn, merging values from the field with default values + function fillFieldDefn(entityType, field) { + var spec = _.cloneDeep(afGui.getField(entityType, field.name)); + return _.merge(spec, field.defn || {}); + } + + // Get all fields on the form for a particular entity + this.getEntityFields = function(entityName) { + var fieldsets = afGui.findRecursive(editor.layout['#children'], {'af-fieldset': entityName}), + entityType = editor.getEntity(entityName).type, + entityFields = {fields: [], joins: []}, + isJoin = function(item) { + return _.isPlainObject(item) && ('af-join' in item); + }; + _.each(fieldsets, function(fieldset) { + _.each(afGui.getFormElements(fieldset['#children'], {'#tag': 'af-field'}, isJoin), function(field) { + if (field.name) { + entityFields.fields.push(fillFieldDefn(entityType, field)); + } + }); + _.each(afGui.getFormElements(fieldset['#children'], isJoin), function(join) { + var joinFields = []; + _.each(afGui.getFormElements(join['#children'], {'#tag': 'af-field'}), function(field) { + if (field.name) { + joinFields.push(fillFieldDefn(join['af-join'], field)); + } + }); + if (joinFields.length) { + entityFields.joins.push({ + entity: join['af-join'], + fields: joinFields + }); + } + }); + }); + return entityFields; + }; + this.toggleNavigation = function() { if (editor.afform.navigation) { editor.afform.navigation = null; diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js b/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js index 3f3d0c7282..855c68629f 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiFieldValue.directive.js @@ -10,7 +10,7 @@ }, require: { ngModel: 'ngModel', - editor: '^^afGuiEditor' + editor: '?^^afGuiEditor' }, controller: function ($element, $timeout) { var ts = CRM.ts('org.civicrm.afform_admin'), diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton-menu.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton-menu.html index d121b827d6..b6eec51f47 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton-menu.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton-menu.html @@ -6,5 +6,7 @@ +
  • +
  • {{:: ts('Delete this button') }}
  • diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton.component.js index a6bca1b567..9ce9a1135a 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiButton.component.js @@ -8,6 +8,9 @@ node: '=', deleteThis: '&' }, + require: { + editor: '^^afGuiEditor', + }, controller: function($scope, afGui) { var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'), ctrl = this; diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiConditionalMenu.directive.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiConditionalMenu.directive.js new file mode 100644 index 0000000000..f02de0fe43 --- /dev/null +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiConditionalMenu.directive.js @@ -0,0 +1,18 @@ +(function(angular, $, _) { + angular.module('afGuiEditor').directive('afGuiConditionalMenu', function() { + return { + restrict: 'A', + templateUrl: '~/afGuiEditor/elements/afGuiConditionalMenu.html', + require: { + editor: '^^afGuiEditor' + }, + bindToController: { + node: ' + + + {{:: ts('Add Conditional Rules') }} + + + {{:: ts('Edit Conditional Rules') }} + + diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer-menu.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer-menu.html index 00b75cff27..f8e5e383e0 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer-menu.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer-menu.html @@ -34,5 +34,7 @@
  • +
  • +
  • {{ !block ? ts('Remove container') : ts('Remove block') }}
  • diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html index bfc0705d0b..804f7f985e 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiField-menu.html @@ -92,6 +92,7 @@ {{:: ts('Post help text') }} +
  • @@ -121,6 +122,9 @@
  • + +
  • +
  • @@ -134,6 +138,7 @@ {{:: ts('Customize options') }}
  • +
  • diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html index 6537d36de8..23910e4fd4 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup-menu.html @@ -1,9 +1,12 @@
  • {{:: ts('Edit content') }}
  • +
  • +
  • +
  • {{:: ts('Delete this content') }} diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js index 8a7ace51f6..e0970ba76e 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiMarkup.component.js @@ -10,6 +10,9 @@ node: '=', deleteThis: '&' }, + require: { + editor: '^^afGuiEditor', + }, controller: function($scope, $sce, $timeout) { var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'), ctrl = this; diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiText-menu.html b/ext/afform/admin/ang/afGuiEditor/elements/afGuiText-menu.html index 06170c5068..0bb6cf0b1b 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiText-menu.html +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiText-menu.html @@ -23,6 +23,8 @@
  • +
  • +
  • {{:: ts('Delete this text') }} diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiText.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiText.component.js index baa50d0408..a623d864da 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiText.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiText.component.js @@ -8,6 +8,9 @@ node: '=', deleteThis: '&' }, + require: { + editor: '^^afGuiEditor', + }, controller: function($scope, afGui) { var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'), ctrl = this; diff --git a/ext/afform/core/ang/af/afForm.component.js b/ext/afform/core/ang/af/afForm.component.js index e06b79dff0..2871466e71 100644 --- a/ext/afform/core/ang/af/afForm.component.js +++ b/ext/afform/core/ang/af/afForm.component.js @@ -106,6 +106,36 @@ } }); + // Handle the logic for conditional fields + this.checkConditions = function(conditions, op) { + op = op || 'AND'; + // OR and AND have the opposite behavior so the logic is inverted + // NOT works identically to OR but gets flipped at the end + var ret = op === 'AND', + flip = !ret; + _.each(conditions, function(clause) { + // Recurse into nested group + if (_.isArray(clause[1])) { + if (ctrl.checkConditions(clause[1], clause[0]) === flip) { + ret = flip; + } + } else { + // Angular can't handle expressions with quotes inside brackets, so they are omitted + // Here we add them back to make valid js + _.each(clause, function(expr, idx) { + if (_.isString(expr) && expr.charAt(0) !== '"') { + clause[idx] = expr.replace(/\[/g, "['").replace(/]/g, "']"); + } + }); + var parser = $parse(clause.join(' ')); + if (parser(data) === flip) { + ret = flip; + } + } + }); + return op === 'NOT' ? !ret : ret; + }; + // Called after form is submitted and files are uploaded function postProcess() { var metaData = ctrl.getFormMeta(), diff --git a/ext/afform/core/ang/af/afIf.directive.js b/ext/afform/core/ang/af/afIf.directive.js new file mode 100644 index 0000000000..7ed4a3473b --- /dev/null +++ b/ext/afform/core/ang/af/afIf.directive.js @@ -0,0 +1,80 @@ +(function(angular, $, _) { + // A modified version of ngIf to use afform.checkConditions + angular.module('af').directive('afIf', function($compile, $animate, $parse) { + return { + multiElement: true, + transclude: 'element', + priority: 601, + terminal: true, + restrict: 'A', + require: ['^^afForm'], + $$tlb: true, + link: function($scope, $element, $attr, ctrl, $transclude) { + var block, childScope, previousElements; + + function watcher() { + var conditions = $parse($attr.afIf)(); + return ctrl[0].checkConditions(conditions); + } + + $scope.$watch(watcher, function(value) { + if (value) { + if (!childScope) { + $transclude(function(clone, newScope) { + childScope = newScope; + clone[clone.length++] = $compile.$$createComment('end afIf', $attr.afIf); + // Note: We only need the first/last node of the cloned nodes. + // However, we need to keep the reference to the jqlite wrapper as it might be changed later + // by a directive with templateUrl when its template arrives. + block = { + clone: clone + }; + $animate.enter(clone, $element.parent(), $element); + }); + } + } else { + if (previousElements) { + previousElements.remove(); + previousElements = null; + } + if (childScope) { + childScope.$destroy(); + childScope = null; + } + if (block) { + previousElements = getBlockNodes(block.clone); + $animate.leave(previousElements).done(function(response) { + if (response !== false) previousElements = null; + }); + block = null; + } + } + }); + } + }; + }); + + /** + * Return the DOM siblings between the first and last node in the given array. + * @param {Array} array like object + * @returns {Array} the inputted object or a jqLite collection containing the nodes + */ + function getBlockNodes(nodes) { + // TODO(perf): update `nodes` instead of creating a new object? + var node = nodes[0]; + var endNode = nodes[nodes.length - 1]; + var blockNodes; + + for (var i = 1; node !== endNode && (node = node.nextSibling); i++) { + if (blockNodes || nodes[i] !== node) { + if (!blockNodes) { + blockNodes = $(slice.call(nodes, 0, i)); + } + blockNodes.push(node); + } + } + + return blockNodes || nodes; + } + +})(angular, CRM.$, CRM._); -- 2.25.1