$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) {
}
}
}
- 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;
}
/**
'name' => 'blocks',
'data_type' => 'Array',
],
+ [
+ 'name' => 'entities',
+ 'data_type' => 'Array',
+ ],
[
'name' => 'fields',
'data_type' => 'Array',
--- /dev/null
+<?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]],
+ ],
+];
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) {
};
$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() {
* 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
*/
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
*/
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[]
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);
use Civi\Afform\Event\AfformSubmitEvent;
use Civi\Api4\AfformSubmission;
+use Civi\Api4\RelationshipType;
use Civi\Api4\Utils\CoreUtil;
/**
$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
}
$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'];
}
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);
}
}
}
}
}
+ /**
+ * @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.
*
$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']);
];
$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',
'joins' => [],
'security' => 'RBAC',
'actions' => ['create' => 1, 'update' => 1],
+ 'min' => 0,
+ 'max' => NULL,
],
],
];
'joins' => [],
'security' => 'RBAC',
'actions' => ['create' => 1, 'update' => 1],
+ 'min' => 1,
+ 'max' => 1,
],
'whiz_bang' => [
'type' => 'Whiz',
'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',
'joins' => [],
'security' => 'FBAC',
'actions' => ['create' => FALSE, 'update' => TRUE],
+ 'min' => 1,
+ 'max' => 2,
],
],
];
--- /dev/null
+<?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']);
+ }
+
+}