Afform - Support creating & updating relationships
authorColeman Watts <coleman@civicrm.org>
Mon, 25 Apr 2022 05:58:35 +0000 (07:58 +0200)
committerColeman Watts <coleman@civicrm.org>
Wed, 4 May 2022 22:50:32 +0000 (18:50 -0400)
Fixes dev/core#3117

ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php
ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/afform/admin/afformEntities/Relationship.php [new file with mode: 0644]
ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js
ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php
ext/afform/core/Civi/Afform/FormDataModel.php
ext/afform/core/Civi/Api4/Action/Afform/Submit.php
ext/afform/core/afform.php
ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php
ext/afform/mock/tests/phpunit/api/v4/AfformRelationshipUsageTest.php [new file with mode: 0644]

index f195ccaff1f106b430533bcca77bc8cfa57823a1..b67372046015b2a7db364c535cbbb3bf8d61a13c 100644 (file)
@@ -104,7 +104,6 @@ class AfformAdminMeta {
       $params['values']['state_province_id'] = \Civi::settings()->get('defaultContactStateProvince');
     }
     $fields = (array) civicrm_api4($entityName, 'getFields', $params);
-
     // Add implicit joins to search fields
     if ($params['action'] === 'get') {
       foreach (array_reverse($fields, TRUE) as $index => $field) {
@@ -122,7 +121,24 @@ class AfformAdminMeta {
         }
       }
     }
-    return array_column($fields, NULL, 'name');
+    // Index by name
+    $fields = array_column($fields, NULL, 'name');
+    // Mix in alterations declared by afform entities
+    if ($params['action'] === 'create') {
+      $afEntity = self::getMetadata()['entities'][$entityName] ?? [];
+      if (!empty($afEntity['alterFields'])) {
+        foreach ($afEntity['alterFields'] as $fieldName => $changes) {
+          // Allow field to be deleted
+          if ($changes === FALSE) {
+            unset($fields[$fieldName]);
+          }
+          else {
+            $fields[$fieldName] = \CRM_Utils_Array::crmArrayMerge($changes, ($fields[$fieldName] ?? []));
+          }
+        }
+      }
+    }
+    return $fields;
   }
 
   /**
index 3c0e68ac553ea1e9e7b5a93aa870f2aa2147e5bf..db70363cdce744b6be979f8edce3785bae2e62aa 100644 (file)
@@ -315,6 +315,10 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
         'name' => 'blocks',
         'data_type' => 'Array',
       ],
+      [
+        'name' => 'entities',
+        'data_type' => 'Array',
+      ],
       [
         'name' => 'fields',
         'data_type' => 'Array',
diff --git a/ext/afform/admin/afformEntities/Relationship.php b/ext/afform/admin/afformEntities/Relationship.php
new file mode 100644 (file)
index 0000000..e1a8666
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+return [
+  'type' => 'primary',
+  'defaults' => "{
+    security: 'FBAC'
+  }",
+  'icon' => 'fa-handshake-o',
+  'boilerplate' => FALSE,
+  'repeatable' => FALSE,
+  'alterFields' => [
+    'contact_id_a' => ['input_attrs' => ['multiple' => TRUE]],
+    'contact_id_b' => ['input_attrs' => ['multiple' => TRUE]],
+  ],
+];
index e7c010e10a9583bb1fd4b1bca5977267290109b8..52fc975d7fed4b026f033187e23304fa1f458192 100644 (file)
           var pos = 1 + _.findLastIndex(editor.layout['#children'], {'#tag': 'af-entity'});
           editor.layout['#children'].splice(pos, 0, $scope.entities[type + num]);
           // Create a new af-fieldset container for the entity
-          var fieldset = _.cloneDeep(afGui.meta.elements.fieldset.element);
-          fieldset['af-fieldset'] = type + num;
-          fieldset['af-title'] = meta.label + ' ' + num;
-          // Add boilerplate contents
-          _.each(meta.boilerplate, function (tag) {
-            fieldset['#children'].push(tag);
-          });
-          // Attempt to place the new af-fieldset after the last one on the form
-          pos = 1 + _.findLastIndex(editor.layout['#children'], 'af-fieldset');
-          if (pos) {
-            editor.layout['#children'].splice(pos, 0, fieldset);
-          } else {
-            editor.layout['#children'].push(fieldset);
+          if (meta.boilerplate !== false) {
+            var fieldset = _.cloneDeep(afGui.meta.elements.fieldset.element);
+            fieldset['af-fieldset'] = type + num;
+            fieldset['af-title'] = meta.label + ' ' + num;
+            // Add boilerplate contents
+            _.each(meta.boilerplate, function (tag) {
+              fieldset['#children'].push(tag);
+            });
+            // Attempt to place the new af-fieldset after the last one on the form
+            pos = 1 + _.findLastIndex(editor.layout['#children'], 'af-fieldset');
+            if (pos) {
+              editor.layout['#children'].splice(pos, 0, fieldset);
+            } else {
+              editor.layout['#children'].push(fieldset);
+            }
           }
           delete $scope.entities[type + num].loading;
           if (selectTab) {
index c59cc898ca0303a38aae1f61b76899e7df4c2364..8a0d0a0e11394042c99cffe5ea814a29c5ee5f35 100644 (file)
       };
 
       $scope.isRepeatable = function() {
-        return ctrl.node['af-fieldset'] || (block.directive && afGui.meta.blocks[block.directive].repeat) || ctrl.join;
+        return ctrl.join ||
+          (block.directive && afGui.meta.blocks[block.directive].repeat) ||
+          (ctrl.node['af-fieldset'] && ctrl.editor.getEntityDefn(ctrl.editor.getEntity(ctrl.node['af-fieldset'])) !== false);
       };
 
       this.toggleRepeat = function() {
index 1788763d3c2cbdad594e76ce4ed6da25fd9d0ad4..fa28b3b4951544c5bb0529439d76791cc1faf775 100644 (file)
@@ -14,9 +14,9 @@ use Civi\Api4\Utils\CoreUtil;
  * If special processing for an entity type is desired, add a new listener with a higher priority
  * than 0, and do one of two things:
  *
- * 1. Fully process the save, and cancel event propagation to bypass `processGenericEntity`.
- * 2. Manipulate the $records and allow the default listener to perform the save.
- *    Setting $record['fields'] = NULL will cancel saving a record, e.g. if the record is not valid.
+ * 1. Fully process the save, and call `$event->stopPropagation()` to skip `processGenericEntity`.
+ * 2. Manipulate the $records and allow `processGenericEntity` to perform the save.
+ *    Setting $record['fields'] = NULL will prevent saving a record, e.g. if the record is not valid.
  *
  * @package Civi\Afform\Event
  */
