From 898b512a54f6d930220c254aa5e6755d39c9119d Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Sat, 6 Aug 2022 15:33:02 -0400 Subject: [PATCH] APIv4 Autocomplete - Add metadata and tests --- CRM/Contact/DAO/Contact.php | 4 +- CRM/Core/CodeGen/Specification.php | 3 + Civi/Api4/Generic/AutocompleteAction.php | 23 +++- Civi/Api4/Service/Spec/SpecFormatter.php | 14 +- ext/afform/core/Civi/Api4/Afform.php | 12 ++ .../phpunit/Civi/Afform/AfformGetTest.php | 17 +++ .../api/v4/Action/AutocompleteTest.php | 124 ++++++++++++++++++ tests/phpunit/api/v4/Action/GetFieldsTest.php | 7 +- tests/phpunit/api/v4/Entity/EntityTest.php | 6 + .../api/v4/Mock/Api4/MockBasicEntity.php | 1 + xml/schema/Contact/Contact.xml | 2 + 11 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 tests/phpunit/api/v4/Action/AutocompleteTest.php diff --git a/CRM/Contact/DAO/Contact.php b/CRM/Contact/DAO/Contact.php index 790dce543b..6126910bbe 100644 --- a/CRM/Contact/DAO/Contact.php +++ b/CRM/Contact/DAO/Contact.php @@ -6,7 +6,7 @@ * * Generated from xml/schema/CRM/Contact/Contact.xml * DO NOT EDIT. Generated by CRM_Core_CodeGen - * (GenCodeChecksum:6c4b31481898fef1b087265d096c65f6) + * (GenCodeChecksum:1a988e976c3347c4050d0b6aad955a09) */ /** @@ -602,6 +602,7 @@ class CRM_Contact_DAO_Contact extends CRM_Core_DAO { 'table' => 'civicrm_contact_type', 'keyColumn' => 'name', 'labelColumn' => 'label', + 'iconColumn' => 'icon', 'condition' => 'parent_id IS NULL', ], 'readonly' => TRUE, @@ -630,6 +631,7 @@ class CRM_Contact_DAO_Contact extends CRM_Core_DAO { 'table' => 'civicrm_contact_type', 'keyColumn' => 'name', 'labelColumn' => 'label', + 'iconColumn' => 'icon', 'condition' => 'parent_id IS NOT NULL', ], 'add' => '1.5', diff --git a/CRM/Core/CodeGen/Specification.php b/CRM/Core/CodeGen/Specification.php index 719db4ee52..a7ba04dbe8 100644 --- a/CRM/Core/CodeGen/Specification.php +++ b/CRM/Core/CodeGen/Specification.php @@ -447,6 +447,9 @@ class CRM_Core_CodeGen_Specification { 'nameColumn', // Column to fetch in "abbreviate" context 'abbrColumn', + // Supported by APIv4 suffixes + 'colorColumn', + 'iconColumn', // Where clause snippet (will be joined to the rest of the query with AND operator) 'condition', // callback function incase of static arrays diff --git a/Civi/Api4/Generic/AutocompleteAction.php b/Civi/Api4/Generic/AutocompleteAction.php index 0e406cfc75..59313b014e 100644 --- a/Civi/Api4/Generic/AutocompleteAction.php +++ b/Civi/Api4/Generic/AutocompleteAction.php @@ -16,6 +16,19 @@ use Civi\Api4\Utils\CoreUtil; /** * Retrieve $ENTITIES for an autocomplete form field. + * + * @method $this setInput(string $input) Set input term. + * @method string getInput() + * @method $this setIds(array $ids) Set array of ids. + * @method array getIds() + * @method $this setPage(int $page) Set current page. + * @method array getPage() + * @method $this setFormName(string $formName) Set formName. + * @method string getFormName() + * @method $this setFieldName(string $fieldName) Set fieldName. + * @method string getFieldName() + * @method $this setClientFilters(array $clientFilters) Set array of untrusted filter values. + * @method array getClientFilters() */ class AutocompleteAction extends AbstractAction { use Traits\SavedSearchInspectorTrait; @@ -110,6 +123,9 @@ class AutocompleteAction extends AbstractAction { // Adding one extra result allows us to see if there are any more $this->_apiParams['limit'] = $resultsPerPage + 1; $this->_apiParams['offset'] = ($this->page - 1) * $resultsPerPage; + + $orderBy = CoreUtil::getInfoItem($this->getEntityName(), 'order_by') ?: $labelField; + $this->_apiParams['orderBy'] = [$orderBy => 'ASC']; if (strlen($this->input)) { $prefix = \Civi::settings()->get('includeWildCardInName') ? '%' : ''; $this->_apiParams['where'][] = [$labelField, 'LIKE', $prefix . $this->input . '%']; @@ -131,9 +147,14 @@ class AutocompleteAction extends AbstractAction { foreach ($map as $key => $fieldName) { $mapped[$key] = $row[$fieldName]; } + // Get icon in order of priority foreach ($iconFields as $fieldName) { if (!empty($row[$fieldName])) { - $mapped['icon'] = $row[$fieldName]; + // Icon field may be multivalued e.g. contact_sub_type + $icon = \CRM_Utils_Array::first(array_filter((array) $row[$fieldName])); + if ($icon) { + $mapped['icon'] = $icon; + } break; } } diff --git a/Civi/Api4/Service/Spec/SpecFormatter.php b/Civi/Api4/Service/Spec/SpecFormatter.php index fb0b83a736..81ac207389 100644 --- a/Civi/Api4/Service/Spec/SpecFormatter.php +++ b/Civi/Api4/Service/Spec/SpecFormatter.php @@ -197,7 +197,6 @@ class SpecFormatter { $returnFormat = array_diff($returnFormat, ['id', 'name', 'label']); // CRM_Core_Pseudoconstant doesn't know how to fetch extra stuff like icon, description, color, etc., so we have to invent that wheel here... if ($returnFormat) { - $optionIds = implode(',', array_column($options, 'id')); $optionIndex = array_flip(array_column($options, 'id')); if ($spec instanceof CustomFieldSpec) { $optionGroupId = \CRM_Core_DAO::getFieldValue('CRM_Core_DAO_CustomField', $spec->getCustomFieldId(), 'option_group_id'); @@ -229,15 +228,18 @@ class SpecFormatter { $returnFormat = array_diff($returnFormat, ['abbr']); } // Fetch anything else (color, icon, description) - if ($returnFormat && !empty($pseudoconstant['table']) && \CRM_Utils_Rule::commaSeparatedIntegers($optionIds)) { - $sql = "SELECT * FROM {$pseudoconstant['table']} WHERE id IN (%1)"; - $query = \CRM_Core_DAO::executeQuery($sql, [1 => [$optionIds, 'CommaSeparatedIntegers']]); + if ($returnFormat && !empty($pseudoconstant['table'])) { + $idCol = $pseudoconstant['keyColumn'] ?? 'id'; + $optionIds = \CRM_Core_DAO::escapeStrings(array_column($options, 'id')); + $sql = "SELECT * FROM {$pseudoconstant['table']} WHERE `$idCol` IN ($optionIds)"; + $query = \CRM_Core_DAO::executeQuery($sql); while ($query->fetch()) { foreach ($returnFormat as $ret) { - if (property_exists($query, $ret)) { + $retCol = $pseudoconstant[$ret . 'Column'] ?? $ret; + if (property_exists($query, $retCol)) { // Note: our schema is inconsistent about whether `description` fields allow html, // but it's usually assumed to be plain text, so we strip_tags() to standardize it. - $options[$optionIndex[$query->id]][$ret] = $ret === 'description' ? strip_tags($query->$ret ?? '') : $query->$ret; + $options[$optionIndex[$query->$idCol]][$ret] = isset($query->$retCol) ? strip_tags($query->$retCol) : NULL; } } } diff --git a/ext/afform/core/Civi/Api4/Afform.php b/ext/afform/core/Civi/Api4/Afform.php index 0f71813b78..8da02bd89d 100644 --- a/ext/afform/core/Civi/Api4/Afform.php +++ b/ext/afform/core/Civi/Api4/Afform.php @@ -2,6 +2,7 @@ namespace Civi\Api4; +use Civi\Api4\Generic\AutocompleteAction; use Civi\Api4\Generic\BasicGetFieldsAction; /** @@ -16,6 +17,8 @@ use Civi\Api4\Generic\BasicGetFieldsAction; * The `prefill` and `submit` actions are used for preparing forms and processing submissions. * * @see https://lab.civicrm.org/extensions/afform + * @labelField title + * @iconField type:icon * @searchable none * @package Civi\Api4 */ @@ -57,6 +60,15 @@ class Afform extends Generic\AbstractEntity { ->setCheckPermissions($checkPermissions); } + /** + * @param bool $checkPermissions + * @return \Civi\Api4\Generic\AutocompleteAction + */ + public static function autocomplete($checkPermissions = TRUE) { + return (new AutocompleteAction('Afform', __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + /** * @param bool $checkPermissions * @return Action\Afform\Convert diff --git a/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php b/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php index 8be0dc3fb1..3218f02869 100644 --- a/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php +++ b/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php @@ -55,6 +55,23 @@ class AfformGetTest extends \PHPUnit\Framework\TestCase implements HeadlessInter $this->assertArrayNotHasKey('base_module', $result); } + public function testAfformAutocomplete(): void { + $title = uniqid(); + Afform::create() + ->addValue('name', $this->formName) + ->addValue('title', $title) + ->addValue('type', 'form') + ->execute(); + + $result = Afform::autocomplete() + ->setInput(substr($title, 0, 9)) + ->execute(); + + $this->assertEquals($this->formName, $result[0]['id']); + $this->assertEquals($title, $result[0]['label']); + $this->assertEquals('fa-list-alt', $result[0]['icon']); + } + public function testGetSearchDisplays() { Afform::create() ->addValue('name', $this->formName) diff --git a/tests/phpunit/api/v4/Action/AutocompleteTest.php b/tests/phpunit/api/v4/Action/AutocompleteTest.php new file mode 100644 index 0000000000..63dc9b4448 --- /dev/null +++ b/tests/phpunit/api/v4/Action/AutocompleteTest.php @@ -0,0 +1,124 @@ +entities['MockBasicEntity'] = MockBasicEntity::getInfo(); + } + + public function setUp(): void { + // Ensure MockBasicEntity gets added via above listener + \Civi::cache('metadata')->clear(); + MockBasicEntity::delete(FALSE)->addWhere('identifier', '>', 0)->execute(); + \Civi::settings()->set('includeWildCardInName', 1); + parent::setUp(); + } + + public function testMockEntityAutocomplete(): void { + $sampleData = [ + ['foo' => 'White', 'color' => 'ffffff'], + ['foo' => 'Gray', 'color' => '777777'], + ['foo' => 'Black', 'color' => '000000'], + ]; + $entities = MockBasicEntity::save(FALSE) + ->setRecords($sampleData) + ->execute(); + + $result = MockBasicEntity::autocomplete() + ->setInput('a') + ->execute(); + $this->assertCount(2, $result); + $this->assertEquals('Black', $result[0]['label']); + $this->assertEquals('777777', $result[1]['color']); + + $result = MockBasicEntity::autocomplete() + ->setInput('ite') + ->execute(); + $this->assertCount(1, $result); + $this->assertEquals($entities[0]['identifier'], $result[0]['id']); + $this->assertEquals('ffffff', $result[0]['color']); + $this->assertEquals('White', $result[0]['label']); + } + + public function testContactIconAutocomplete(): void { + $this->createTestRecord('ContactType', [ + 'label' => 'Star', + 'name' => 'Star', + 'parent_id:name' => 'Individual', + 'icon' => 'fa-star', + ]); + $this->createTestRecord('ContactType', [ + 'label' => 'None', + 'name' => 'None', + 'parent_id:name' => 'Individual', + 'icon' => NULL, + ]); + + $lastName = uniqid(__FUNCTION__); + $sampleData = [ + [ + 'first_name' => 'Starry', + 'contact_sub_type' => ['Star'], + ], + [ + 'first_name' => 'No icon', + 'contact_sub_type' => ['None'], + ], + [ + 'first_name' => 'Both', + 'contact_sub_type' => ['None', 'Star'], + ], + ]; + $records = $this->saveTestRecords('Contact', [ + 'records' => $sampleData, + 'defaults' => ['last_name' => $lastName], + ]); + + $result = Contact::autocomplete() + ->setInput($lastName) + ->execute(); + + // Contacts will be returned in order by sort_name + $this->assertStringStartsWith('Both', $result[0]['label']); + $this->assertEquals('fa-star', $result[0]['icon']); + $this->assertStringStartsWith('No icon', $result[1]['label']); + $this->assertEquals('fa-user', $result[1]['icon']); + $this->assertStringStartsWith('Starry', $result[2]['label']); + $this->assertEquals('fa-star', $result[2]['icon']); + } + +} diff --git a/tests/phpunit/api/v4/Action/GetFieldsTest.php b/tests/phpunit/api/v4/Action/GetFieldsTest.php index 981780fa96..657f51fcfd 100644 --- a/tests/phpunit/api/v4/Action/GetFieldsTest.php +++ b/tests/phpunit/api/v4/Action/GetFieldsTest.php @@ -46,12 +46,17 @@ class GetFieldsTest extends Api4TestBase implements TransactionalInterface { $this->assertFalse($fields['first_name']['options']); } - public function testTableAndColumnReturned() { + public function testContactGetFields() { $fields = Contact::getFields(FALSE) ->execute() ->indexBy('name'); + // Ensure table & column are returned $this->assertEquals('civicrm_contact', $fields['display_name']['table_name']); $this->assertEquals('display_name', $fields['display_name']['column_name']); + + // Check suffixes + $this->assertEquals(['name', 'label', 'icon'], $fields['contact_type']['suffixes']); + $this->assertEquals(['name', 'label', 'icon'], $fields['contact_sub_type']['suffixes']); } public function testComponentFields() { diff --git a/tests/phpunit/api/v4/Entity/EntityTest.php b/tests/phpunit/api/v4/Entity/EntityTest.php index 1bd6f6e916..0d7c437a4e 100644 --- a/tests/phpunit/api/v4/Entity/EntityTest.php +++ b/tests/phpunit/api/v4/Entity/EntityTest.php @@ -37,6 +37,12 @@ class EntityTest extends Api4TestBase { "Entity::get missing itself"); $this->assertArrayHasKey('Participant', $result, "Entity::get missing Participant"); + + $this->assertEquals('CRM_Contact_DAO_Contact', $result['Contact']['dao']); + $this->assertEquals(['DAOEntity'], $result['Contact']['type']); + $this->assertEquals(['id'], $result['Contact']['primary_key']); + // Contact icon fields + $this->assertEquals(['contact_sub_type:icon', 'contact_type:icon'], $result['Contact']['icon_field']); } public function testEntity() { diff --git a/tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php b/tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php index a9c832e050..0fd9d6849c 100644 --- a/tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php +++ b/tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php @@ -24,6 +24,7 @@ use api\v4\Mock\MockEntityDataStorage; /** * MockBasicEntity entity. * + * @labelField foo * @package Civi\Api4 */ class MockBasicEntity extends Generic\BasicEntity { diff --git a/xml/schema/Contact/Contact.xml b/xml/schema/Contact/Contact.xml index 4b9c1019fd..69b694642c 100644 --- a/xml/schema/Contact/Contact.xml +++ b/xml/schema/Contact/Contact.xml @@ -44,6 +44,7 @@ civicrm_contact_type
name label + icon parent_id IS NULL @@ -72,6 +73,7 @@ civicrm_contact_type
name label + icon parent_id IS NOT NULL -- 2.25.1