Afform - Fix chainSelect to work with anonymous users
authorColeman Watts <coleman@civicrm.org>
Thu, 7 Oct 2021 04:24:07 +0000 (00:24 -0400)
committerColeman Watts <coleman@civicrm.org>
Thu, 7 Oct 2021 14:06:07 +0000 (10:06 -0400)
ext/afform/core/Civi/Afform/AfformMetadataInjector.php
ext/afform/core/Civi/Afform/FormDataModel.php
ext/afform/core/Civi/Api4/Action/Afform/GetOptions.php [new file with mode: 0644]
ext/afform/core/Civi/Api4/Afform.php
ext/afform/core/afform.php
ext/afform/core/ang/af/afField.component.js
ext/afform/core/ang/af/afFieldset.directive.js
ext/afform/mock/tests/phpunit/api/v4/AfformContactUsageTest.php

index a8e596e720915c0aff178527404e20088071fffd..ca20b3ac78f55ea0ab1ddd8b130fe375f071e43e 100644 (file)
@@ -163,7 +163,7 @@ class AfformMetadataInjector {
     $params = [
       'action' => $action,
       'where' => [['name', 'IN', $namesToMatch]],
-      'select' => ['name', 'label', 'input_type', 'input_attrs', 'help_pre', 'help_post', 'options', 'entity', 'fk_entity'],
+      'select' => ['name', 'label', 'input_type', 'input_attrs', 'help_pre', 'help_post', 'options', 'fk_entity'],
       'loadOptions' => ['id', 'label'],
       // If the admin included this field on the form, then it's OK to get metadata about the field regardless of user permissions.
       'checkPermissions' => FALSE,
@@ -172,11 +172,16 @@ class AfformMetadataInjector {
       $params['values'] = ['contact_type' => $entityName];
       $entityName = 'Contact';
     }
-    $fields = civicrm_api4($entityName, 'getFields', $params);
-    $field = $originalField = $fields->first();
+    foreach (civicrm_api4($entityName, 'getFields', $params) as $field) {
+      // In the highly unlikely event of 2 fields returned, prefer the exact match
+      if ($field['name'] === $fieldName) {
+        break;
+      }
+    }
     // If this is an implicit join, get new field from fk entity
     if ($field['name'] !== $fieldName && $field['fk_entity']) {
       $params['where'] = [['name', '=', substr($fieldName, 1 + strrpos($fieldName, '.'))]];
+      $originalField = $field;
       $field = civicrm_api4($field['fk_entity'], 'getFields', $params)->first();
       if ($field) {
         $field['label'] = $originalField['label'] . ' ' . $field['label'];
index 82b78fb189802d45eddb256c15071f23917eede1..a0d93dad539b8a8626670ce935ad4b879417d292 100644 (file)
@@ -26,6 +26,11 @@ class FormDataModel {
    */
   protected $blocks = [];
 
+  /**
+   * @var array
+   */
+  protected $searchDisplays = [];
+
   /**
    * @var array
    *   Ex: $secureApi4s['spouse'] = function($entity, $action, $params){...};
@@ -124,14 +129,19 @@ class FormDataModel {
    * @param array $nodes
    * @param string $entity
    * @param string $join
+   * @param string $searchDisplay
    */
-  protected function parseFields($nodes, $entity = NULL, $join = NULL) {
+  protected function parseFields($nodes, $entity = NULL, $join = NULL, $searchDisplay = NULL) {
     foreach ($nodes as $node) {
       if (!is_array($node) || !isset($node['#tag'])) {
         continue;
       }
-      elseif (!empty($node['af-fieldset']) && !empty($node['#children'])) {
-        $this->parseFields($node['#children'], $node['af-fieldset'], $join);
+      elseif (isset($node['af-fieldset']) && !empty($node['#children'])) {
+        $searchDisplay = $node['af-fieldset'] ? NULL : $this->findSearchDisplay($node);
+        $this->parseFields($node['#children'], $node['af-fieldset'], $join, $searchDisplay);
+      }
+      elseif ($searchDisplay && $node['#tag'] === 'af-field') {
+        $this->searchDisplays[$searchDisplay]['fields'][$node['name']] = AHQ::getProps($node);
       }
       elseif ($entity && $node['#tag'] === 'af-field') {
         if ($join) {
@@ -146,7 +156,7 @@ class FormDataModel {
         $this->parseFields($node['#children'] ?? [], $entity, $node['af-join']);
       }
       elseif (!empty($node['#children'])) {
-        $this->parseFields($node['#children'], $entity, $join);
+        $this->parseFields($node['#children'], $entity, $join, $searchDisplay);
       }
       // Recurse into embedded blocks
       if (isset($this->blocks[$node['#tag']])) {
@@ -154,12 +164,26 @@ class FormDataModel {
           $this->blocks[$node['#tag']] = Afform::get()->setCheckPermissions(FALSE)->setSelect(['name', 'layout'])->addWhere('name', '=', $this->blocks[$node['#tag']]['name'])->execute()->first();
         }
         if (!empty($this->blocks[$node['#tag']]['layout'])) {
-          $this->parseFields($this->blocks[$node['#tag']]['layout'], $entity, $join);
+          $this->parseFields($this->blocks[$node['#tag']]['layout'], $entity, $join, $searchDisplay);
         }
       }
     }
   }
 
+  /**
+   * Finds a search display within a fieldset
+   *
+   * @param array $node
+   */
+  public function findSearchDisplay($node) {
+    foreach (\Civi\Search\Display::getDisplayTypes(['name']) as $displayType) {
+      foreach (AHQ::getTags($node, $displayType['name']) as $display) {
+        $this->searchDisplays[$display['display-name']]['searchName'] = $display['search-name'];
+        return $display['display-name'];
+      }
+    }
+  }
+
   /**
    * @return array[]
    *   Ex: $entities['spouse']['type'] = 'Contact';
@@ -175,4 +199,11 @@ class FormDataModel {
     return $this->entities[$entityName] ?? NULL;
   }
 
+  /**
+   * @return array
+   */
+  public function getSearchDisplay($displayName) {
+    return $this->searchDisplays[$displayName] ?? NULL;
+  }
+
 }
diff --git a/ext/afform/core/Civi/Api4/Action/Afform/GetOptions.php b/ext/afform/core/Civi/Api4/Action/Afform/GetOptions.php
new file mode 100644 (file)
index 0000000..a972694
--- /dev/null
@@ -0,0 +1,98 @@
+<?php
+
+namespace Civi\Api4\Action\Afform;
+
+use Civi\Api4\SavedSearch;
+use Civi\Api4\Utils\FormattingUtil;
+
+/**
+ * Loads option values for a form field
+ *
+ * @method $this setFieldName(string $fieldName)
+ * @method $this setModelName(string $modelName)
+ * @method $this setJoinEntity(string $joinEntity)
+ * @method $this setValues(array $values)
+ * @method string getFieldName()
+ * @method string getModelName()
+ * @method string getJoinEntity()
+ * @method array getValues()
+ * @package Civi\Api4\Action\Afform
+ */
+class GetOptions extends AbstractProcessor {
+
+  /**
+   * @var string
+   * @required
+   */
+  protected $modelName;
+
+  /**
+   * @var string
+   * @required
+   */
+  protected $fieldName;
+
+  /**
+   * @var string
+   */
+  protected $joinEntity;
+
+  /**
+   * @var array
+   */
+  protected $values;
+
+  /**
+   * @return array
+   * @throws \API_Exception
+   */
+  protected function processForm() {
+    $formEntity = $this->_formDataModel->getEntity($this->modelName);
+    $searchDisplay = $this->_formDataModel->getSearchDisplay($this->modelName);
+    $fieldName = $this->fieldName;
+
+    // For data-entry forms
+    if ($formEntity) {
+      $entity = $this->joinEntity ?: $formEntity['type'];
+      if ($this->joinEntity && !isset($formEntity['joins'][$this->joinEntity]['fields'][$this->fieldName])) {
+        throw new \API_Exception('Cannot get options for field not present on form');
+      }
+      elseif (!$this->joinEntity && !isset($formEntity['fields'][$this->fieldName])) {
+        throw new \API_Exception('Cannot get options for field not present on form');
+      }
+    }
+    // For search forms, get entity from savedSearch api params
+    elseif ($searchDisplay) {
+      if (!isset($searchDisplay['fields'][$this->fieldName])) {
+        throw new \API_Exception('Cannot get options for field not present on form');
+      }
+      $savedSearch = SavedSearch::get(FALSE)
+        ->addWhere('name', '=', $searchDisplay['searchName'])
+        ->addSelect('api_entity', 'api_params')
+        ->execute()->single();
+      // If field is not prefixed with a join, it's from the main entity
+      $entity = $savedSearch['api__entity'];
+      // Check to see if field belongs to a join
+      foreach ($savedSearch['api_params']['join'] ?? [] as $join) {
+        [$joinEntity, $joinAlias] = array_pad(explode(' AS ', $join[0]), 2, '');
+        if (strpos($fieldName, $joinAlias . '.') === 0) {
+          $entity = $joinEntity;
+          $fieldName = substr($fieldName, strlen($joinAlias) + 1);
+        }
+      }
+    }
+
+    return civicrm_api4($entity, 'getFields', [
+      'checkPermissions' => FALSE,
+      'where' => [['name', '=', $fieldName]],
+      'select' => ['options'],
+      'loadOptions' => ['id', 'label'],
+      'values' => FormattingUtil::filterByPrefix($this->values, $this->fieldName, $fieldName),
+    ], 0)['options'] ?: [];
+  }
+
+  protected function loadEntities() {
+    // Do nothing; this action doesn't need entity data
+  }
+
+}
index 5bd35b0d468ff908c4b3062fc14ddc2b5a8b6adb..4e5f0c977f7085456137bbaa68c99f138b3f013d 100644 (file)
@@ -93,6 +93,15 @@ class Afform extends Generic\AbstractEntity {
       ->setCheckPermissions($checkPermissions);
   }
 
+  /**
+   * @param bool $checkPermissions
+   * @return Action\Afform\GetOptions
+   */
+  public static function getOptions($checkPermissions = TRUE) {
+    return (new Action\Afform\GetOptions('Afform', __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
   /**
    * @param bool $checkPermissions
    * @return Generic\BasicBatchAction
@@ -243,6 +252,7 @@ class Afform extends Generic\AbstractEntity {
       "default" => ["administer CiviCRM"],
       // These all check form-level permissions
       'get' => [],
+      'getOptions' => [],
       'prefill' => [],
       'submit' => [],
       'submitFile' => [],
index df16ec6a80e7cafc60823cf2aaba48e89ffec4b2..2ecf7c854dc33b98ee908b12508392ddfd486ff0 100644 (file)
@@ -492,7 +492,9 @@ function _afform_angular_module_name($fileBaseName, $format = 'camel') {
  */
 function afform_civicrm_alterApiRoutePermissions(&$permissions, $entity, $action) {
   if ($entity == 'Afform') {
-    if ($action == 'prefill' || $action == 'submit' || $action == 'submitFile') {
+    // These actions should be accessible to anonymous users; permissions are checked internally
+    $allowedActions = ['prefill', 'submit', 'submitFile', 'getOptions'];
+    if (in_array($action, $allowedActions, TRUE)) {
       $permissions = CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION;
     }
   }
index 5737c1a4a8aed4693b4670b884b49198d455bb83..aa83eac8fbae9dced69200437e697e5dc774f4c0 100644 (file)
                 $scope.dataProvider.getFieldData()[ctrl.fieldName] = '';
               }
             }
-            if (val) {
+            if (val && (typeof val === 'number' || val.length)) {
               $('input[crm-ui-select]', $element).addClass('loading').prop('disabled', true);
               var params = {
-                where: [['name', '=', ctrl.defn.name]],
-                select: ['options'],
-                loadOptions: ['id', 'label'],
-                values: {}
+                name: ctrl.afFieldset.getFormName(),
+                modelName: ctrl.afFieldset.getName(),
+                fieldName: ctrl.fieldName,
+                joinEntity: ctrl.afJoin ? ctrl.afJoin.entity : null,
+                values: $scope.dataProvider.getFieldData()
               };
-              params.values[ctrl.defn.input_attrs.control_field] = val;
-              crmApi4(ctrl.defn.entity, 'getFields', params, 0)
+              crmApi4('Afform', 'getOptions', params)
                 .then(function(data) {
-                  $('input[crm-ui-select]', $element).removeClass('loading').prop('disabled', false);
-                  chainSelectOptions = data.options;
+                  $('input[crm-ui-select]', $element).removeClass('loading').prop('disabled', !data.length);
+                  chainSelectOptions = data;
                   validateValue();
                 });
             } else {
index 4525f02054891fb603e4cd206a3881dec17ff9e9..384fad9b90f07c37c30f2db1ad9af9d414e8e236 100644 (file)
@@ -35,7 +35,7 @@
           return data[0].fields;
         };
         this.getFormName = function() {
-          return ctrl.afFormCtrl ? ctrl.afFormCtrl.getMeta().name : $scope.meta.name;
+          return ctrl.afFormCtrl ? ctrl.afFormCtrl.getFormMeta().name : $scope.meta.name;
         };
       }
     };
index 7e0b2f6f4631228dbcf81e9e3d365218f0e6bad8..35d1c2a159d6e1713ca48da4d01738be7647dcf7 100644 (file)
@@ -15,6 +15,9 @@ class api_v4_AfformContactUsageTest extends api_v4_AfformUsageTestCase {
   <fieldset af-fieldset="me">
       <af-field name="first_name" />
       <af-field name="last_name" />
+      <div af-join="Address" min="1" af-repeat="Add">
+        <afblock-contact-address></afblock-contact-address>
+      </div>
   </fieldset>
 </af-form>
 EOHTML;
@@ -87,6 +90,33 @@ EOHTML;
     $this->assertEquals('Lasty', $contact['last_name']);
   }
 
+  public function testChainSelect(): void {
+    $this->useValues([
+      'layout' => self::$layouts['aboutMe'],
+      'permission' => CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION,
+    ]);
+
+    // Get states for USA
+    $result = Civi\Api4\Afform::getOptions()
+      ->setName($this->formName)
+      ->setModelName('me')
+      ->setFieldName('state_province_id')
+      ->setJoinEntity('Address')
+      ->setValues(['country_id' => CRM_Core_DAO::getFieldValue('CRM_Core_DAO_Country', 'United States', 'id', 'name')])
+      ->execute();
+    $this->assertEquals('Alabama', $result[0]['label']);
+
+    // Get states for UK
+    $result = Civi\Api4\Afform::getOptions()
+      ->setName($this->formName)
+      ->setModelName('me')
+      ->setFieldName('state_province_id')
+      ->setJoinEntity('Address')
+      ->setValues(['country_id' => CRM_Core_DAO::getFieldValue('CRM_Core_DAO_Country', 'United Kingdom', 'id', 'name')])
+      ->execute();
+    $this->assertEquals('Aberdeen City', $result[0]['label']);
+  }
+
   public function testCheckEntityReferenceFieldsReplacement(): void {
     $this->useValues([
       'layout' => self::$layouts['registerSite'],