From af1fdfa1af4749fb25dbe225d0c561f986e2251f Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Thu, 3 Nov 2022 12:57:26 -0400 Subject: [PATCH] APIv4 - Convert Autocomplete action to use SearchDisplay::run 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. --- .../Subscriber/ValidateFieldsSubscriber.php | 11 ++ Civi/Api4/Generic/AutocompleteAction.php | 177 ++++++++++-------- .../Traits/SavedSearchInspectorTrait.php | 51 ++++- .../phpunit/Civi/Afform/AfformGetTest.php | 2 +- .../tests/phpunit/api/v4/AfformTestCase.php | 3 +- .../SearchDisplay/AbstractRunAction.php | 43 +---- .../Api4/Action/SearchDisplay/GetDefault.php | 75 ++++---- .../Civi/Api4/Action/SearchDisplay/Run.php | 21 ++- .../Subscriber/DefaultDisplaySubscriber.php | 144 ++++++++++++++ .../managed/SearchDisplayType.mgd.php | 20 ++ .../api/v4/Action/AutocompleteTest.php | 107 ++++++++--- 11 files changed, 457 insertions(+), 197 deletions(-) create mode 100644 ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php diff --git a/Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php b/Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php index be3a827652..497bd92d6c 100644 --- a/Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php +++ b/Civi/Api4/Event/Subscriber/ValidateFieldsSubscriber.php @@ -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 diff --git a/Civi/Api4/Generic/AutocompleteAction.php b/Civi/Api4/Generic/AutocompleteAction.php index bb686d2bb7..d900f2aa80 100644 --- a/Civi/Api4/Generic/AutocompleteAction.php +++ b/Civi/Api4/Generic/AutocompleteAction.php @@ -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 */ diff --git a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php index 27094dd914..051df4a5f1 100644 --- a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php +++ b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php @@ -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'); + } + } + } diff --git a/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php b/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php index f84eae063d..f93fea9bdf 100644 --- a/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php +++ b/ext/afform/core/tests/phpunit/Civi/Afform/AfformGetTest.php @@ -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 { diff --git a/ext/afform/mock/tests/phpunit/api/v4/AfformTestCase.php b/ext/afform/mock/tests/phpunit/api/v4/AfformTestCase.php index a2dcb6af55..43b174e415 100644 --- a/ext/afform/mock/tests/phpunit/api/v4/AfformTestCase.php +++ b/ext/afform/mock/tests/phpunit/api/v4/AfformTestCase.php @@ -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(); } diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php index 66cf56ba95..ad59000f3f 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php @@ -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(); - } - } - } diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php index c9c26de851..b21aabe9cb 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php @@ -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'); + } + } diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php index 9406c67ea9..517de4ea7d 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php @@ -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 index 0000000000..9114362970 --- /dev/null +++ b/ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php @@ -0,0 +1,144 @@ + [ + // 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']] : []; + } + +} diff --git a/ext/search_kit/managed/SearchDisplayType.mgd.php b/ext/search_kit/managed/SearchDisplayType.mgd.php index 4998d88c0e..87bf76e64b 100644 --- a/ext/search_kit/managed/SearchDisplayType.mgd.php +++ b/ext/search_kit/managed/SearchDisplayType.mgd.php @@ -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'], + ], + ], ]; diff --git a/tests/phpunit/api/v4/Action/AutocompleteTest.php b/tests/phpunit/api/v4/Action/AutocompleteTest.php index e63567af4c..1ec4c2c5f3 100644 --- a/tests/phpunit/api/v4/Action/AutocompleteTest.php +++ b/tests/phpunit/api/v4/Action/AutocompleteTest.php @@ -20,8 +20,10 @@ 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']); + } + } -- 2.25.1