From e1f799507b878f7075a81230aa2de080bedfaa1b Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Mon, 25 Apr 2022 07:58:35 +0200 Subject: [PATCH] Afform - Support creating & updating relationships Fixes dev/core#3117 --- .../Civi/AfformAdmin/AfformAdminMeta.php | 20 +++++- .../Civi/Api4/Action/Afform/LoadAdminData.php | 4 ++ .../admin/afformEntities/Relationship.php | 14 ++++ .../ang/afGuiEditor/afGuiEditor.component.js | 28 ++++---- .../elements/afGuiContainer.component.js | 4 +- .../Civi/Afform/Event/AfformSubmitEvent.php | 13 +++- ext/afform/core/Civi/Afform/FormDataModel.php | 18 +++-- .../core/Civi/Api4/Action/Afform/Submit.php | 69 ++++++++++++++++++- ext/afform/core/afform.php | 1 + .../phpunit/Civi/Afform/FormDataModelTest.php | 12 +++- .../api/v4/AfformRelationshipUsageTest.php | 65 +++++++++++++++++ 11 files changed, 220 insertions(+), 28 deletions(-) create mode 100644 ext/afform/admin/afformEntities/Relationship.php create mode 100644 ext/afform/mock/tests/phpunit/api/v4/AfformRelationshipUsageTest.php diff --git a/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php b/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php index f195ccaff1..b673720460 100644 --- a/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php +++ b/ext/afform/admin/Civi/AfformAdmin/AfformAdminMeta.php @@ -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; } /** diff --git a/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php b/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php index 3c0e68ac55..db70363cdc 100644 --- a/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php +++ b/ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php @@ -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 index 0000000000..e1a8666d5d --- /dev/null +++ b/ext/afform/admin/afformEntities/Relationship.php @@ -0,0 +1,14 @@ + '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]], + ], +]; diff --git a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js index e7c010e10a..52fc975d7f 100644 --- a/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js +++ b/ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js @@ -133,19 +133,21 @@ 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) { diff --git a/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js b/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js index c59cc898ca..8a0d0a0e11 100644 --- a/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js +++ b/ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js @@ -109,7 +109,9 @@ }; $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() { diff --git a/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php b/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php index 1788763d3c..fa28b3b495 100644 --- a/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php +++ b/ext/afform/core/Civi/Afform/Event/AfformSubmitEvent.php @@ -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 diff --git a/ext/afform/core/Civi/Afform/FormDataModel.php b/ext/afform/core/Civi/Afform/FormDataModel.php index 3feade32dc..a368728262 100644 --- a/ext/afform/core/Civi/Afform/FormDataModel.php +++ b/ext/afform/core/Civi/Afform/FormDataModel.php @@ -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); diff --git a/ext/afform/core/Civi/Api4/Action/Afform/Submit.php b/ext/afform/core/Civi/Api4/Action/Afform/Submit.php index 029daabd41..068fc657db 100644 --- a/ext/afform/core/Civi/Api4/Action/Afform/Submit.php +++ b/ext/afform/core/Civi/Api4/Action/Afform/Submit.php @@ -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. * diff --git a/ext/afform/core/afform.php b/ext/afform/core/afform.php index 1fdb6bd9ff..4cb7886524 100644 --- a/ext/afform/core/afform.php +++ b/ext/afform/core/afform.php @@ -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']); diff --git a/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php b/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php index 0e6d7812ee..0819c8ba78 100644 --- a/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php +++ b/ext/afform/core/tests/phpunit/Civi/Afform/FormDataModelTest.php @@ -39,7 +39,7 @@ class FormDataModelTest extends \PHPUnit\Framework\TestCase implements HeadlessI ]; $cases[] = [ - 'html' => '

', + 'html' => '

', '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' => '
', + 'html' => '
', '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 index 0000000000..99dd5256fb --- /dev/null +++ b/ext/afform/mock/tests/phpunit/api/v4/AfformRelationshipUsageTest.php @@ -0,0 +1,65 @@ + + + + +
+ +
+
+
+ +
+ + +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']); + } + +} -- 2.25.1