*/
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
/**
* 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.
* @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;
/**
* 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
*/
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.
*/
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());
}
/**
$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
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
*/
namespace Civi\Api4\Generic\Traits;
+use Civi\API\Exception\UnauthorizedException;
use Civi\API\Request;
use Civi\Api4\Query\SqlExpression;
use Civi\Api4\SavedSearch;
$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
*
* @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) {
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');
+ }
+ }
+
}
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 {
*/
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();
}
use Civi\API\Exception\UnauthorizedException;
use Civi\Api4\Query\SqlField;
-use Civi\Api4\SearchDisplay;
use Civi\Api4\Utils\CoreUtil;
use Civi\Api4\Utils\FormattingUtil;
* @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();
/**
* 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) {
}
}
- /**
- * @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
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();
- }
- }
-
}
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;
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 {
*/
protected $savedSearch;
+ /**
+ * @var string
+ * @optionsCallback getDisplayTypes
+ */
+ protected $type = 'table';
+
/**
* @var array
*/
/**
* @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
'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) {
}
}
$results = [$display];
- // Replace pseudoconstants
+ // Replace pseudoconstants e.g. type:icon
FormattingUtil::formatOutputValues($results, $fields);
$result->exchangeArray($this->selectArray($results));
}
* @param string $key
* @return array
*/
- private function configureColumn($clause, $key) {
+ public function configureColumn($clause, $key) {
$col = [
'type' => 'field',
'key' => $key,
/**
* return array[]
*/
- private function getLinksMenu() {
+ public function getLinksMenu() {
$menu = [];
$mainEntity = $this->savedSearch['api_entity'] ?? NULL;
if ($mainEntity && !$this->canAggregate(CoreUtil::getIdFieldName($mainEntity))) {
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');
+ }
+
}
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;
$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':
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);
}
$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;
}
--- /dev/null
+<?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']] : [];
+ }
+
+}
'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'],
+ ],
+ ],
];
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;
*/
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
*
$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')
$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) {
$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']);
+ }
+
}