From ad8d834d8b6e871c8feaf072b53f07cb4c0fe103 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Thu, 19 Aug 2021 10:22:13 -0400 Subject: [PATCH] APIv4 - Add 'suffixes' to getFields metadata 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 | 26 ++++++++++++ .../Spec/Provider/ContactGetSpecProvider.php | 1 + .../Provider/EntityTagFilterSpecProvider.php | 1 + Civi/Api4/Service/Spec/SpecFormatter.php | 19 ++++++++- Civi/Schema/Traits/OptionsSpecTrait.php | 15 +++++++ ext/afform/core/Civi/Api4/Afform.php | 1 + .../api/v4/Action/BasicActionsTest.php | 4 ++ tests/phpunit/api/v4/Action/GetFieldsTest.php | 16 +++++++- .../api/v4/Action/PseudoconstantTest.php | 40 +++++++++++++++++++ 9 files changed, 120 insertions(+), 3 deletions(-) diff --git a/Civi/Api4/Generic/BasicGetFieldsAction.php b/Civi/Api4/Generic/BasicGetFieldsAction.php index c64be64977a6..2a2d7bb62e72 100644 --- a/Civi/Api4/Generic/BasicGetFieldsAction.php +++ b/Civi/Api4/Generic/BasicGetFieldsAction.php @@ -135,6 +135,9 @@ protected function formatResults(&$values, $isInternal) { if (array_key_exists('label', $fieldDefaults)) { $field['label'] = $field['label'] ?? $field['title'] ?? $field['name']; } + if (!empty($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 @@ private function formatOptionList($options) { 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 @@ public function fields() { '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', diff --git a/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php b/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php index 2d178bf4bdea..1e41abccba09 100644 --- a/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php +++ b/Civi/Api4/Service/Spec/Provider/ContactGetSpecProvider.php @@ -30,6 +30,7 @@ public function modifySpec(RequestSpec $spec) { ->setType('Filter') ->setOperators(['IN', 'NOT IN']) ->addSqlFilter([__CLASS__, 'getContactGroupSql']) + ->setSuffixes(['id', 'name', 'label']) ->setOptionsCallback([__CLASS__, 'getGroupList']); $spec->addFieldSpec($field); } diff --git a/Civi/Api4/Service/Spec/Provider/EntityTagFilterSpecProvider.php b/Civi/Api4/Service/Spec/Provider/EntityTagFilterSpecProvider.php index dbe6efab673a..be1aad01e79d 100644 --- a/Civi/Api4/Service/Spec/Provider/EntityTagFilterSpecProvider.php +++ b/Civi/Api4/Service/Spec/Provider/EntityTagFilterSpecProvider.php @@ -33,6 +33,7 @@ public function modifySpec(RequestSpec $spec) { ->setType('Filter') ->setOperators(['IN', 'NOT IN']) ->addSqlFilter([__CLASS__, 'getTagFilterSql']) + ->setSuffixes(['id', 'name', 'label', 'description', 'color']) ->setOptionsCallback([__CLASS__, 'getTagList']); $spec->addFieldSpec($field); } diff --git a/Civi/Api4/Service/Spec/SpecFormatter.php b/Civi/Api4/Service/Spec/SpecFormatter.php index 6eae4b396d4f..27a1e3bdc176 100644 --- a/Civi/Api4/Service/Spec/SpecFormatter.php +++ b/Civi/Api4/Service/Spec/SpecFormatter.php @@ -43,6 +43,11 @@ public static function arrayToField(array $data, $entity) { $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 @@ public static function arrayToField(array $data, $entity) { $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'])); } diff --git a/Civi/Schema/Traits/OptionsSpecTrait.php b/Civi/Schema/Traits/OptionsSpecTrait.php index 13a2fa525f54..cae1a8ee28c5 100644 --- a/Civi/Schema/Traits/OptionsSpecTrait.php +++ b/Civi/Schema/Traits/OptionsSpecTrait.php @@ -25,6 +25,11 @@ trait OptionsSpecTrait { */ public $options; + /** + * @var array|null + */ + public $suffixes; + /** * @var callable */ @@ -59,6 +64,16 @@ public function setOptions($options) { return $this; } + /** + * @param array $suffixes + * + * @return $this + */ + public function setSuffixes(?array $suffixes) { + $this->suffixes = $suffixes; + return $this; + } + /** * @param callable $callback * diff --git a/ext/afform/core/Civi/Api4/Afform.php b/ext/afform/core/Civi/Api4/Afform.php index 5aa33eb1f501..f0450a091b80 100644 --- a/ext/afform/core/Civi/Api4/Afform.php +++ b/ext/afform/core/Civi/Api4/Afform.php @@ -128,6 +128,7 @@ public static function getFields($checkPermissions = TRUE) { [ 'name' => 'type', 'options' => $self->pseudoconstantOptions('afform_type'), + 'suffixes' => ['id', 'name', 'label', 'icon'], ], [ 'name' => 'requires', diff --git a/tests/phpunit/api/v4/Action/BasicActionsTest.php b/tests/phpunit/api/v4/Action/BasicActionsTest.php index 32d223ea1dc1..9ff1b3bd1170 100644 --- a/tests/phpunit/api/v4/Action/BasicActionsTest.php +++ b/tests/phpunit/api/v4/Action/BasicActionsTest.php @@ -168,6 +168,10 @@ public function testGetFields() { $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']) diff --git a/tests/phpunit/api/v4/Action/GetFieldsTest.php b/tests/phpunit/api/v4/Action/GetFieldsTest.php index fbb1fa5cfbe3..e57457b36350 100644 --- a/tests/phpunit/api/v4/Action/GetFieldsTest.php +++ b/tests/phpunit/api/v4/Action/GetFieldsTest.php @@ -21,6 +21,7 @@ use api\v4\UnitTestCase; use Civi\Api4\Contact; +use Civi\Api4\Contribution; /** * @group headless @@ -66,8 +67,7 @@ public function testComponentFields() { 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 @@ public function testInternalPropsAreHidden() { } } + 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']); + } + } diff --git a/tests/phpunit/api/v4/Action/PseudoconstantTest.php b/tests/phpunit/api/v4/Action/PseudoconstantTest.php index 645e1670bdb6..d8f7621731e7 100644 --- a/tests/phpunit/api/v4/Action/PseudoconstantTest.php +++ b/tests/phpunit/api/v4/Action/PseudoconstantTest.php @@ -20,8 +20,10 @@ 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 @@ public function testParticipantRole() { $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']); + } + }