APIv4 - Add 'suffixes' to getFields metadata
authorColeman Watts <coleman@civicrm.org>
Thu, 19 Aug 2021 14:22:13 +0000 (10:22 -0400)
committerColeman Watts <coleman@civicrm.org>
Fri, 20 Aug 2021 15:24:31 +0000 (11:24 -0400)
This breaks apart the concept of a field having 'options' vs
a field supporting suffixes like campaign_id:label.

It is now possible for a field to not have options but still support suffixes.

This also makes the available suffixes for each field discoverable,
e.g. fields like state_province_id support an :abbr suffix.

Civi/Api4/Generic/BasicGetFieldsAction.php
Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php
Civi/Api4/Service/Spec/Provider/EntityTagFilterSpecProvider.php
Civi/Api4/Service/Spec/SpecFormatter.php
Civi/Schema/Traits/OptionsSpecTrait.php
ext/afform/core/Civi/Api4/Afform.php
tests/phpunit/api/v4/Action/BasicActionsTest.php
tests/phpunit/api/v4/Action/GetFieldsTest.php
tests/phpunit/api/v4/Action/PseudoconstantTest.php

index c64be64977a6594d42199614da4c2b87a7926c10..7bb18411efc4a117cfc3952279805887e8c7cd4c 100644 (file)
@@ -135,6 +135,9 @@ class BasicGetFieldsAction extends BasicGetAction {
       if (array_key_exists('label', $fieldDefaults)) {
         $field['label'] = $field['label'] ?? $field['title'] ?? $field['name'];
       }
+      if (!empty($field['options']) && is_array($field['options']) && empty($field['suffixes']) && array_key_exists('suffixes', $field)) {
+        $this->setFieldSuffixes($field);
+      }
       if (isset($defaults['options'])) {
         $field['options'] = $this->formatOptionList($field['options']);
       }
@@ -183,6 +186,22 @@ class BasicGetFieldsAction extends BasicGetAction {
     return $formatted;
   }
 
+  /**
+   * Set supported field suffixes based on available option keys
+   * @param array $field
+   */
+  private function setFieldSuffixes(array &$field) {
+    // These suffixes are always supported if a field has options
+    $field['suffixes'] = ['name', 'label'];
+    $firstOption = reset($field['options']);
+    // If first option is an array, merge in those keys as available suffixes
+    if (is_array($firstOption)) {
+      // Remove 'id' because there is no practical reason to use it as a field suffix
+      $otherKeys = array_diff(array_keys($firstOption), ['id', 'name', 'label']);
+      $field['suffixes'] = array_merge($field['suffixes'], $otherKeys);
+    }
+  }
+
   /**
    * @return string
    */
@@ -275,6 +294,13 @@ class BasicGetFieldsAction extends BasicGetAction {
         'data_type' => 'Array',
         'default_value' => FALSE,
       ],
+      [
+        'name' => 'suffixes',
+        'data_type' => 'Array',
+        'default_value' => NULL,
+        'options' => ['name', 'label', 'description', 'abbr', 'color', 'icon'],
+        'description' => 'Available option transformations, e.g. :name, :label',
+      ],
       [
         'name' => 'operators',
         'data_type' => 'Array',
index 2d178bf4bdea58a15f9609eb9a38d508eac3d2a6..1e41abccba094b3626482b8381390c3c97e9d571 100644 (file)
@@ -30,6 +30,7 @@ class ContactGetSpecProvider implements Generic\SpecProviderInterface {
       ->setType('Filter')
       ->setOperators(['IN', 'NOT IN'])
       ->addSqlFilter([__CLASS__, 'getContactGroupSql'])
+      ->setSuffixes(['id', 'name', 'label'])
       ->setOptionsCallback([__CLASS__, 'getGroupList']);
     $spec->addFieldSpec($field);
   }
index dbe6efab673a655ac04b6edb219eb9447ad6e219..be1aad01e79dc7d2062e9f5d4e70fc5ac2166155 100644 (file)
@@ -33,6 +33,7 @@ class EntityTagFilterSpecProvider implements Generic\SpecProviderInterface {
       ->setType('Filter')
       ->setOperators(['IN', 'NOT IN'])
       ->addSqlFilter([__CLASS__, 'getTagFilterSql'])
+      ->setSuffixes(['id', 'name', 'label', 'description', 'color'])
       ->setOptionsCallback([__CLASS__, 'getTagList']);
     $spec->addFieldSpec($field);
   }
index 6eae4b396d4f412a0a4e019d62dfbf7716736401..27a1e3bdc1766edaaada6c163efdd4d866f92dae 100644 (file)
@@ -43,6 +43,11 @@ class SpecFormatter {
       $field->setHelpPost($data['help_post'] ?? NULL);
       if (self::customFieldHasOptions($data)) {
         $field->setOptionsCallback([__CLASS__, 'getOptions']);
+        if (!empty($data['option_group_id'])) {
+          // Option groups support other stuff like description, icon & color,
+          // but at time of this writing, custom fields do not.
+          $field->setSuffixes(['id', 'name', 'label']);
+        }
       }
       $field->setReadonly($data['is_view']);
     }
@@ -55,7 +60,19 @@ class SpecFormatter {
       $field->setTitle($data['title'] ?? NULL);
       $field->setLabel($data['html']['label'] ?? NULL);
       if (!empty($data['pseudoconstant'])) {
-        $field->setOptionsCallback([__CLASS__, 'getOptions']);
+        // Do not load options if 'prefetch' is explicitly FALSE
+        if ($data['pseudoconstant']['prefetch'] ?? TRUE) {
+          $field->setOptionsCallback([__CLASS__, 'getOptions']);
+        }
+        // These suffixes are always supported if a field has options
+        $suffixes = ['name', 'label'];
+        // Add other columns specified in schema (e.g. 'abbrColumn')
+        foreach (['description', 'abbr', 'icon', 'color'] as $suffix) {
+          if (isset($data['pseudoconstant'][$suffix . 'Column'])) {
+            $suffixes[] = $suffix;
+          }
+        }
+        $field->setSuffixes($suffixes);
       }
       $field->setReadonly(!empty($data['readonly']));
     }
index 13a2fa525f54afcf1031444e392d2da668112c8a..aebe2694bfdad7c52131a1c089c7575c7fa14754 100644 (file)
@@ -25,6 +25,11 @@ trait OptionsSpecTrait {
    */
   public $options;
 
+  /**
+   * @var array|null
+   */
+  public $suffixes;
+
   /**
    * @var callable
    */
@@ -59,6 +64,16 @@ trait OptionsSpecTrait {
     return $this;
   }
 
+  /**
+   * @param array $suffixes
+   *
+   * @return $this
+   */
+  public function setSuffixes($suffixes) {
+    $this->suffixes = $suffixes;
+    return $this;
+  }
+
   /**
    * @param callable $callback
    *
index 5aa33eb1f50192dddba79fced3ae3260355e234e..f0450a091b80f13ec36a9b44eaa79dca270ae60b 100644 (file)
@@ -128,6 +128,7 @@ class Afform extends Generic\AbstractEntity {
         [
           'name' => 'type',
           'options' => $self->pseudoconstantOptions('afform_type'),
+          'suffixes' => ['id', 'name', 'label', 'icon'],
         ],
         [
           'name' => 'requires',
index 32d223ea1dc156153d4cd25d69b616e5a8a7d469..9ff1b3bd1170f97b2592df4bf019abb02a85e142 100644 (file)
@@ -168,6 +168,10 @@ class BasicActionsTest extends UnitTestCase {
     $this->assertTrue($getFields['fruit']['options']);
     $this->assertFalse($getFields['identifier']['options']);
 
+    // Getfields should figure out what suffixes are available based on option keys
+    $this->assertEquals(['name', 'label'], $getFields['group']['suffixes']);
+    $this->assertEquals(['name', 'label', 'color'], $getFields['fruit']['suffixes']);
+
     // Load simple options
     $getFields = MockBasicEntity::getFields()
       ->addWhere('name', 'IN', ['group', 'fruit'])
index fbb1fa5cfbe3acd5f3d6205e536beab943094c68..e57457b36350558e3386ab4c6fd74fb202f83d8d 100644 (file)
@@ -21,6 +21,7 @@ namespace api\v4\Action;
 
 use api\v4\UnitTestCase;
 use Civi\Api4\Contact;
+use Civi\Api4\Contribution;
 
 /**
  * @group headless
@@ -66,8 +67,7 @@ class GetFieldsTest extends UnitTestCase {
   public function testInternalPropsAreHidden() {
     // Public getFields should not contain @internal props
     $fields = Contact::getFields(FALSE)
-      ->execute()
-      ->getArrayCopy();
+      ->execute();
     foreach ($fields as $field) {
       $this->assertArrayNotHasKey('output_formatters', $field);
     }
@@ -79,4 +79,16 @@ class GetFieldsTest extends UnitTestCase {
     }
   }
 
+  public function testPreloadFalse() {
+    \CRM_Core_BAO_ConfigSetting::enableComponent('CiviContribute');
+    \CRM_Core_BAO_ConfigSetting::enableComponent('CiviCampaign');
+    // The campaign_id field has preload = false in the schema,
+    // Which means the options will NOT load but suffixes are still available
+    $fields = Contribution::getFields(FALSE)
+      ->setLoadOptions(['name', 'label'])
+      ->execute()->indexBy('name');
+    $this->assertFalse($fields['campaign_id']['options']);
+    $this->assertEquals(['name', 'label'], $fields['campaign_id']['suffixes']);
+  }
+
 }
index 645e1670bdb6a4915d7b78c7e16f962d419f018d..d8f7621731e761f4fdbe2742dc76d77c497955f7 100644 (file)
 namespace api\v4\Action;
 
 use Civi\Api4\Address;
+use Civi\Api4\Campaign;
 use Civi\Api4\Contact;
 use Civi\Api4\Activity;
+use Civi\Api4\Contribution;
 use Civi\Api4\CustomField;
 use Civi\Api4\CustomGroup;
 use Civi\Api4\Email;
@@ -298,4 +300,42 @@ class PseudoconstantTest extends BaseCustomValueTest {
     $this->assertArrayNotHasKey($participant['id'], (array) $search2);
   }
 
+  public function testPreloadFalse() {
+    \CRM_Core_BAO_ConfigSetting::enableComponent('CiviContribute');
+    \CRM_Core_BAO_ConfigSetting::enableComponent('CiviCampaign');
+
+    $contact = $this->createEntity(['type' => 'Individual']);
+
+    $campaignTitle = uniqid('Test ');
+
+    $campaignId = Campaign::create(FALSE)
+      ->addValue('title', $campaignTitle)
+      ->addValue('campaign_type_id', 1)
+      ->execute()->first()['id'];
+
+    $contributionId = Contribution::create(FALSE)
+      ->addValue('campaign_id', $campaignId)
+      ->addValue('contact_id', $contact['id'])
+      ->addValue('financial_type_id', 1)
+      ->addValue('total_amount', .01)
+      ->execute()->first()['id'];
+
+    // Even though the option list of campaigns is not available (prefetch = false)
+    // We should still be able to get the title of the campaign as :label
+    $result = Contribution::get(FALSE)
+      ->addWhere('id', '=', $contributionId)
+      ->addSelect('campaign_id:label')
+      ->execute()->single();
+
+    $this->assertEquals($campaignTitle, $result['campaign_id:label']);
+
+    // Fetching the title via join ought to work too
+    $result = Contribution::get(FALSE)
+      ->addWhere('id', '=', $contributionId)
+      ->addSelect('campaign_id.title')
+      ->execute()->single();
+
+    $this->assertEquals($campaignTitle, $result['campaign_id.title']);
+  }
+
 }