@@ -88,6 +88,13 @@ class AfformSubmitEvent extends AfformBaseEvent {
     return $this->entityName;
   }
 
+  /**
+   * @return array{type: string, fields: array, joins: array, security: string, actions: array}
+   */
+  public function getEntity() {
+    return $this->getFormDataModel()->getEntity($this->entityName);
+  }
+
   /**
    * @return callable
    *   API4-style
index 3feade32dc6dc1958b1f5f282f640e5a28a839b2..a36872826260b1e3623b61bbe0b9579c9715e422 100644 (file)
@@ -13,7 +13,12 @@ use Civi\Api4\Afform;
  */
 class FormDataModel {
 
-  protected $defaults = ['security' => 'RBAC', 'actions' => ['create' => TRUE, 'update' => TRUE]];
+  protected $defaults = [
+    'security' => 'RBAC',
+    'actions' => ['create' => TRUE, 'update' => TRUE],
+    'min' => 1,
+    'max' => 1,
+  ];
 
   /**
    * @var array[]
@@ -136,9 +141,14 @@ class FormDataModel {
       if (!is_array($node) || !isset($node['#tag'])) {
         continue;
       }
-      elseif (isset($node['af-fieldset']) && !empty($node['#children'])) {
-        $searchDisplay = $node['af-fieldset'] ? NULL : $this->findSearchDisplay($node);
-        $this->parseFields($node['#children'], $node['af-fieldset'], $join, $searchDisplay);
+      elseif (isset($node['af-fieldset'])) {
+        $entity = $node['af-fieldset'] ?? NULL;
+        $searchDisplay = $entity ? NULL : $this->findSearchDisplay($node);
+        if ($entity && isset($node['af-repeat'])) {
+          $this->entities[$entity]['min'] = $node['min'] ?? 0;
+          $this->entities[$entity]['max'] = $node['max'] ?? NULL;
+        }
+        $this->parseFields($node['#children'] ?? [], $node['af-fieldset'], $join, $searchDisplay);
       }
       elseif ($searchDisplay && $node['#tag'] === 'af-field') {
         $this->searchDisplays[$searchDisplay]['fields'][$node['name']] = AHQ::getProps($node);
index 029daabd413de31ebbdadd45499a8636da351341..068fc657dbed652363075e65663ce3b735853a56 100644 (file)
@@ -4,6 +4,7 @@ namespace Civi\Api4\Action\Afform;
 
 use Civi\Afform\Event\AfformSubmitEvent;
 use Civi\Api4\AfformSubmission;
+use Civi\Api4\RelationshipType;
 use Civi\Api4\Utils\CoreUtil;
 
 /**
@@ -28,7 +29,6 @@ class Submit extends AbstractProcessor {
     $entityValues = [];
     foreach ($this->_formDataModel->getEntities() as $entityName => $entity) {
       $entityValues[$entityName] = [];
-
       // Gather submitted field values from $values['fields'] and sub-entities from $values['joins']
       foreach ($this->values[$entityName] ?? [] as $values) {
         // Only accept values from fields on the form
@@ -47,8 +47,12 @@ class Submit extends AbstractProcessor {
         }
         $entityValues[$entityName][] = $values;
       }
-      // Predetermined values override submitted values
       if (!empty($entity['data'])) {
+        // If no submitted values but data exists, fill the minimum number of records
+        for ($index = 0; $index < $entity['min']; $index++) {
+          $entityValues[$entityName][$index] = $entityValues[$entityName][$index] ?? ['fields' => []];
+        }
+        // Predetermined values override submitted values
         foreach ($entityValues[$entityName] as $index => $vals) {
           $entityValues[$entityName][$index]['fields'] = $entity['data'] + $vals['fields'];
         }
@@ -94,7 +98,8 @@ class Submit extends AbstractProcessor {
           if (is_array($value)) {
             foreach ($value as $i => $val) {
               if (in_array($val, $entityNames, TRUE)) {
-                $records[$key]['fields'][$field][$i] = $this->_entityIds[$val][0]['id'] ?? NULL;
+                $refIds = array_filter(array_column($this->_entityIds[$val], 'id'));
+                array_splice($records[$key]['fields'][$field], $i, 1, $refIds);
               }
             }
           }
@@ -162,6 +167,64 @@ class Submit extends AbstractProcessor {
     }
   }
 
+  /**
+   * @param \Civi\Afform\Event\AfformSubmitEvent $event
+   */
+  public static function processRelationships(AfformSubmitEvent $event) {
+    if ($event->getEntityType() !== 'Relationship') {
+      return;
+    }
+    // Prevent processGenericEntity
+    $event->stopPropagation();
+    $api4 = $event->getSecureApi4();
+    $relationship = $event->records[0]['fields'] ?? [];
+    if (empty($relationship['contact_id_a']) || empty($relationship['contact_id_b']) || empty($relationship['relationship_type_id'])) {
+      return;
+    }
+    $relationshipType = RelationshipType::get(FALSE)
+      ->addWhere('id', '=', $relationship['relationship_type_id'])
+      ->execute()->single();
+    $isReciprocal = $relationshipType['label_a_b'] == $relationshipType['label_b_a'];
+    $isActive = !isset($relationship['is_active']) || !empty($relationship['is_active']);
+    // Each contact id could be multivalued (e.g. using `af-repeat`)
+    foreach ((array) $relationship['contact_id_a'] as $contact_id_a) {
+      foreach ((array) $relationship['contact_id_b'] as $contact_id_b) {
+        $params = $relationship;
+        $params['contact_id_a'] = $contact_id_a;
+        $params['contact_id_b'] = $contact_id_b;
+        // Check for existing relationships (if allowed)
+        if (!empty($event->getEntity()['actions']['update'])) {
+          $where = [
+            ['is_active', '=', $isActive],
+            ['relationship_type_id', '=', $relationship['relationship_type_id']],
+          ];
+          // Reciprocal relationship types need an extra check
+          if ($isReciprocal) {
+            $where[] = ['OR',
+              ['AND', ['contact_id_a', '=', $contact_id_a], ['contact_id_b', '=', $contact_id_b]],
+              ['AND', ['contact_id_a', '=', $contact_id_b], ['contact_id_b', '=', $contact_id_a]],
+            ];
+          }
+          else {
+            $where[] = ['contact_id_a', '=', $contact_id_a];
+            $where[] = ['contact_id_b', '=', $contact_id_b];
+          }
+          $existing = $api4('Relationship', 'get', ['where' => $where])->first();
+          if ($existing) {
+            $params['id'] = $existing['id'];
+            unset($params['contact_id_a'], $params['contact_id_b']);
+            // If this is a flipped reciprocal relationship, also flip the permissions
+            $params['is_permission_a_b'] = $relationship['is_permission_b_a'] ?? NULL;
+            $params['is_permission_b_a'] = $relationship['is_permission_a_b'] ?? NULL;
+          }
+        }
+        $api4('Relationship', 'save', [
+          'records' => [$params],
+        ]);
+      }
+    }
+  }
+
   /**
    * This saves joins (sub-entities) such as Email, Address, Phone, etc.
    *
index 1fdb6bd9ffcd06e0f00c00a4512a138dce36b96b..4cb78865244adfc80ffb73887abfa17b4e46c164 100644 (file)
@@ -51,6 +51,7 @@ function afform_civicrm_config(&$config) {
   $dispatcher = Civi::dispatcher();
   $dispatcher->addListener('civi.afform.submit', ['\Civi\Api4\Action\Afform\Submit', 'processGenericEntity'], 0);
   $dispatcher->addListener('civi.afform.submit', ['\Civi\Api4\Action\Afform\Submit', 'preprocessContact'], 10);
+  $dispatcher->addListener('civi.afform.submit', ['\Civi\Api4\Action\Afform\Submit', 'processRelationships'], 1);
   $dispatcher->addListener('hook_civicrm_angularModules', ['\Civi\Afform\AngularDependencyMapper', 'autoReq'], -1000);
   $dispatcher->addListener('hook_civicrm_alterAngular', ['\Civi\Afform\AfformMetadataInjector', 'preprocess']);
   $dispatcher->addListener('hook_civicrm_check', ['\Civi\Afform\StatusChecks', 'hook_civicrm_check']);
index 0e6d7812ee42dab230398f4d5c4e8921fce38b41..0819c8ba78cb4a53cc1a8e2e5fdfcc354c4cfbec 100644 (file)
@@ -39,7 +39,7 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
     ];
 
     $cases[] = [
-      'html' => '<af-form><af-entity type="Foo" name="foobar"/><div af-fieldset="foobar"><af-field name="propA" /><span><p><af-field name="propB" defn="{title: \'Whiz\'}" /></p></span></div></af-form>',
+      'html' => '<af-form><af-entity type="Foo" name="foobar"/><div af-fieldset="foobar" af-repeat><af-field name="propA" /><span><p><af-field name="propB" defn="{title: \'Whiz\'}" /></p></span></div></af-form>',
       'entities' => [
         'foobar' => [
           'type' => 'Foo',
@@ -51,6 +51,8 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
           'joins' => [],
           'security' => 'RBAC',
           'actions' => ['create' => 1, 'update' => 1],
+          'min' => 0,
+          'max' => NULL,
         ],
       ],
     ];
@@ -65,6 +67,8 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
           'joins' => [],
           'security' => 'RBAC',
           'actions' => ['create' => 1, 'update' => 1],
+          'min' => 1,
+          'max' => 1,
         ],
         'whiz_bang' => [
           'type' => 'Whiz',
@@ -73,12 +77,14 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
           'joins' => [],
           'security' => 'RBAC',
           'actions' => ['create' => 1, 'update' => 1],
+          'min' => 1,
+          'max' => 1,
         ],
       ],
     ];
 
     $cases[] = [
-      'html' => '<af-form><div><af-entity type="Foo" name="foobar" security="FBAC" actions="{create: false, update: true}"/></div></af-form>',
+      'html' => '<af-form><div><af-entity type="Foo" name="foobar" security="FBAC" actions="{create: false, update: true}"/><div af-fieldset="foobar" af-repeat min="1" max="2"></div></div></af-form>',
       'entities' => [
         'foobar' => [
           'type' => 'Foo',
@@ -87,6 +93,8 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI
           'joins' => [],
           'security' => 'FBAC',
           'actions' => ['create' => FALSE, 'update' => TRUE],
+          'min' => 1,
+          'max' => 2,
         ],
       ],
     ];
diff --git a/ext/afform/mock/tests/phpunit/api/v4/AfformRelationshipUsageTest.php b/ext/afform/mock/tests/phpunit/api/v4/AfformRelationshipUsageTest.php
new file mode 100644 (file)
index 0000000..99dd525
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+
+use Civi\Api4\Relationship;
+
+/**
+ * Test case for Afform.prefill and Afform.submit.
+ *
+ * @group headless
+ */
+class api_v4_AfformRelationshipUsageTest extends api_v4_AfformUsageTestCase {
+
+  /**
+   * Tests creating a relationship between multiple contacts
+   */
+  public function testCreateContactsWithRelationships(): void {
+    $layout = <<<EOHTML
+<af-form ctrl="afform">
+  <af-entity data="{contact_type: 'Individual', source: 'Test Rel'}" type="Contact" name="Individual1" label="Individual 1" actions="{create: true, update: true}" security="RBAC" />
+  <af-entity security="FBAC" type="Relationship" name="Relationship1" label="Relationship 1" actions="{create: true, update: true}" data="{contact_id_b: ['Individual1'], contact_id_a: ['Individual2'], relationship_type_id: '1'}" />
+  <af-entity data="{contact_type: 'Individual', source: 'Test Rel'}" type="Contact" name="Individual2" label="Individual 2" actions="{create: true, update: true}" security="RBAC" />
+  <fieldset af-fieldset="Individual1" class="af-container" af-title="Individual 1">
+    <afblock-name-individual></afblock-name-individual>
+  </fieldset>
+  <fieldset af-fieldset="Relationship1" class="af-container"></fieldset>
+  <fieldset af-fieldset="Individual2" class="af-container" af-title="Individual 2" af-repeat="Add" min="1">
+    <afblock-name-individual></afblock-name-individual>
+  </fieldset>
+  <button class="af-button btn btn-primary" crm-icon="fa-check" ng-click="afform.submit()">Submit</button>
+</af-form>
+EOHTML;
+
+    $this->useValues([
+      'layout' => $layout,
+      'permission' => CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION,
+    ]);
+
+    $lastName = uniqid(__FUNCTION__);
+
+    $submission = [
+      'Individual1' => [
+        ['fields' => ['first_name' => 'Firsty1', 'last_name' => $lastName]],
+      ],
+      'Individual2' => [
+        ['fields' => ['first_name' => 'Firsty2', 'last_name' => $lastName]],
+        ['fields' => ['first_name' => 'Firsty3', 'last_name' => $lastName]],
+      ],
+    ];
+
+    Civi\Api4\Afform::submit()
+      ->setName($this->formName)
+      ->setValues($submission)
+      ->execute();
+
+    $saved = Relationship::get(FALSE)
+      ->addWhere('contact_id_b.last_name', '=', $lastName)
+      ->addSelect('contact_id_a.first_name', 'is_active')
+      ->addOrderBy('contact_id_a.first_name')
+      ->execute();
+
+    $this->assertCount(2, $saved);
+    $this->assertEquals('Firsty2', $saved[0]['contact_id_a.first_name']);
+    $this->assertEquals('Firsty3', $saved[1]['contact_id_a.first_name']);
+  }
+
+}