APIv4 Autocomplete - Add metadata and tests
authorColeman Watts <coleman@civicrm.org>
Sat, 6 Aug 2022 19:33:02 +0000 (15:33 -0400)
committerColeman Watts <coleman@civicrm.org>
Wed, 10 Aug 2022 02:28:44 +0000 (22:28 -0400)
CRM/Contact/DAO/Contact.php
CRM/Core/CodeGen/Specification.php
Civi/Api4/Generic/AutocompleteAction.php
Civi/Api4/Service/Spec/SpecFormatter.php
ext/afform/core/Civi/Api4/Afform.php
ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php
tests/phpunit/api/v4/Action/AutocompleteTest.php [new file with mode: 0644]
tests/phpunit/api/v4/Action/GetFieldsTest.php
tests/phpunit/api/v4/Entity/EntityTest.php
tests/phpunit/api/v4/Mock/Api4/MockBasicEntity.php
xml/schema/Contact/Contact.xml

index 790dce543b51c191fd61500adbb2af06174cba31..6126910bbe5444204264f88f57809f1f9a477fec 100644 (file)
@@ -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',
index 719db4ee524328c31b6b8caa44ea0b1c5e7ba7b6..a7ba04dbe805bd72b46160ebe9e9b6a8f9e04d6a 100644 (file)
@@ -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
index 0e406cfc75e0d4f55b0cde55368a723b56d13b4b..59313b014e861e7962ffbfd1ab937692d9a47ce0 100644 (file)
@@ -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;
         }
       }
index fb0b83a7360e419fec7b1fefe31bde3c37e0509b..81ac207389f107335feae47479a2bb8bac5af885 100644 (file)
@@ -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;
               }
             }
           }
index 0f71813b78497a4ac8111e8bf5109917a8f23ec2..8da02bd89d2f04892af08888f99c0c64d4c3e6e2 100644 (file)
@@ -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
index 8be0dc3fb128d67f62df18e8983d8ad90b561b45..3218f02869556f6937c5cdcd472e2be36fb6a6f5 100644 (file)
@@ -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 (file)
index 0000000..63dc9b4
--- /dev/null
@@ -0,0 +1,124 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+
+
+namespace api\v4\Action;
+
+use api\v4\Api4TestBase;
+use Civi\Api4\Contact;
+use Civi\Api4\MockBasicEntity;
+use Civi\Core\Event\GenericHookEvent;
+use Civi\Test\HookInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class AutocompleteTest extends Api4TestBase implements HookInterface, TransactionalInterface {
+
+  /**
+   * Listens for civi.api4.entityTypes event to manually add this nonstandard entity
+   *
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   */
+  public function on_civi_api4_entityTypes(GenericHookEvent $e): void {
+    $e->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']);
+  }
+
+}
index 981780fa963ba1e877ff26cb219ffcf8d09a1318..657f51fcfd892b8e5f15bcad5697d239984437e0 100644 (file)
@@ -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() {
index 1bd6f6e91611356cd41e7ae604edd8b008d2179e..0d7c437a4e8218e7d9728aa586748b2bdf35eb1e 100644 (file)
@@ -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() {
index a9c832e0507f8c113ad372de374e4127ccaeeb54..0fd9d6849cd10831c20a1afba69501861794f418 100644 (file)
@@ -24,6 +24,7 @@ use api\v4\Mock\MockEntityDataStorage;
 /**
  * MockBasicEntity entity.
  *
+ * @labelField foo
  * @package Civi\Api4
  */
 class MockBasicEntity extends Generic\BasicEntity {
index 4b9c1019fdc9904923d121369084d93334361bc2..69b694642c8ca7e5f2999e1d133b15630f18171f 100644 (file)
@@ -44,6 +44,7 @@
       <table>civicrm_contact_type</table>
       <keyColumn>name</keyColumn>
       <labelColumn>label</labelColumn>
+      <iconColumn>icon</iconColumn>
       <condition>parent_id IS NULL</condition>
     </pseudoconstant>
     <html>
@@ -72,6 +73,7 @@
       <table>civicrm_contact_type</table>
       <keyColumn>name</keyColumn>
       <labelColumn>label</labelColumn>
+      <iconColumn>icon</iconColumn>
       <condition>parent_id IS NOT NULL</condition>
     </pseudoconstant>
     <html>