Afform - Selectable location_type, is_primary, etc.
authorColeman Watts <coleman@civicrm.org>
Wed, 25 Aug 2021 13:25:18 +0000 (09:25 -0400)
committerColeman Watts <coleman@civicrm.org>
Fri, 27 Aug 2021 12:44:54 +0000 (08:44 -0400)
This allows the user to set a join block (email, phone, address, etc) as either repeatable
OR with a set value for is_primary or location_type.

Fixes dev/core#2703

14 files changed:
ext/afform/admin/afformEntities/Address.php
ext/afform/admin/afformEntities/Email.php
ext/afform/admin/afformEntities/IM.php
ext/afform/admin/afformEntities/Phone.php
ext/afform/admin/afformEntities/Website.php
ext/afform/admin/ang/afGuiEditor.css
ext/afform/admin/ang/afGuiEditor/afGuiContainerMultiToggle.component.js [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/afGuiContainerMultiToggle.html [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer-menu.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.html
ext/afform/core/CRM/Afform/ArrayHtml.php
ext/afform/core/Civi/Api4/Action/Afform/Submit.php
ext/afform/mock/tests/phpunit/api/v4/AfformCustomFieldUsageTest.php

index a8a35545a24c42cef02f84781723fdb8523419ec..0b07aeafbaad786b5bbfac5c2633ea3e5b40cced 100644 (file)
@@ -2,4 +2,5 @@
 return [
   'type' => 'join',
   'repeat_max' => NULL,
+  'unique_fields' => ['is_primary', 'location_type_id'],
 ];
index a8a35545a24c42cef02f84781723fdb8523419ec..0b07aeafbaad786b5bbfac5c2633ea3e5b40cced 100644 (file)
@@ -2,4 +2,5 @@
 return [
   'type' => 'join',
   'repeat_max' => NULL,
+  'unique_fields' => ['is_primary', 'location_type_id'],
 ];
index a8a35545a24c42cef02f84781723fdb8523419ec..0b07aeafbaad786b5bbfac5c2633ea3e5b40cced 100644 (file)
@@ -2,4 +2,5 @@
 return [
   'type' => 'join',
   'repeat_max' => NULL,
+  'unique_fields' => ['is_primary', 'location_type_id'],
 ];
index a8a35545a24c42cef02f84781723fdb8523419ec..0b07aeafbaad786b5bbfac5c2633ea3e5b40cced 100644 (file)
@@ -2,4 +2,5 @@
 return [
   'type' => 'join',
   'repeat_max' => NULL,
+  'unique_fields' => ['is_primary', 'location_type_id'],
 ];
index a8a35545a24c42cef02f84781723fdb8523419ec..c646f06870eda8ca251527affefd4be97c01172c 100644 (file)
@@ -2,4 +2,5 @@
 return [
   'type' => 'join',
   'repeat_max' => NULL,
+  'unique_fields' => ['website_type_id'],
 ];
index a18dc7383487eb3920b4ba40fc4c0902c2e06b25..cd75a4411a4b1dc610287132bcb130acb6c99b22 100644 (file)
@@ -260,13 +260,14 @@ body.af-gui-dragging {
   opacity: 1;
   transition: opacity 0s;
 }
-#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > button > span,
+/* Fix button colors when bar is highlighted */
+#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > .btn-group > .btn-group > button > span,
 #afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > span {
   color: white;
 }
-#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > button:hover > span,
-#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .open > button > span,
-#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > button:focus > span {
+#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > .btn-group > .btn-group > button:hover > span,
+#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > .btn-group > .btn-group > button[aria-expanded=true] > span,
+#afGuiEditor #afGuiEditor-canvas .af-entity-selected > .af-gui-bar > .form-inline > .btn-group > .btn-group > button:focus > span {
   color: #0071bd;
 }
 
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiContainerMultiToggle.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiContainerMultiToggle.component.js
new file mode 100644 (file)
index 0000000..cd3ff7e
--- /dev/null
@@ -0,0 +1,115 @@
+// https://civicrm.org/licensing
+(function(angular, $, _) {
+  "use strict";
+
+  // Menu item to control the border property of a node
+  angular.module('afGuiEditor').component('afGuiContainerMultiToggle', {
+    templateUrl: '~/afGuiEditor/afGuiContainerMultiToggle.html',
+    bindings: {
+      entity: '<'
+    },
+    require: {
+      container: '^^afGuiContainer'
+    },
+    controller: function($scope, afGui) {
+      var ts = $scope.ts = CRM.ts('org.civicrm.afform_admin'),
+        ctrl = this;
+      this.menuItems = [];
+      this.uniqueFields = {};
+
+      this.$onInit = function() {
+        this.menuItems.push({
+          key: 'repeat',
+          label: ts('Multiple')
+        });
+        _.each(afGui.getEntity(this.entity).unique_fields, function(fieldName) {
+          var field = ctrl.uniqueFields[fieldName] = afGui.getField(ctrl.entity, fieldName);
+          ctrl.menuItems.push({});
+          if (field.options) {
+            _.each(field.options, function(option) {
+              ctrl.menuItems.push({
+                field: fieldName,
+                key: option.id,
+                label: option.label
+              });
+            });
+          } else {
+            ctrl.menuItems.push({
+              field: fieldName,
+              key: true,
+              label: field.label
+            });
+          }
+        });
+      };
+
+      this.isMulti = function() {
+        return 'af-repeat' in ctrl.container.node;
+      };
+
+      this.isSelected = function(item) {
+        if (!item.field && item.key === 'repeat') {
+          return ctrl.isMulti();
+        }
+        if (ctrl.container.node.data) {
+          var field = ctrl.uniqueFields[item.field];
+          if (field.options) {
+            return ctrl.container.node.data[item.field] === item.key;
+          }
+          return ctrl.container.node.data[item.field];
+        }
+        return false;
+      };
+
+      this.selectOption = function(item) {
+        if (!item.field && item.key === 'repeat') {
+          return ctrl.container.toggleRepeat();
+        }
+        if (ctrl.isMulti()) {
+          ctrl.container.toggleRepeat();
+        }
+        var field = ctrl.uniqueFields[item.field];
+        ctrl.container.node.data = ctrl.container.node.data || {};
+        if (field.options) {
+          if (ctrl.container.node.data[item.field] === item.key) {
+            delete ctrl.container.node.data[item.field];
+          } else {
+            ctrl.container.node.data = {};
+            ctrl.container.node.data[item.field] = item.key;
+            ctrl.container.removeField(item.field);
+          }
+        } else if (ctrl.container.node.data[item.field]) {
+          delete ctrl.container.node.data[item.field];
+        } else {
+          ctrl.container.node.data = {};
+          ctrl.container.node.data[item.field] = true;
+          ctrl.container.removeField(item.field);
+        }
+        if (_.isEmpty(ctrl.container.node.data)) {
+          delete ctrl.container.node.data;
+        }
+      };
+
+      this.getButtonText = function() {
+        if (ctrl.isMulti()) {
+          return ts('Multiple');
+        }
+        var output = ts('Single');
+        _.each(ctrl.container.node.data, function(val, fieldName) {
+          if (val && (fieldName in ctrl.uniqueFields)) {
+            var field = ctrl.uniqueFields[fieldName];
+            if (field.options) {
+              output = _.result(_.findWhere(field.options, {id: val}), 'label');
+            } else {
+              output = field.label;
+            }
+            return false;
+          }
+        });
+        return output;
+      };
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiContainerMultiToggle.html b/ext/afform/admin/ang/afGuiEditor/afGuiContainerMultiToggle.html
new file mode 100644 (file)
index 0000000..0d1fdc8
--- /dev/null
@@ -0,0 +1,14 @@
+<button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown" title="{{:: ts('Toggle multi or single block') }}">
+  <span>
+    <i class="crm-i fa-{{ $ctrl.isMulti() ? 'repeat' : 'dot-circle-o' }}"></i>
+    {{ $ctrl.getButtonText() }}
+  </span>
+</button>
+<ul class="dropdown-menu dropdown-menu-right">
+  <li ng-repeat="item in $ctrl.menuItems" class="{{ item.key ? '' : 'divider' }}">
+    <a href ng-if="item.key" ng-click="$ctrl.selectOption(item)">
+      <i class="crm-i fa-{{ $ctrl.isSelected(item) ? 'check-' : '' }}circle-o"></i>
+      {{:: item.label }}
+    </a>
+  </li>
+</ul>
index 4a9b6d6fb7d4664ab9faf7f66125e3f0a382e8d8..182e092e8303b98ffc175df839dedbb8f0c7bc1a 100644 (file)
@@ -10,7 +10,7 @@
 </li>
 <li ng-if="isRepeatable()" ng-click="$event.stopPropagation()">
   <div class="af-gui-field-select-in-dropdown form-inline">
-    <label ng-click="toggleRepeat()">
+    <label ng-click="$ctrl.toggleRepeat()">
       <i class="crm-i fa-{{ $ctrl.node['af-repeat'] || $ctrl.node['af-repeat'] === '' ? 'check-' : '' }}square-o"></i>
       {{:: ts('Repeat') }}
     </label>
index 097782c795e7c20956476df95f4044e24721f8c9..f38a8a1b4fcb80ae7cc34c6133a681fbaa5587d8 100644 (file)
@@ -76,7 +76,7 @@
         return ctrl.node['af-fieldset'] || (block.directive && afGui.meta.blocks[block.directive].repeat) || ctrl.join;
       };
 
-      $scope.toggleRepeat = function() {
+      this.toggleRepeat = function() {
         if ('af-repeat' in ctrl.node) {
           delete ctrl.node.max;
           delete ctrl.node.min;
@@ -85,6 +85,7 @@
         } else {
           ctrl.node.min = '1';
           ctrl.node['af-repeat'] = ts('Add');
+          delete ctrl.node.data;
         }
       };
 
       // or from afformEntity php file for core entities.
       $scope.getRepeatMax = function() {
         if (ctrl.join) {
-          return afGui.getEntity(ctrl.join).repeat_max || '';
+          return ctrl.getJoinEntity().repeat_max || '';
         }
         return '';
       };
         afGui.removeRecursive($scope.getSetChildren(), {$$hashKey: element.$$hashKey});
       };
 
+      this.removeField = function(fieldName) {
+        afGui.removeRecursive($scope.getSetChildren(), {'#tag': 'af-field', name: fieldName});
+      };
+
       this.getEntityName = function() {
         return ctrl.entityName ? ctrl.entityName.split('-join-')[0] : null;
       };
 
+      this.getJoinEntity = function() {
+        if (!ctrl.join) {
+          return null;
+        }
+        return afGui.getEntity(ctrl.join);
+      };
+
       // Returns the primary entity type for this container e.g. "Contact"
       this.getMainEntityType = function() {
         return ctrl.editor && ctrl.editor.getEntity(ctrl.getEntityName()).type;
index 4082c032924cc642d57d60b3c7af62a18ae48352..ad3fe18eb15a422d06e7052bba89cea89ff655fd 100644 (file)
@@ -1,5 +1,5 @@
 <div class="af-gui-bar" ng-if="$ctrl.node['#tag']" ng-click="selectEntity()" >
-  <div ng-if="!$ctrl.loading" class="form-inline" af-gui-menu>
+  <div ng-if="!$ctrl.loading" class="form-inline">
     <span ng-if="$ctrl.getNodeType($ctrl.node) == 'fieldset'">{{ $ctrl.editor.getEntity($ctrl.entityName).label }}</span>
     <span ng-if="block">{{ $ctrl.join ? ts($ctrl.join) + ':' : ts('Block:') }}</span>
     <span ng-if="!block">{{ tags[$ctrl.node['#tag']] }}</span>
@@ -8,10 +8,15 @@
       <option ng-value="option.id" ng-repeat="option in block.options track by option.id">{{ option.text }}</option>
     </select>
     <button type="button" class="btn btn-default btn-xs" ng-if="block && !block.layout" ng-click="saveBlock()">{{:: ts('Save...') }}</button>
-    <button type="button" class="btn btn-default btn-xs dropdown-toggle af-gui-add-element-button pull-right" data-toggle="dropdown" title="{{:: ts('Configure') }}">
-      <span><i class="crm-i fa-gear"></i></span>
-    </button>
-    <ul class="dropdown-menu dropdown-menu-right" ng-if="menu.open" ng-include="'~/afGuiEditor/elements/afGuiContainer-menu.html'"></ul>
+    <div class="btn-group pull-right">
+      <af-gui-container-multi-toggle ng-if="!ctrl.loading && ($ctrl.join || $ctrl.node['af-repeat'])" entity="$ctrl.getFieldEntityType()" class="btn-group"></af-gui-container-multi-toggle>
+      <div class="btn-group" af-gui-menu>
+        <button type="button" class="btn btn-default btn-xs dropdown-toggle af-gui-add-element-button" data-toggle="dropdown" title="{{:: ts('Configure') }}">
+          <span><i class="crm-i fa-gear"></i></span>
+        </button>
+        <ul class="dropdown-menu dropdown-menu-right" ng-if="menu.open" ng-include="'~/afGuiEditor/elements/afGuiContainer-menu.html'"></ul>
+      </div>
+    </div>
   </div>
   <div ng-if="$ctrl.loading"><i class="crm-i fa-spin fa-spinner"></i></div>
 </div>
index f24a2f27e0778dc91e44691152b2bd284a60281c..44eec67092b702e60291d11f3cf12d11fa9f80fb 100644 (file)
@@ -23,12 +23,12 @@ class CRM_Afform_ArrayHtml {
     '*' => [
       '*' => 'text',
       'af-fieldset' => 'text',
+      'data' => 'js',
     ],
     'af-entity' => [
       '#selfClose' => TRUE,
       'name' => 'text',
       'type' => 'text',
-      'data' => 'js',
       'security' => 'text',
       'actions' => 'js',
     ],
index 1c69dc9cb269e7b4c17b4ae2d39c2b90edc3c377..029daabd413de31ebbdadd45499a8636da351341 100644 (file)
@@ -38,9 +38,11 @@ class Submit extends AbstractProcessor {
         foreach ($values['joins'] as $joinEntity => &$joinValues) {
           // Enforce the limit set by join[max]
           $joinValues = array_slice($joinValues, 0, $entity['joins'][$joinEntity]['max'] ?? NULL);
-          // Only accept values from join fields on the form
           foreach ($joinValues as $index => $vals) {
+            // Only accept values from join fields on the form
             $joinValues[$index] = array_intersect_key($vals, $entity['joins'][$joinEntity]['fields'] ?? []);
+            // Merge in pre-set data
+            $joinValues[$index] = array_merge($joinValues[$index], $entity['joins'][$joinEntity]['data'] ?? []);
           }
         }
         $entityValues[$entityName][] = $values;
index 5ad2aa7255b41bb9e6c7c50b0bb420ebfc2e570e..3ece0fb88deed85ffec8ec21ca5a9c9b179069c0 100644 (file)
@@ -53,7 +53,6 @@ EOHTML;
       ->setLayoutFormat('shallow')
       ->setFormatWhitespace(TRUE)
       ->execute()->single();
-    $this->assertEquals(2, $block['repeat']);
     $this->assertEquals('afblock-custom-my-things', $block['directive_name']);
     $this->assertEquals('my_text', $block['layout'][0]['name']);
     $this->assertEquals('my_friend', $block['layout'][1]['name']);