APIv4 - Convert Autocomplete action to use SearchDisplay::run
authorColeman Watts <coleman@civicrm.org>
Thu, 3 Nov 2022 16:57:26 +0000 (12:57 -0400)
committerColeman Watts <coleman@civicrm.org>
Thu, 10 Nov 2022 17:34:45 +0000 (12:34 -0500)
This allows Autocomplete fields to be customized as Search Displays,
and expands the SearchDisplay::getDefault action to return default settings
that work for most entities.

Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php
Civi/Api4/Generic/AutocompleteAction.php
Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php
ext/afform/mock/tests/phpunit/api/v4/AfformTestCase.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php
ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php [new file with mode: 0644]
ext/search_kit/managed/SearchDisplayType.mgd.php
tests/phpunit/api/v4/Action/AutocompleteTest.php

index be3a8276527cd7f706c1681f094390608d20fc5a..497bd92d6cad4bb91926be6b8b0c13739ef0283b 100644 (file)
@@ -20,6 +20,17 @@ use Civi\API\Event\PrepareEvent;
  */
 class ValidateFieldsSubscriber extends Generic\AbstractPrepareSubscriber {
 
+  /**
+   * @return array
+   */
+  public static function getSubscribedEvents() {
+    return [
+      // Validating between W_EARLY and W_MIDDLE allows other event subscribers to
+      // alter params without constraint (only params added W_EARLY will be validated).
+      'civi.api.prepare' => [['onApiPrepare', 50]],
+    ];
+  }
+
   /**
    * @param \Civi\API\Event\PrepareEvent $event
    * @throws \Exception
index bb686d2bb796089125162a5d4ecd7cd482beae39..d900f2aa80db8308f588c2c5c4e87b6bb47a6404 100644 (file)
@@ -17,6 +17,7 @@ use Civi\Api4\Utils\CoreUtil;
 /**
  * Retrieve $ENTITIES for an autocomplete form field.
  *
+ * @since 5.54
  * @method $this setInput(string $input) Set input term.
  * @method string getInput()
  * @method $this setIds(array $ids) Set array of ids.
@@ -27,8 +28,6 @@ use Civi\Api4\Utils\CoreUtil;
  * @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;
@@ -54,10 +53,19 @@ class AutocompleteAction extends AbstractAction {
 
   /**
    * Name of SavedSearch to use for filtering.
-   * @var string
+   * @var string|array
    */
   protected $savedSearch;
 
+  /**
+   * Either the name of the display or an array containing the display definition (for preview mode)
+   *
+   * Leave NULL to use the autogenerated default.
+   *
+   * @var string|array|null
+   */
+  protected $display;
+
   /**
    * @var string
    */
@@ -69,12 +77,11 @@ class AutocompleteAction extends AbstractAction {
   protected $fieldName;
 
   /**
-   * Filters requested by untrusted client, permissions will be checked before applying (even if this request has checkPermissions = FALSE).
+   * Unique identifier to be returned as key (typically `id` or `name`)
    *
-   * Format: [fieldName => value][]
-   * @var array
+   * @var string
    */
-  protected $clientFilters = [];
+  protected $key;
 
   /**
    * Filters set programmatically by `civi.api.prepare` listener. Automatically trusted.
@@ -84,84 +91,94 @@ class AutocompleteAction extends AbstractAction {
    */
   private $trustedFilters = [];
 
+  /**
+   * @var string
+   * @see \Civi\Api4\Generic\Traits\SavedSearchInspectorTrait::loadSearchDisplay
+   */
+  protected $_displayType = 'autocomplete';
+
   /**
    * Fetch results.
    *
    * @param \Civi\Api4\Generic\Result $result
    */
   public function _run(Result $result) {
+    $this->checkPermissionToLoadSearch();
+
     $entityName = $this->getEntityName();
-    $fields = CoreUtil::getApiClass($entityName)::get()->entityFields();
-    $idField = CoreUtil::getIdFieldName($entityName);
-    $labelField = CoreUtil::getInfoItem($entityName, 'label_field');
-    $iconFields = CoreUtil::getInfoItem($entityName, 'icon_field') ?? [];
-    $map = [
-      'id' => $idField,
-      'label' => $labelField,
-    ];
-    // FIXME: Use metadata
-    if (isset($fields['description'])) {
-      $map['description'] = 'description';
-    }
-    if (isset($fields['color'])) {
-      $map['color'] = 'color';
-    }
-    $select = array_merge(array_values($map), $iconFields);
 
     if (!$this->savedSearch) {
       $this->savedSearch = ['api_entity' => $entityName];
     }
     $this->loadSavedSearch();
+    $this->loadSearchDisplay();
+
     // Pass-through this parameter
-    $this->_apiParams['checkPermissions'] = $this->savedSearch['api_params']['checkPermissions'] = $this->getCheckPermissions();
+    $this->display['acl_bypass'] = !$this->getCheckPermissions();
+
+    $idField = $this->getIdFieldName();
+    $labelField = $this->display['settings']['columns'][0]['key'];
+    // If label column uses a rewrite, search on those fields too
+    if (!empty($this->display['settings']['columns'][0]['rewrite'])) {
+      $labelField = implode(',', array_unique(array_merge([$labelField], $this->getTokens($this->display['settings']['columns'][0]['rewrite']))));
+    }
+
+    $apiParams =& $this->savedSearch['api_params'];
     // Render mode: fetch by id
     if ($this->ids) {
-      $this->_apiParams['where'][] = [$idField, 'IN', $this->ids];
-      $resultsPerPage = NULL;
+      $apiParams['where'][] = [$idField, 'IN', $this->ids];
+      unset($this->display['settings']['pager']);
+      $return = NULL;
     }
     // Search mode: fetch a page of results based on input
     else {
-      $resultsPerPage = \Civi::settings()->get('search_autocomplete_count') ?: 10;
-      // 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 . '%'];
+      $this->display['settings']['limit'] = $this->display['settings']['limit'] ?? \Civi::settings()->get('search_autocomplete_count') ?: 10;
+      $this->display['settings']['pager'] = [];
+      $return = 'scroll:' . $this->page;
+      $this->addFilter($labelField, $this->input);
+    }
+
+    // Ensure SELECT param includes all fields & filters
+    $select = [$idField];
+    foreach ($this->display['settings']['columns'] as $column) {
+      if ($column['type'] === 'field') {
+        $select[] = $column['key'];
+      }
+      if (!empty($column['rewrite'])) {
+        $select = array_merge($select, $this->getTokens($column['rewrite']));
       }
     }
-    if (empty($this->_apiParams['having'])) {
-      $this->_apiParams['select'] = $select;
+    foreach ($this->trustedFilters as $fields => $val) {
+      $select = array_merge($select, explode(',', $fields));
     }
-    // A HAVING clause depends on the SELECT clause so don't overwrite it.
-    else {
-      $this->_apiParams['select'] = array_unique(array_merge($this->_apiParams['select'], $select));
+    if (!empty($this->display['settings']['color'])) {
+      $select[] = $this->display['settings']['color'];
     }
-    $this->applyFilters();
-    $apiResult = civicrm_api4($entityName, 'get', $this->_apiParams);
-    $rawResults = array_slice((array) $apiResult, 0, $resultsPerPage);
-    foreach ($rawResults as $row) {
-      $mapped = [];
-      foreach ($map as $key => $fieldName) {
-        $mapped[$key] = $row[$fieldName];
+    $apiParams['select'] = array_unique(array_merge($apiParams['select'], $select));
+
+    $apiResult = \Civi\Api4\SearchDisplay::run(FALSE)
+      ->setSavedSearch($this->savedSearch)
+      ->setDisplay($this->display)
+      ->setFilters($this->trustedFilters)
+      ->setReturn($return)
+      ->execute();
+
+    foreach ($apiResult as $row) {
+      $item = [
+        'id' => $row['data'][$idField],
+        'label' => $row['columns'][0]['val'],
+        'icon' => $row['columns'][0]['icons'][0]['class'] ?? NULL,
+        'description' => [],
+      ];
+      foreach (array_slice($row['columns'], 1) as $col) {
+        $item['description'][] = $col['val'];
       }
-      // Get icon in order of priority
-      foreach ($iconFields as $fieldName) {
-        if (!empty($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;
-        }
+      if (!empty($this->display['settings']['color'])) {
+        $item['color'] = $row['data'][$this->display['settings']['color']] ?? NULL;
       }
-      $result[] = $mapped;
+      $result[] = $item;
     }
-    $result->setCountMatched($apiResult->countFetched());
+    $result->setCountMatched($apiResult->count());
   }
 
   /**
@@ -174,22 +191,6 @@ class AutocompleteAction extends AbstractAction {
     $this->trustedFilters[$fieldName] = $value;
   }
 
-  /**
-   * Applies trusted filters. Checks access before applying client filters.
-   */
-  private function applyFilters() {
-    foreach ($this->trustedFilters as $field => $val) {
-      if ($this->hasValue($val)) {
-        $this->applyFilter($field, $val);
-      }
-    }
-    foreach ($this->clientFilters as $field => $val) {
-      if ($this->hasValue($val) && $this->checkFieldAccess($field)) {
-        $this->applyFilter($field, $val);
-      }
-    }
-  }
-
   /**
    * @param $fieldNameWithSuffix
    * @return bool
@@ -213,6 +214,30 @@ class AutocompleteAction extends AbstractAction {
     return FALSE;
   }
 
+  /**
+   * By default, returns the primary key of the entity (typically `id`).
+   *
+   * If $this->key param is set, it will allow it ONLY if the field is a unique index on the entity.
+   * This is a security measure. Allowing any value could give access to potentially sentitive data.
+   *
+   * @return string
+   */
+  private function getIdFieldName() {
+    $entityName = $this->savedSearch['api_entity'];
+    if ($this->key) {
+      /** @var \CRM_Core_DAO $dao */
+      $dao = CoreUtil::getInfoItem($entityName, 'dao');
+      if ($dao && method_exists($dao, 'indices')) {
+        foreach ($dao::indices(FALSE) as $index) {
+          if (!empty($index['unique']) && in_array($this->key, $index['field'], TRUE)) {
+            return $this->key;
+          }
+        }
+      }
+    }
+    return CoreUtil::getIdFieldName($entityName);
+  }
+
   /**
    * @return array
    */
index 27094dd9141f65d436e3d6f87bedf3770b58e2b0..051df4a5f1db5af94c93a6948df7b22408b2ba1b 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Civi\Api4\Generic\Traits;
 
+use Civi\API\Exception\UnauthorizedException;
 use Civi\API\Request;
 use Civi\Api4\Query\SqlExpression;
 use Civi\Api4\SavedSearch;
@@ -61,6 +62,29 @@ trait SavedSearchInspectorTrait {
     $this->_apiParams = ($this->savedSearch['api_params'] ?? []) + ['select' => [], 'where' => []];
   }
 
+  /**
+   * Loads display if not already an array
+   */
+  protected function loadSearchDisplay(): void {
+    // Display name given
+    if (is_string($this->display)) {
+      $this->display = \Civi\Api4\SearchDisplay::get(FALSE)
+        ->setSelect(['*', 'type:name'])
+        ->addWhere('name', '=', $this->display)
+        ->addWhere('saved_search_id', '=', $this->savedSearch['id'])
+        ->execute()->single();
+    }
+    // Null given - use default display
+    elseif (is_null($this->display)) {
+      $this->display = \Civi\Api4\SearchDisplay::getDefault(FALSE)
+        ->addSelect('*', 'type:name')
+        ->setSavedSearch($this->savedSearch)
+        // Set by AutocompleteAction
+        ->setType($this->_displayType ?? 'table')
+        ->execute()->first();
+    }
+  }
+
   /**
    * Returns field definition for a given field or NULL if not found
    * @param $fieldName
@@ -122,7 +146,7 @@ trait SavedSearchInspectorTrait {
    *
    * @return array{fields: array, expr: SqlExpression, dataType: string}[]
    */
-  protected function getSelectClause() {
+  public function getSelectClause() {
     if (!isset($this->_selectClause)) {
       $this->_selectClause = [];
       foreach ($this->_apiParams['select'] as $selectExpr) {
@@ -287,4 +311,29 @@ trait SavedSearchInspectorTrait {
     return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue']));
   }
 
+  /**
+   * Search a string for all square bracket tokens and return their contents (without the brackets)
+   *
+   * @param string $str
+   */
+  protected function getTokens($str) {
+    $tokens = [];
+    preg_match_all('/\\[([^]]+)\\]/', $str, $tokens);
+    return array_unique($tokens[1]);
+  }
+
+  /**
+   * Only SearchKit admins can use unsecured "preview mode" and pass an array for savedSearch or display
+   *
+   * @throws UnauthorizedException
+   */
+  protected function checkPermissionToLoadSearch() {
+    if (
+      (is_array($this->savedSearch) || (isset($this->display) && is_array($this->display))) && $this->checkPermissions &&
+      !\CRM_Core_Permission::check([['administer CiviCRM data', 'administer search_kit']])
+    ) {
+      throw new UnauthorizedException('Access denied');
+    }
+  }
+
 }
index f84eae063d1588dd712e9de4e709e4e02d67657d..f93fea9bdf38e0ab1745b7dc1509610724f0b53b 100644 (file)
@@ -13,7 +13,7 @@ class AfformGetTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
   private $formName = 'abc_123_test';
 
   public function setUpHeadless() {
-    return \Civi\Test::headless()->installMe(__DIR__)->apply();
+    return \Civi\Test::headless()->installMe(__DIR__)->install('org.civicrm.search_kit')->apply();
   }
 
   public function tearDown(): void {
index a2dcb6af553bd8cd6441c47cce322a7482ba64e1..43b174e4152e6ca715534474b80a6ddb32924f39 100644 (file)
@@ -14,8 +14,7 @@ abstract class api_v4_AfformTestCase extends \PHPUnit\Framework\TestCase impleme
    */
   public function setUpHeadless() {
     return \Civi\Test::headless()
-      ->install(version_compare(CRM_Utils_System::version(), '5.19.alpha1', '<') ? ['org.civicrm.api4'] : [])
-      ->install(['org.civicrm.afform', 'org.civicrm.afform-mock'])
+      ->install(['org.civicrm.search_kit', 'org.civicrm.afform', 'org.civicrm.afform-mock'])
       ->apply();
   }
 
index 66cf56ba95cd19531081fdfdc9827a266b973d3a..ad59000f3fd6ff1853c2d75a0a2b475ed14560ed 100644 (file)
@@ -4,7 +4,6 @@ namespace Civi\Api4\Action\SearchDisplay;
 
 use Civi\API\Exception\UnauthorizedException;
 use Civi\Api4\Query\SqlField;
-use Civi\Api4\SearchDisplay;
 use Civi\Api4\Utils\CoreUtil;
 use Civi\Api4\Utils\FormattingUtil;
 
@@ -90,13 +89,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    * @throws \CRM_Core_Exception
    */
   public function _run(\Civi\Api4\Generic\Result $result) {
-    // Only SearchKit admins can use this in unsecured "preview mode"
-    if (
-      (is_array($this->savedSearch) || is_array($this->display)) && $this->checkPermissions &&
-      !\CRM_Core_Permission::check([['administer CiviCRM data', 'administer search_kit']])
-    ) {
-      throw new UnauthorizedException('Access denied');
-    }
+    $this->checkPermissionToLoadSearch();
     $this->loadSavedSearch();
     $this->loadSearchDisplay();
 
@@ -119,10 +112,10 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
   /**
    * Transforms each row into an array of raw data and an array of formatted columns
    *
-   * @param \Civi\Api4\Generic\Result $result
+   * @param iterable $result
    * @return array{data: array, columns: array}[]
    */
-  protected function formatResult(\Civi\Api4\Generic\Result $result): array {
+  protected function formatResult(iterable $result): array {
     $rows = [];
     $keyName = CoreUtil::getIdFieldName($this->savedSearch['api_entity']);
     foreach ($result as $index => $record) {
@@ -1020,15 +1013,6 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     }
   }
 
-  /**
-   * @param string $str
-   */
-  private function getTokens($str) {
-    $tokens = [];
-    preg_match_all('/\\[([^]]+)\\]/', $str, $tokens);
-    return array_unique($tokens[1]);
-  }
-
   /**
    * Given an alias like Contact_Email_01_location_type_id
    * this will return Contact_Email_01.location_type_id
@@ -1256,25 +1240,4 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     return $values;
   }
 
-  /**
-   * Loads display if not already an array
-   */
-  private function loadSearchDisplay(): void {
-    // Display name given
-    if (is_string($this->display)) {
-      $this->display = SearchDisplay::get(FALSE)
-        ->setSelect(['*', 'type:name'])
-        ->addWhere('name', '=', $this->display)
-        ->addWhere('saved_search_id', '=', $this->savedSearch['id'])
-        ->execute()->single();
-    }
-    // Null given - use default display
-    elseif (is_null($this->display)) {
-      $this->display = SearchDisplay::getDefault(FALSE)
-        ->addSelect('*', 'type:name')
-        ->setSavedSearch($this->savedSearch)
-        ->execute()->first();
-    }
-  }
-
 }
index c9c26de851e54db3d0541e3d9e62518085971cb0..b21aabe9cba60da27bee002f47ff67d0574e6dd5 100644 (file)
@@ -5,6 +5,7 @@ namespace Civi\Api4\Action\SearchDisplay;
 use Civi\Api4\Generic\Traits\SavedSearchInspectorTrait;
 use Civi\Api4\SavedSearch;
 use Civi\Api4\Utils\FormattingUtil;
+use Civi\Core\Event\GenericHookEvent;
 use Civi\Search\Display;
 use CRM_Search_ExtensionUtil as E;
 use Civi\Api4\Query\SqlEquation;
@@ -13,11 +14,12 @@ use Civi\Api4\Query\SqlField;
 use Civi\Api4\Query\SqlFunction;
 use Civi\Api4\Query\SqlFunctionGROUP_CONCAT;
 use Civi\Api4\Utils\CoreUtil;
-use Civi\API\Exception\UnauthorizedException;
 
 /**
  * Return the default results table for a saved search.
  *
+ * @method $this setType(string $type)
+ * @method string getType()
  * @package Civi\Api4\Action\SearchDisplay
  */
 class GetDefault extends \Civi\Api4\Generic\AbstractAction {
@@ -32,6 +34,12 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
    */
   protected $savedSearch;
 
+  /**
+   * @var string
+   * @optionsCallback getDisplayTypes
+   */
+  protected $type = 'table';
+
   /**
    * @var array
    */
@@ -39,17 +47,11 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
 
   /**
    * @param \Civi\Api4\Generic\Result $result
-   * @throws UnauthorizedException
    * @throws \CRM_Core_Exception
    */
   public function _run(\Civi\Api4\Generic\Result $result) {
     // Only SearchKit admins can use this in unsecured "preview mode"
-    if (
-      is_array($this->savedSearch) && $this->checkPermissions &&
-      !\CRM_Core_Permission::check([['administer CiviCRM data', 'administer search_kit']])
-    ) {
-      throw new UnauthorizedException('Access denied');
-    }
+    $this->checkPermissionToLoadSearch();
     $this->loadSavedSearch();
     $this->expandSelectClauseWildcards();
     // Use label from saved search
@@ -63,40 +65,19 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
       'name' => NULL,
       'saved_search_id' => $this->savedSearch['id'] ?? NULL,
       'label' => $label,
-      'type' => 'table',
+      'type' => $this->type ?: 'table',
       'acl_bypass' => FALSE,
-      'settings' => [
-        'actions' => TRUE,
-        'limit' => \Civi::settings()->get('default_pager_size'),
-        'classes' => ['table', 'table-striped'],
-        'pager' => [
-          'show_count' => TRUE,
-          'expose_limit' => TRUE,
-        ],
-        'placeholder' => 5,
-        'sort' => [],
-        'columns' => [],
-      ],
-    ];
-    // Supply default sort if no orderBy given in api params
-    if (!empty($this->savedSearch['api_entity']) && empty($this->savedSearch['api_params']['orderBy'])) {
-      $defaultSort = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'order_by');
-      if ($defaultSort) {
-        $display['settings']['sort'][] = [$defaultSort, 'ASC'];
-      }
-    }
-    foreach ($this->getSelectClause() as $key => $clause) {
-      $display['settings']['columns'][] = $this->configureColumn($clause, $key);
-    }
-    $display['settings']['columns'][] = [
-      'label' => '',
-      'type' => 'menu',
-      'icon' => 'fa-bars',
-      'size' => 'btn-xs',
-      'style' => 'secondary-outline',
-      'alignment' => 'text-right',
-      'links' => $this->getLinksMenu(),
+      'settings' => [],
     ];
+
+    // Allow the default display to be modified
+    // @see \Civi\Api4\Event\Subscriber\DefaultDisplaySubscriber
+    \Civi::dispatcher()->dispatch('civi.search.defaultDisplay', GenericHookEvent::create([
+      'savedSearch' => $this->savedSearch,
+      'display' => &$display,
+      'apiAction' => $this,
+    ]));
+
     $fields = $this->entityFields();
     // Allow implicit-join-style selection of saved search fields
     if ($this->savedSearch) {
@@ -111,7 +92,7 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
       }
     }
     $results = [$display];
-    // Replace pseudoconstants
+    // Replace pseudoconstants e.g. type:icon
     FormattingUtil::formatOutputValues($results, $fields);
     $result->exchangeArray($this->selectArray($results));
   }
@@ -121,7 +102,7 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
    * @param string $key
    * @return array
    */
-  private function configureColumn($clause, $key) {
+  public function configureColumn($clause, $key) {
     $col = [
       'type' => 'field',
       'key' => $key,
@@ -223,7 +204,7 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
   /**
    * return array[]
    */
-  private function getLinksMenu() {
+  public function getLinksMenu() {
     $menu = [];
     $mainEntity = $this->savedSearch['api_entity'] ?? NULL;
     if ($mainEntity && !$this->canAggregate(CoreUtil::getIdFieldName($mainEntity))) {
@@ -267,4 +248,12 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
     return $link ? $link + ['join' => $joinAlias] : NULL;
   }
 
+  /**
+   * Options callback for $this->type
+   * @return array
+   */
+  public static function getDisplayTypes(): array {
+    return array_column(\CRM_Core_OptionValue::getValues(['name' => 'search_display_type']), 'value');
+  }
+
 }
index 9406c67ea9432d62ee4b7b8454fc4f773275c90d..517de4ea7d5fb2017f30f79e258d65994ace5195 100644 (file)
@@ -18,8 +18,8 @@ use Civi\Api4\Utils\CoreUtil;
 class Run extends AbstractRunAction {
 
   /**
-   * Should this api call return a page of results or the row_count or the ids
-   * E.g. "page:1" or "row_count" or "id"
+   * Should this api call return a page/scroll of results or the row_count or the ids
+   * E.g. "page:1" or "scroll:2" or "row_count" or "id"
    * @var string
    */
   protected $return;
@@ -40,6 +40,8 @@ class Run extends AbstractRunAction {
     $settings = $this->display['settings'];
     $page = $index = NULL;
     $key = $this->return;
+    // Pager can operate in "page" mode for traditional pager, or "scroll" mode for infinite scrolling
+    $pagerMode = NULL;
 
     switch ($this->return) {
       case 'id':
@@ -84,13 +86,19 @@ class Run extends AbstractRunAction {
         return;
 
       default:
-        if (($settings['pager'] ?? FALSE) !== FALSE && preg_match('/^page:\d+$/', $key)) {
-          $page = explode(':', $key)[1];
+        // Pager mode: `page:n`
+        // AJAX scroll mode: `scroll:n`
+        // Or NULL for unlimited results
+        if (($settings['pager'] ?? FALSE) !== FALSE && preg_match('/^(page|scroll):\d+$/', $key)) {
+          [$pagerMode, $page] = explode(':', $key);
         }
         $limit = !empty($settings['pager']['expose_limit']) && $this->limit ? $this->limit : NULL;
         $apiParams['debug'] = $this->debug;
         $apiParams['limit'] = $limit ?? $settings['limit'] ?? NULL;
         $apiParams['offset'] = $page ? $apiParams['limit'] * ($page - 1) : 0;
+        if ($apiParams['limit'] && $pagerMode === 'scroll') {
+          $apiParams['limit']++;
+        }
         $apiParams['orderBy'] = $this->getOrderByFromSort();
         $this->augmentSelectClause($apiParams);
     }
@@ -106,6 +114,11 @@ class Run extends AbstractRunAction {
       $result->exchangeArray($apiResult->getArrayCopy());
     }
     else {
+      if ($pagerMode === 'scroll') {
+        // Remove the extra result appended for the sake of infinite scrolling
+        $result->setCountMatched($apiResult->countFetched());
+        $apiResult = array_slice((array) $apiResult, 0, $apiParams['limit'] - 1);
+      }
       $result->exchangeArray($this->formatResult($apiResult));
       $result->labels = $this->filterLabels;
     }
diff --git a/ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php b/ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php
new file mode 100644 (file)
index 0000000..9114362
--- /dev/null
@@ -0,0 +1,144 @@
+<?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       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Event\Subscriber;
+
+use Civi\Api4\Utils\CoreUtil;
+use Civi\Core\Event\GenericHookEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Provides default display for type 'table' and type 'autocomplete'
+ *
+ * Other extensions can override or modify these defaults on a per-type or per-entity basis.
+ *
+ * @service
+ * @internal
+ */
+class DefaultDisplaySubscriber extends \Civi\Core\Service\AutoService implements EventSubscriberInterface {
+
+  /**
+   * @return array
+   */
+  public static function getSubscribedEvents() {
+    return [
+      'civi.search.defaultDisplay' => [
+        // Responding in-between W_MIDDLE and W_LATE so that other subscribers can either:
+        // 1. Override these defaults (W_MIDDLE and earlier)
+        // 2. Supplement these defaults (W_LATE)
+        ['autocompleteDefault', -10],
+        ['fallbackDefault', -20],
+      ],
+    ];
+  }
+
+  /**
+   * Defaults for Autocomplete display type.
+   *
+   * These defaults work for any entity with a @labelField declared.
+   * For other entity types, it's necessary to override these defaults.
+   *
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   */
+  public static function autocompleteDefault(GenericHookEvent $e) {
+    // Only fill autocomplete defaults if another subscriber hasn't already done the work
+    if ($e->display['settings'] || $e->display['type'] !== 'autocomplete') {
+      return;
+    }
+    $entityName = $e->savedSearch['api_entity'];
+    if (!$entityName) {
+      throw new \CRM_Core_Exception("Entity name is required to get autocomplete default display.");
+    }
+    $labelField = CoreUtil::getInfoItem($entityName, 'label_field');
+    if (!$labelField) {
+      throw new \CRM_Core_Exception("Entity $entityName has no default label field.");
+    }
+
+    // Default sort order
+    $e->display['settings']['sort'] = self::getDefaultSort($entityName);
+
+    $fields = CoreUtil::getApiClass($entityName)::get()->entityFields();
+    $columns = [$labelField];
+    if (isset($fields['description'])) {
+      $columns[] = 'description';
+    }
+
+    // First column is the main label
+    foreach ($columns as $columnField) {
+      $e->display['settings']['columns'][] = [
+        'type' => 'field',
+        'key' => $columnField,
+      ];
+    }
+
+    // Default icons
+    $iconFields = CoreUtil::getInfoItem($entityName, 'icon_field') ?? [];
+    foreach ($iconFields as $iconField) {
+      $e->display['settings']['columns'][0]['icons'][] = ['field' => $iconField];
+    }
+
+    // Color field
+    if (isset($fields['color'])) {
+      $e->display['settings']['color'] = 'color';
+    }
+  }
+
+  /**
+   * @param \Civi\Core\Event\GenericHookEvent $e
+   */
+  public static function fallbackDefault(GenericHookEvent $e) {
+    // Early return if another subscriber has already done the work
+    if ($e->display['settings']) {
+      return;
+    }
+    $e->display['settings'] += [
+      'sort' => [],
+      'limit' => \Civi::settings()->get('default_pager_size'),
+      'pager' => [
+        'show_count' => TRUE,
+        'expose_limit' => TRUE,
+      ],
+      'placeholder' => 5,
+      'columns' => [],
+    ];
+    // Supply default sort if no orderBy given in api params
+    if (!empty($e->savedSearch['api_entity']) && empty($e->savedSearch['api_params']['orderBy'])) {
+      $e->display['settings']['sort'] = self::getDefaultSort($e->savedSearch['api_entity']);
+    }
+    foreach ($e->apiAction->getSelectClause() as $key => $clause) {
+      $e->display['settings']['columns'][] = $e->apiAction->configureColumn($clause, $key);
+    }
+    // Table-specific settings
+    if ($e->display['type'] === 'table') {
+      $e->display['settings']['actions'] = TRUE;
+      $e->display['settings']['classes'] = ['table', 'table-striped'];
+      $e->display['settings']['columns'][] = [
+        'label' => '',
+        'type' => 'menu',
+        'icon' => 'fa-bars',
+        'size' => 'btn-xs',
+        'style' => 'secondary-outline',
+        'alignment' => 'text-right',
+        'links' => $e->apiAction->getLinksMenu(),
+      ];
+    }
+  }
+
+  /**
+   * @param $entityName
+   * @return array
+   */
+  protected static function getDefaultSort($entityName) {
+    $sortField = CoreUtil::getInfoItem($entityName, 'order_by') ?: CoreUtil::getInfoItem($entityName, 'label_field');
+    return $sortField ? [[$sortField, 'ASC']] : [];
+  }
+
+}
index 4998d88c0e93e5054550f61f445ef01e943ad3eb..87bf76e64be92dce3faa065a881b4ec7e9d2f9ea 100644 (file)
@@ -85,4 +85,24 @@ return [
       'match' => ['option_group_id', 'name'],
     ],
   ],
+  [
+    'name' => 'SearchDisplayType:autocomplete',
+    'entity' => 'OptionValue',
+    'cleanup' => 'always',
+    'update' => 'always',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'option_group_id.name' => 'search_display_type',
+        'value' => 'autocomplete',
+        'name' => 'crm-search-display-autocomplete',
+        'label' => E::ts('Autocomplete'),
+        'icon' => 'fa-keyboard-o',
+        'is_reserved' => TRUE,
+        'is_active' => TRUE,
+        'domain_id' => NULL,
+      ],
+      'match' => ['option_group_id', 'name'],
+    ],
+  ],
 ];
index e63567af4c2429259164c9315450a24f0f61ad00..1ec4c2c5f33423a2445a84a4536577870bd62471 100644 (file)
 namespace api\v4\Action;
 
 use api\v4\Api4TestBase;
+use Civi\API\Exception\UnauthorizedException;
 use Civi\Api4\Contact;
 use Civi\Api4\MockBasicEntity;
+use Civi\Api4\SavedSearch;
 use Civi\Core\Event\GenericHookEvent;
 use Civi\Test\HookInterface;
 use Civi\Test\TransactionalInterface;
@@ -36,6 +38,10 @@ class AutocompleteTest extends Api4TestBase implements HookInterface, Transactio
    */
   private $hookCallback;
 
+  public function setUpHeadless(): void {
+    \Civi\Test::headless()->install('org.civicrm.search_kit')->apply();
+  }
+
   /**
    * Listens for civi.api4.entityTypes event to manually add this nonstandard entity
    *
@@ -77,6 +83,8 @@ class AutocompleteTest extends Api4TestBase implements HookInterface, Transactio
     $this->assertCount(2, $result);
     $this->assertEquals('Black', $result[0]['label']);
     $this->assertEquals('777777', $result[1]['color']);
+    $this->assertEquals($entities[1]['identifier'], $result[1]['id']);
+    $this->assertEquals($entities[2]['identifier'], $result[0]['id']);
 
     $result = MockBasicEntity::autocomplete()
       ->setInput('ite')
@@ -158,51 +166,25 @@ class AutocompleteTest extends Api4TestBase implements HookInterface, Transactio
     $this->createLoggedInUser();
 
     \CRM_Core_Config::singleton()->userPermissionClass->permissions = [
-      'administer CiviCRM',
       'view all contacts',
     ];
 
-    // Admin can apply the api_key filter
-    $result = Contact::autocomplete()
-      ->setInput($lastName)
-      ->setClientFilters(['api_key' => 'secret789'])
-      ->execute();
-    $this->assertCount(1, $result);
-
-    \CRM_Core_Config::singleton()->userPermissionClass->permissions = [
-      'access CiviCRM',
-      'view all contacts',
-    ];
-
-    // Non-admin cannot apply filter
-    $result = Contact::autocomplete()
-      ->setInput($lastName)
-      ->setClientFilters(['api_key' => 'secret789'])
-      ->execute();
-    $this->assertCount(3, $result);
-
-    // Cannot apply filter even with permissions disabled
-    $result = Contact::autocomplete(FALSE)
-      ->setInput($lastName)
-      ->setClientFilters(['api_key' => 'secret789'])
-      ->execute();
-    $this->assertCount(3, $result);
-
     // Assert that the end-user is not allowed to inject arbitrary savedSearch params
     $msg = '';
     try {
-      $result = Contact::autocomplete()
+      Contact::autocomplete()
         ->setInput($lastName)
         ->setSavedSearch([
           'api_entity' => 'Contact',
           'api_params' => [],
         ])
         ->execute();
+      $this->fail();
     }
-    catch (\CRM_Core_Exception $e) {
+    catch (UnauthorizedException $e) {
       $msg = $e->getMessage();
     }
-    $this->assertEquals('Parameter "savedSearch" is not of the correct type. Expecting string.', $msg);
+    $this->assertEquals('Access denied', $msg);
 
     // With hook callback, permissions can be overridden by injecting a trusted filter
     $this->hookCallback = function(\Civi\Api4\Generic\AutocompleteAction $action) {
@@ -216,4 +198,69 @@ class AutocompleteTest extends Api4TestBase implements HookInterface, Transactio
     $this->assertCount(1, $result);
   }
 
+  public function testAutocompletePager() {
+    MockBasicEntity::delete()->addWhere('identifier', '>', 0)->execute();
+    $sampleData = [];
+    foreach (range(1, 21) as $num) {
+      $sampleData[] = ['foo' => 'Test ' . $num];
+    }
+    MockBasicEntity::save()
+      ->setRecords($sampleData)
+      ->execute();
+
+    $result1 = MockBasicEntity::autocomplete()
+      ->setInput('est')
+      ->execute();
+    $this->assertEquals('Test 1', $result1[0]['label']);
+    $this->assertEquals(10, $result1->countFetched());
+    $this->assertEquals(11, $result1->countMatched());
+
+    $result2 = MockBasicEntity::autocomplete()
+      ->setInput('est')
+      ->setPage(2)
+      ->execute();
+    $this->assertEquals('Test 11', $result2[0]['label']);
+    $this->assertEquals(10, $result2->countFetched());
+    $this->assertEquals(11, $result2->countMatched());
+
+    $result3 = MockBasicEntity::autocomplete()
+      ->setInput('est')
+      ->setPage(3)
+      ->execute();
+    $this->assertEquals('Test 21', $result3[0]['label']);
+    $this->assertEquals(1, $result3->countFetched());
+    $this->assertEquals(1, $result3->countMatched());
+  }
+
+  public function testAutocompleteIdField() {
+    $label = uniqid();
+    $sample = $this->saveTestRecords('SavedSearch', [
+      'records' => [
+        ['name' => 'c', 'label' => "C $label"],
+        ['name' => 'a', 'label' => "A $label"],
+        ['name' => 'b', 'label' => "B $label"],
+      ],
+      'defaults' => ['api_entity' => 'Contact'],
+    ]);
+
+    $result1 = SavedSearch::autocomplete()
+      ->setInput($label)
+      ->setKey('name')
+      ->execute();
+
+    $this->assertEquals('a', $result1[0]['id']);
+    $this->assertEquals('b', $result1[1]['id']);
+    $this->assertEquals('c', $result1[2]['id']);
+
+    // This key won't be used since api_entity is not a unique index
+    $result2 = SavedSearch::autocomplete()
+      ->setInput($label)
+      ->setKey('api_entity')
+      ->execute();
+    // Expect id to be returned as key instead of api_entity
+    $this->assertEquals($sample[1]['id'], $result2[0]['id']);
+    $this->assertEquals($sample[2]['id'], $result2[1]['id']);
+    $this->assertEquals($sample[0]['id'], $result2[2]['id']);
+  }
+
 }