Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dev/core#5053 Afform - Add support for saving event location #30140

Merged
merged 7 commits into from
Aug 21, 2024
Merged
Prev Previous commit
Next Next commit
Afform - Support autocomplete+prefill of joined entities
  • Loading branch information
colemanw committed Aug 10, 2024
commit e5dbfd5fae39bb802aeb461b22d44ddf16b56375
149 changes: 100 additions & 49 deletions ext/afform/core/Civi/Api4/Action/Afform/AbstractProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,16 @@ protected function loadEntities() {
if ($ids) {
// If 'update' (or 'create' in special cases like 'template_id') is allowed, load entity.
$matchField = self::getNestedKey($ids) ?: $idField;
$matchFieldDefn = $this->_formDataModel->getField($entity['type'], $matchField, 'create');
$autofillMode = $matchFieldDefn['input_attrs']['autofill'] ?? NULL;
if (!empty($entity['actions'][$autofillMode])) {
if (!empty($entity['url-autofill']) || isset($entity['fields'][$matchField])) {
$this->loadEntity($entity, $ids, $autofillMode);
if ($matchField === 'joins') {
$this->loadJoin($entity, $ids);
}
else {
$matchFieldDefn = $this->_formDataModel->getField($entity['type'], $matchField, 'create');
$autofillMode = $matchFieldDefn['input_attrs']['autofill'] ?? NULL;
if (!empty($entity['actions'][$autofillMode])) {
if (!empty($entity['url-autofill']) || isset($entity['fields'][$matchField])) {
$this->loadEntity($entity, $ids, $autofillMode);
}
}
}
}
Expand Down Expand Up @@ -184,14 +189,13 @@ public function loadEntity(array $entity, array $values, string $mode = 'update'
// In create mode, use id as the key
$keyField = $mode === 'create' ? CoreUtil::getIdFieldName($entity['name']) : $matchField;

$api4 = $this->_formDataModel->getSecureApi4($entity['name']);
if ($keys && !empty($entity['fields'][$keyField]['defn']['saved_search'])) {
$keys = $this->validateBySavedSearch($entity, $keys, $matchField);
$keys = $this->validateBySavedSearch($entity['name'], $entity['type'], $keys, $matchField);
}
if (!$keys) {
return;
}
$result = $this->apiGet($api4, $entity['type'], $entity['fields'], $keyField, [
$result = $this->apiGet($entity['name'], $entity['type'], $entity['fields'], $keyField, [
'where' => [[$keyField, 'IN', $keys]],
]);
$idField = CoreUtil::getIdFieldName($entity['type']);
Expand All @@ -213,61 +217,107 @@ public function loadEntity(array $entity, array $values, string $mode = 'update'
if (!empty($result[$key])) {
$data = ['fields' => $result[$key]];
foreach ($entity['joins'] ?? [] as $joinEntity => $join) {
$joinIdField = CoreUtil::getIdFieldName($joinEntity);
$multipleLocationBlocks = is_array($join['data']['location_type_id'] ?? NULL);
$limit = 1;
// Repeating blocks - set limit according to `max`, if set, otherwise 0 for unlimited
if (!empty($join['af-repeat'])) {
$limit = $join['max'] ?? 0;
}
// Remove limit when handling multiple location blocks
if ($multipleLocationBlocks) {
$limit = 0;
}
$where = self::getJoinWhereClause($this->_formDataModel, $entity['name'], $joinEntity, $entityId);
if ($where) {
$joinResult = $this->apiGet($api4, $joinEntity, $join['fields'] + ($join['data'] ?? []), $joinIdField, [
'where' => $where,
'limit' => $limit,
'orderBy' => self::getEntityField($joinEntity, 'is_primary') ? ['is_primary' => 'DESC'] : [],
]);
}
else {
$joinResult = [];
}
// Sort into multiple location blocks
if ($multipleLocationBlocks) {
$items = array_column($joinResult, NULL, 'location_type_id');
$joinResult = [];
foreach ($join['data']['location_type_id'] as $locationType) {
$joinResult[] = $items[$locationType] ?? [];
$data['joins'][$joinEntity] = $this->loadJoins($joinEntity, $join, $entity, $entityId, $index);
}
$this->_entityValues[$entity['name']][$index] = $data;
}
}
}

/**
* Finds all joins after loading an entity.
*/
public function loadJoins($joinEntity, $join, $afEntity, $entityId, $index): array {
$joinIdField = CoreUtil::getIdFieldName($joinEntity);
$multipleLocationBlocks = is_array($join['data']['location_type_id'] ?? NULL);
$limit = 1;
// Repeating blocks - set limit according to `max`, if set, otherwise 0 for unlimited
if (!empty($join['af-repeat'])) {
$limit = $join['max'] ?? 0;
}
// Remove limit when handling multiple location blocks
if ($multipleLocationBlocks) {
$limit = 0;
}
$where = self::getJoinWhereClause($this->_formDataModel, $afEntity['name'], $joinEntity, $entityId);
if ($where) {
$joinResult = $this->getJoinResult($afEntity, $joinEntity, $join, $where, $limit);
}
else {
$joinResult = [];
}
// Sort into multiple location blocks
if ($multipleLocationBlocks) {
$items = array_column($joinResult, NULL, 'location_type_id');
$joinResult = [];
foreach ($join['data']['location_type_id'] as $locationType) {
$joinResult[] = $items[$locationType] ?? [];
}
}
$this->_entityIds[$afEntity['name']][$index]['joins'][$joinEntity] = \CRM_Utils_Array::filterColumns($joinResult, [$joinIdField]);
return array_values($joinResult);
}

/**
* Directly loads a join entity e.g. from an autocomplete field in the join block.
*/
private function loadJoin(array $afEntity, array $values): array {
$joinResult = [];
foreach ($values as $entityIndex => $value) {
foreach ($value['joins'] as $joinEntity => $joins) {
$joinIdField = CoreUtil::getIdFieldName($joinEntity);
$joinInfo = $afEntity['joins'][$joinEntity] ?? [];
foreach ($joins as $joinIndex => $join) {
foreach ($join as $fieldName => $fieldValue) {
if (!empty($joinInfo['fields'][$fieldName])) {
$where = [[$fieldName, '=', $fieldValue]];
$joinResult = $this->getJoinResult($afEntity, $joinEntity, $joinInfo, $where, 1);
$this->_entityIds[$afEntity['name']][$entityIndex]['joins'][$joinEntity] = \CRM_Utils_Array::filterColumns($joinResult, [$joinIdField]);
$this->_entityValues[$afEntity['name']][$entityIndex]['joins'][$joinEntity] = array_values($joinResult);
}
}
$data['joins'][$joinEntity] = array_values($joinResult);
$this->_entityIds[$entity['name']][$index]['joins'][$joinEntity] = \CRM_Utils_Array::filterColumns($joinResult, [$joinIdField]);
}
$this->_entityValues[$entity['name']][$index] = $data;
}
}
return array_values($joinResult);
}

public function getJoinResult(array $afEntity, string $joinEntity, array $join, array $where, int $limit): array {
$joinIdField = CoreUtil::getIdFieldName($joinEntity);
$joinResult = $this->apiGet($afEntity['name'], $joinEntity, $join['fields'] + ($join['data'] ?? []), $joinIdField, [
'where' => $where,
'limit' => $limit,
'orderBy' => self::getEntityField($joinEntity, 'is_primary') ? ['is_primary' => 'DESC'] : [],
]);
// Validate autocomplete fields
if ($joinResult && !empty($entity['joins'][$joinEntity]['fields'][$joinIdField]['defn']['saved_search'])) {
$keys = array_combine(array_keys($joinResult), array_column($joinResult, $joinIdField));
$keys = $this->validateBySavedSearch($entity['name'], $joinEntity, $keys, $joinIdField);
$joinResult = array_intersect_key($joinResult, $keys);
}
return $joinResult;
}

/**
* Delegated by loadEntity to call API.get and fill in additioal info
*
* @param callable $api4
* @param string $entityName
* @param string $afEntityName
* e.g. Individual1
* @param string $apiEntityName
* Not necessarily the api of the afEntity, in the case of joins it will be different.
* @param array $entityFields
* @param string $keyField
* @param array $params
* @return array
*/
private function apiGet($api4, $entityName, $entityFields, string $keyField, $params) {
$idField = CoreUtil::getIdFieldName($entityName);
private function apiGet($afEntityName, $apiEntityName, $entityFields, string $keyField, $params) {
$api4 = $this->_formDataModel->getSecureApi4($afEntityName);
$idField = CoreUtil::getIdFieldName($apiEntityName);
// Ensure 'id' is selected
$params['select'] = array_unique(array_merge([$idField], array_keys($entityFields)));
$result = (array) $api4($entityName, 'get', $params)->indexBy($keyField);
$result = (array) $api4($apiEntityName, 'get', $params)->indexBy($keyField);
// Fill additional info about file fields
$fileFields = $this->getFileFields($entityName, $entityFields);
$fileFields = $this->getFileFields($apiEntityName, $entityFields);
foreach ($fileFields as $fieldName => $fieldDefn) {
foreach ($result as &$item) {
if (!empty($item[$fieldName])) {
Expand Down Expand Up @@ -297,17 +347,18 @@ protected static function getFileFields($entityName, $entityFields): array {
/**
* Validate that given id(s) are actually returned by the Autocomplete API
*
* @param array $entity
* @param string $afEntityName
* @param string $apiEntity
* @param array $ids
* @param string $matchField
* @return array
* @throws \CRM_Core_Exception
*/
private function validateBySavedSearch(array $entity, array $ids, string $matchField) {
$fetched = civicrm_api4($entity['type'], 'autocomplete', [
private function validateBySavedSearch(string $afEntityName, string $apiEntity, array $ids, string $matchField) {
$fetched = civicrm_api4($apiEntity, 'autocomplete', [
'ids' => $ids,
'formName' => 'afform:' . $this->name,
'fieldName' => $entity['name'] . ':' . $matchField,
'fieldName' => $afEntityName . ':' . $matchField,
])->indexBy($matchField);
$validIds = [];
// Preserve keys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ private function processAfformAutocomplete(string $formName, string $entityName,
$formDataModel = new FormDataModel($afform['layout']);
[$entityName, $joinEntity] = array_pad(explode('+', $entityName), 2, NULL);
$entity = $formDataModel->getEntity($entityName);
$isId = FALSE;

// If no model entity, it's a search display
if (!$entity) {
Expand All @@ -88,7 +89,6 @@ private function processAfformAutocomplete(string $formName, string $entityName,
// If using a join (e.g. Contact -> Email)
elseif ($joinEntity) {
$apiEntity = $joinEntity;
$isId = FALSE;
$formField = $entity['joins'][$joinEntity]['fields'][$fieldName]['defn'] ?? [];
}
else {
Expand Down
10 changes: 8 additions & 2 deletions ext/afform/core/ang/af/afField.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@
const entity = ctrl.afFieldset.modelName;
const entityIndex = ctrl.getEntityIndex();
const joinEntity = ctrl.afJoin ? ctrl.afJoin.entity : null;
const joinIndex = ctrl.afJoin && $scope.dataProvider.repeatIndex || null;
const joinIndex = ctrl.afJoin && $scope.dataProvider.repeatIndex || 0;
ctrl.afFieldset.afFormCtrl.loadData(entity, entityIndex, val, ctrl.defn.name, joinEntity, joinIndex);
}
};
Expand All @@ -272,9 +272,15 @@
};

ctrl.getAutocompleteParams = function() {
let fieldName = ctrl.afFieldset.getName();
// Append join name which will be unpacked by AfformAutocompleteSubscriber::processAfformAutocomplete
if (ctrl.afJoin) {
fieldName += '+' + ctrl.afJoin.entity;
}
fieldName += ':' + ctrl.fieldName;
return {
formName: 'afform:' + ctrl.afFieldset.getFormName(),
fieldName: ctrl.afFieldset.getName() + ':' + ctrl.fieldName,
fieldName: fieldName,
values: $scope.dataProvider.getFieldData()
};
};
Expand Down
13 changes: 10 additions & 3 deletions ext/afform/core/ang/af/afForm.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,22 @@
// With no arguments this will prefill the entire form based on url args
// and also check if the form is open for submissions.
// With selectedEntity, selectedIndex & selectedId provided this will prefill a single entity
this.loadData = function(selectedEntity, selectedIndex, selectedId, selectedField) {
this.loadData = function(selectedEntity, selectedIndex, selectedId, selectedField, joinEntity, joinIndex) {
let toLoad = true;
const params = {name: ctrl.getFormMeta().name, args: {}};
// Load single entity
if (selectedEntity) {
toLoad = !!selectedId;
params.matchField = selectedField;
params.args[selectedEntity] = {};
params.args[selectedEntity][selectedIndex] = selectedId;
params.args[selectedEntity][selectedIndex] = {};
if (joinEntity) {
params.args[selectedEntity][selectedIndex].joins = {};
params.args[selectedEntity][selectedIndex].joins[joinEntity] = {};
params.args[selectedEntity][selectedIndex].joins[joinEntity][joinIndex] = {};
params.args[selectedEntity][selectedIndex].joins[joinEntity][joinIndex][selectedField] = selectedId;
} else {
params.args[selectedEntity][selectedIndex][selectedField] = selectedId;
}
}
// Prefill entire form
else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
*/
class AfformAutocompleteUsageTest extends AfformUsageTestCase {

public function tearDown(): void {
CustomGroup::delete(FALSE)
->addWhere('id', '>', 0)
->execute();
parent::tearDown();
}

/**
* Ensure that Afform restricts autocomplete results when it's set to use a SavedSearch
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,21 @@ class AfformEventUsageTest extends AfformUsageTestCase {
* Tests prefilling an event from a template
*/
public function testEventTemplatePrefill(): void {
$locBlock = $this->createTestEntity('LocBlock', [
$locBlock1 = $this->createTestEntity('LocBlock', [
'email_id' => $this->createTestEntity('Email', ['email' => '1@te.st'])['id'],
'phone_id' => $this->createTestEntity('Phone', ['phone' => '1234567'])['id'],
]);
$locBlock2 = $this->createTestEntity('LocBlock', [
'email_id' => $this->createTestEntity('Email', ['email' => '2@te.st'])['id'],
'phone_id' => $this->createTestEntity('Phone', ['phone' => '2234567'])['id'],
]);

$eventTemplate = $this->createTestEntity('Event', [
'template_title' => 'Test Template Title',
'title' => 'Test Me',
'event_type_id' => 1,
'is_template' => TRUE,
'loc_block_id' => $locBlock['id'],
'loc_block_id' => $locBlock1['id'],
]);

$layout = <<<EOHTML
Expand All @@ -48,17 +52,25 @@ public function testEventTemplatePrefill(): void {
'permission' => \CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION,
]);

// Prefill from template
$prefill = Afform::prefill()
->setName($this->formName)
->setArgs(['Event1' => [['template_id' => $eventTemplate['id']]]])
->execute()->single();

$this->assertSame('Test Me', $prefill['values'][0]['fields']['title']);
$this->assertSame($eventTemplate['event_type_id'], $prefill['values'][0]['fields']['event_type_id']);
$this->assertSame($eventTemplate['id'], $prefill['values'][0]['fields']['template_id']);
$this->assertArrayNotHasKey('id', $prefill['values'][0]['fields']);
$this->assertSame('1@te.st', $prefill['values'][0]['joins']['LocBlock'][0]['email_id.email']);
$this->assertSame('1234567', $prefill['values'][0]['joins']['LocBlock'][0]['phone_id.phone']);

// Prefill just the locBlock
$prefill = Afform::prefill()
->setName($this->formName)
->setArgs(['Event1' => [['joins' => ['LocBlock' => [['id' => $locBlock2['id']]]]]]])
->execute()->single();
$this->assertSame('2@te.st', $prefill['values'][0]['joins']['LocBlock'][0]['email_id.email']);
$this->assertSame('2234567', $prefill['values'][0]['joins']['LocBlock'][0]['phone_id.phone']);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace api\v4\Afform;

use Civi\Api4\Afform;
use Civi\Api4\CustomGroup;

/**
* Test case for Afform.prefill and Afform.submit.
Expand All @@ -28,6 +29,9 @@ public function tearDown(): void {
Afform::revert(FALSE)
->addWhere('name', '=', $this->formName)
->execute();
CustomGroup::delete(FALSE)
->addWhere('id', '>', 0)
->execute();
parent::tearDown();
}

Expand Down