From 8e9b940f0f6e0e49cbfdc0fac4e2c05ce8d6ab6e Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Tue, 15 Nov 2022 08:18:11 -0500 Subject: [PATCH] APIv4 Autocomplete improvements - Support searching by ID, customize some entities Gives parity with the v3-based widget which would search either by id or label and place the exact match at the top if found. Also gives parity with the customized output of some v3 entities, plus a few like Relationship that v3 never was able to handle. --- Civi/Api4/Generic/AutocompleteAction.php | 91 ++++++------- .../Traits/SavedSearchInspectorTrait.php | 3 +- Civi/Api4/Query/SqlEquation.php | 31 ++++- .../ActivityAutocompleteProvider.php | 121 ++++++++++++++++++ .../AddressAutocompleteProvider.php | 59 +++++++++ .../Autocomplete/CaseAutocompleteProvider.php | 2 + .../ContactAutocompleteProvider.php | 56 ++++++++ .../ParticipantAutocompleteProvider.php | 57 +++++++++ .../RelationshipAutocompleteProvider.php | 53 ++++++++ .../StateProvinceAutocompleteProvider.php | 50 ++++++++ .../Subscriber/DefaultDisplaySubscriber.php | 12 ++ .../searchAdminDisplayAutocomplete.html | 6 +- .../api/v4/SearchDisplay/UtilsTest.php | 57 +++++++++ .../api/v4/Action/AutocompleteTest.php | 66 +++++++++- tests/phpunit/api/v4/Api4TestBase.php | 13 +- 15 files changed, 619 insertions(+), 58 deletions(-) create mode 100644 Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php create mode 100644 Civi/Api4/Service/Autocomplete/AddressAutocompleteProvider.php create mode 100644 Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php create mode 100644 Civi/Api4/Service/Autocomplete/ParticipantAutocompleteProvider.php create mode 100644 Civi/Api4/Service/Autocomplete/RelationshipAutocompleteProvider.php create mode 100644 Civi/Api4/Service/Autocomplete/StateProvinceAutocompleteProvider.php create mode 100644 ext/search_kit/tests/phpunit/api/v4/SearchDisplay/UtilsTest.php diff --git a/Civi/Api4/Generic/AutocompleteAction.php b/Civi/Api4/Generic/AutocompleteAction.php index 561201e09c..874e965420 100644 --- a/Civi/Api4/Generic/AutocompleteAction.php +++ b/Civi/Api4/Generic/AutocompleteAction.php @@ -131,29 +131,46 @@ class AutocompleteAction extends AbstractAction { // Pass-through this parameter $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'])))); - } + $keyField = $this->getKeyField(); + $displayFields = $this->getDisplayFields(); + $this->augmentSelectClause($keyField, $displayFields); // Render mode: fetch by id if ($this->ids) { - $this->savedSearch['api_params']['where'][] = [$idField, 'IN', $this->ids]; + $this->savedSearch['api_params']['where'][] = [$keyField, 'IN', $this->ids]; unset($this->display['settings']['pager']); $return = NULL; } // Search mode: fetch a page of results based on input else { + // Default search and sort field + $labelField = $this->display['settings']['columns'][0]['key']; + $idField = CoreUtil::getIdFieldName($this->savedSearch['api_entity']); + $this->display['settings'] += [ + 'sort' => [$labelField, 'ASC'], + ]; + // Always search on the first line of the display + $searchFields = [$labelField]; + // If input is an integer, search by id + if (\CRM_Utils_Rule::positiveInteger($this->input)) { + $searchFields[] = $idField; + // Add a sort clause to place exact ID match at the top + array_unshift($this->display['settings']['sort'], [ + "($idField = $this->input)", + 'DESC', + ]); + } + // If first line uses a rewrite, search on those fields too + if (!empty($this->display['settings']['columns'][0]['rewrite'])) { + $searchFields = array_merge($searchFields, $this->getTokens($this->display['settings']['columns'][0]['rewrite'])); + } $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); + // SearchKit treats comma-separated fieldnames as OR clauses + $this->addFilter(implode(',', array_unique($searchFields)), $this->input); } - $this->augmentSelectClause($idField); - $apiResult = \Civi\Api4\SearchDisplay::run(FALSE) ->setSavedSearch($this->savedSearch) ->setDisplay($this->display) @@ -163,7 +180,7 @@ class AutocompleteAction extends AbstractAction { foreach ($apiResult as $row) { $item = [ - 'id' => $row['data'][$idField], + 'id' => $row['data'][$keyField], 'label' => $row['columns'][0]['val'], 'icon' => $row['columns'][0]['icons'][0]['class'] ?? NULL, 'description' => [], @@ -191,20 +208,31 @@ class AutocompleteAction extends AbstractAction { } /** - * Ensure SELECT param includes all display fields & trusted filters + * Gather all fields used by the display * - * @param string $idField + * @return array */ - private function augmentSelectClause(string $idField) { - $select = [$idField]; + private function getDisplayFields() { + $fields = []; foreach ($this->display['settings']['columns'] as $column) { if ($column['type'] === 'field') { - $select[] = $column['key']; + $fields[] = $column['key']; } if (!empty($column['rewrite'])) { - $select = array_merge($select, $this->getTokens($column['rewrite'])); + $fields = array_merge($fields, $this->getTokens($column['rewrite'])); } } + return array_unique($fields); + } + + /** + * Ensure SELECT param includes all display fields & trusted filters + * + * @param string $idField + * @param array $displayFields + */ + private function augmentSelectClause(string $idField, array $displayFields) { + $select = array_merge([$idField], $displayFields); // Add trustedFilters to the SELECT clause so that SearchDisplay::run will trust them foreach ($this->trustedFilters as $fields => $val) { $select = array_merge($select, explode(',', $fields)); @@ -217,37 +245,14 @@ class AutocompleteAction extends AbstractAction { } /** - * @param $fieldNameWithSuffix - * @return bool - */ - private function checkFieldAccess($fieldNameWithSuffix) { - [$fieldName] = explode(':', $fieldNameWithSuffix); - if ( - in_array($fieldName, $this->_apiParams['select'], TRUE) || - in_array($fieldNameWithSuffix, $this->_apiParams['select'], TRUE) || - in_array($fieldName, $this->savedSearch['api_params']['select'], TRUE) || - in_array($fieldNameWithSuffix, $this->savedSearch['api_params']['select'], TRUE) - ) { - return TRUE; - } - // Proceed only if permissions are being enforced.' - // Anonymous users in permission-bypass mode should not be allowed to set arbitrary filters. - if ($this->getCheckPermissions()) { - // This function checks field permissions - return (bool) $this->getField($fieldName); - } - return FALSE; - } - - /** - * By default, returns the primary key of the entity (typically `id`). + * Get the field by which results will be keyed (typically `id` unless $this->key is set). * * 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. + * This is a security measure. Allowing any value could give access to potentially sensitive data. * * @return string */ - private function getIdFieldName() { + private function getKeyField() { $entityName = $this->savedSearch['api_entity']; if ($this->key) { /** @var \CRM_Core_DAO $dao */ diff --git a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php index 051df4a5f1..f40b384690 100644 --- a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php +++ b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php @@ -315,8 +315,9 @@ trait SavedSearchInspectorTrait { * Search a string for all square bracket tokens and return their contents (without the brackets) * * @param string $str + * @return array */ - protected function getTokens($str) { + protected function getTokens(string $str): array { $tokens = []; preg_match_all('/\\[([^]]+)\\]/', $str, $tokens); return array_unique($tokens[1]); diff --git a/Civi/Api4/Query/SqlEquation.php b/Civi/Api4/Query/SqlEquation.php index 8ce182fa6a..71e85d7ec4 100644 --- a/Civi/Api4/Query/SqlEquation.php +++ b/Civi/Api4/Query/SqlEquation.php @@ -81,13 +81,17 @@ class SqlEquation extends SqlExpression { */ public function render(Api4SelectQuery $query): string { $output = []; - foreach ($this->args as $arg) { + foreach ($this->args as $i => $arg) { // Just an operator - if (is_string($arg)) { + if ($this->getOperatorType($arg)) { $output[] = $arg; } - // Surround fields with COALESCE to handle null values - elseif (is_a($arg, SqlField::class)) { + // Surround fields with COALESCE to prevent null values when using arithmetic operators + elseif (is_a($arg, SqlField::class) && ( + $this->getOperatorType($this->args[$i - 1] ?? NULL) === 'arithmetic' || + $this->getOperatorType($this->args[$i + 1] ?? NULL) === 'arithmetic' + ) + ) { $output[] = 'COALESCE(' . $arg->render($query) . ', 0)'; } else { @@ -106,6 +110,25 @@ class SqlEquation extends SqlExpression { return $this->alias ?? \CRM_Utils_String::munge(trim($this->expr, ' ()'), '_', 256); } + /** + * Check if an item is an operator and if so what category it belongs to + * + * @param $item + * @return string|null + */ + protected function getOperatorType($item): ?string { + if (!is_string($item)) { + return NULL; + } + if (in_array($item, self::$arithmeticOperators, TRUE)) { + return 'arithmetic'; + } + if (in_array($item, self::$comparisonOperators, TRUE)) { + return 'comparison'; + } + return NULL; + } + /** * Change $dataType according to operator used in equation * diff --git a/Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php new file mode 100644 index 0000000000..144035c90a --- /dev/null +++ b/Civi/Api4/Service/Autocomplete/ActivityAutocompleteProvider.php @@ -0,0 +1,121 @@ +savedSearch) || $e->savedSearch['api_entity'] !== 'Activity') { + return; + } + $e->savedSearch['api_params'] = [ + 'version' => 4, + 'select' => [ + 'id', + 'subject', + 'activity_date_time', + 'Activity_ActivityContact_Contact_01.display_name', + 'activity_type_id:label', + ], + 'orderBy' => [], + 'where' => [], + 'groupBy' => [], + 'join' => [ + [ + 'Contact AS Activity_ActivityContact_Contact_01', + 'LEFT', + 'ActivityContact', + ['id', '=', 'Activity_ActivityContact_Contact_01.activity_id'], + ['Activity_ActivityContact_Contact_01.record_type_id:name', '=', '"Activity Targets"'], + ], + ], + 'having' => [], + ]; + } + + /** + * Provide default SearchDisplay for Activity autocompletes + * + * @param \Civi\Core\Event\GenericHookEvent $e + */ + public static function on_civi_search_defaultDisplay(GenericHookEvent $e) { + if ($e->display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'Activity') { + return; + } + // Basic settings with no join + // We won't assume the SavedSearch includes a contact join, because it's possible to override + // the savedSearch for an autocomplete and still use this default display. + $e->display['settings'] = [ + 'sort' => [ + ['subject', 'ASC'], + ], + 'columns' => [ + [ + 'type' => 'field', + 'key' => 'subject', + 'empty_value' => '(' . ts('no subject') . ')', + 'icons' => [ + ['field' => 'activity_type_id:icon'], + ], + ], + [ + 'type' => 'field', + 'key' => 'id', + 'rewrite' => '#[id] [activity_type_id:label]', + ], + [ + 'type' => 'field', + 'key' => 'status_id:label', + 'rewrite' => '[status_id:label] - [activity_date_time]', + ], + ], + ]; + // If the savedSearch includes a contact join, add it to the output and the sort. + foreach ($e->savedSearch['api_params']['join'] ?? [] as $join) { + [$entity, $contactAlias] = explode(' AS ', $join[0]); + if ($entity === 'Contact') { + array_unshift($e->display['settings']['sort'], ["$contactAlias.sort_name", 'ASC']); + $e->display['settings']['columns'][0]['rewrite'] = "[$contactAlias.display_name] - [subject]"; + $e->display['settings']['columns'][0]['empty_value'] = "[$contactAlias.display_name] (" . ts('no subject') . ')'; + break; + } + } + // If CiviCampaign is enabled + if (\CRM_Core_Component::isEnabled('CiviCampaign')) { + $e->display['settings']['columns'][] = [ + 'type' => 'field', + 'key' => 'campaign_id.title', + ]; + } + // If CiviCase is enabled + if (\CRM_Core_Component::isEnabled('CiviCase')) { + $e->display['settings']['columns'][] = [ + 'type' => 'field', + 'key' => 'case_id.subject', + ]; + } + } + +} diff --git a/Civi/Api4/Service/Autocomplete/AddressAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/AddressAutocompleteProvider.php new file mode 100644 index 0000000000..74ab84a0a1 --- /dev/null +++ b/Civi/Api4/Service/Autocomplete/AddressAutocompleteProvider.php @@ -0,0 +1,59 @@ +display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'Address') { + return; + } + $e->display['settings'] = [ + 'sort' => [ + ['street_address', 'ASC'], + ], + 'columns' => [ + [ + 'type' => 'field', + 'key' => 'street_address', + 'rewrite' => '[street_address], [city]', + 'empty_value' => '[city]', + ], + [ + 'type' => 'field', + 'key' => 'state_province_id.name', + 'rewrite' => '[state_province_id.name], [country_id.name]', + 'empty_value' => '[country_id.name]', + ], + [ + 'type' => 'field', + 'key' => 'id', + 'rewrite' => '#[id]', + ], + ], + ]; + } + +} diff --git a/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php index 5bc7476b95..ed13b5c951 100644 --- a/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php +++ b/Civi/Api4/Service/Autocomplete/CaseAutocompleteProvider.php @@ -75,6 +75,7 @@ class CaseAutocompleteProvider extends \Civi\Core\Service\AutoService implements [ 'type' => 'field', 'key' => 'subject', + 'empty_value' => '(' . ts('no subject') . ')', ], [ 'type' => 'field', @@ -94,6 +95,7 @@ class CaseAutocompleteProvider extends \Civi\Core\Service\AutoService implements if ($entity === 'Contact') { array_unshift($e->display['settings']['sort'], ["$contactAlias.sort_name", 'ASC']); $e->display['settings']['columns'][0]['rewrite'] = "[$contactAlias.display_name] - [subject]"; + $e->display['settings']['columns'][0]['empty_value'] = "[$contactAlias.display_name] (" . ts('no subject') . ')'; break; } } diff --git a/Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php new file mode 100644 index 0000000000..1aadc64464 --- /dev/null +++ b/Civi/Api4/Service/Autocomplete/ContactAutocompleteProvider.php @@ -0,0 +1,56 @@ +display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'Contact') { + return; + } + $e->display['settings'] = [ + 'sort' => [ + ['sort_name', 'ASC'], + ], + 'columns' => [ + [ + 'type' => 'field', + 'key' => 'display_name', + 'icons' => [ + ['field' => 'contact_sub_type:icon'], + ['field' => 'contact_type:icon'], + ], + ], + [ + 'type' => 'field', + 'key' => 'contact_sub_type:label', + 'rewrite' => '#[id] [contact_sub_type:label]', + 'empty_value' => '#[id] [contact_type:label]', + ], + ], + ]; + } + +} diff --git a/Civi/Api4/Service/Autocomplete/ParticipantAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/ParticipantAutocompleteProvider.php new file mode 100644 index 0000000000..2131e3cd8d --- /dev/null +++ b/Civi/Api4/Service/Autocomplete/ParticipantAutocompleteProvider.php @@ -0,0 +1,57 @@ +display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'Participant') { + return; + } + $e->display['settings'] = [ + 'sort' => [ + ['contact_id.sort_name', 'ASC'], + ['event_id.title', 'ASC'], + ], + 'columns' => [ + [ + 'type' => 'field', + 'key' => 'contact_id.display_name', + 'rewrite' => '[contact_id.display_name] - [event_id.title]', + ], + [ + 'type' => 'field', + 'key' => 'role_id:label', + 'rewrite' => '#[id] [role_id:label]', + ], + [ + 'type' => 'field', + 'key' => 'status_id:label', + ], + ], + ]; + } + +} diff --git a/Civi/Api4/Service/Autocomplete/RelationshipAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/RelationshipAutocompleteProvider.php new file mode 100644 index 0000000000..f86bcbaae6 --- /dev/null +++ b/Civi/Api4/Service/Autocomplete/RelationshipAutocompleteProvider.php @@ -0,0 +1,53 @@ +display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'Relationship') { + return; + } + $e->display['settings'] = [ + 'sort' => [ + ['contact_id_a.sort_name', 'ASC'], + ], + 'columns' => [ + [ + 'type' => 'field', + 'key' => 'relationship_type_id.label_a_b', + 'rewrite' => '[contact_id_a.display_name] [relationship_type_id.label_a_b] [contact_id_b.display_name]', + ], + [ + 'type' => 'field', + 'key' => 'description', + 'rewrite' => '#[id] [description]', + 'empty_value' => '#[id]', + ], + ], + ]; + } + +} diff --git a/Civi/Api4/Service/Autocomplete/StateProvinceAutocompleteProvider.php b/Civi/Api4/Service/Autocomplete/StateProvinceAutocompleteProvider.php new file mode 100644 index 0000000000..8a19a2df6f --- /dev/null +++ b/Civi/Api4/Service/Autocomplete/StateProvinceAutocompleteProvider.php @@ -0,0 +1,50 @@ +display['settings'] || $e->display['type'] !== 'autocomplete' || $e->savedSearch['api_entity'] !== 'StateProvince') { + return; + } + $e->display['settings'] = [ + 'sort' => [ + ['name', 'ASC'], + ], + 'columns' => [ + [ + 'type' => 'field', + 'key' => 'name', + ], + [ + 'type' => 'field', + 'key' => 'country_id.name', + ], + ], + ]; + } + +} diff --git a/ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php b/ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php index 9114362970..ee97e7623f 100644 --- a/ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php +++ b/ext/search_kit/Civi/Api4/Event/Subscriber/DefaultDisplaySubscriber.php @@ -57,6 +57,7 @@ class DefaultDisplaySubscriber extends \Civi\Core\Service\AutoService implements if (!$entityName) { throw new \CRM_Core_Exception("Entity name is required to get autocomplete default display."); } + $idField = CoreUtil::getIdFieldName($entityName); $labelField = CoreUtil::getInfoItem($entityName, 'label_field'); if (!$labelField) { throw new \CRM_Core_Exception("Entity $entityName has no default label field."); @@ -67,6 +68,11 @@ class DefaultDisplaySubscriber extends \Civi\Core\Service\AutoService implements $fields = CoreUtil::getApiClass($entityName)::get()->entityFields(); $columns = [$labelField]; + // Add grouping fields like "event_type_id" in the description + $grouping = (array) (CoreUtil::getCustomGroupExtends($entityName)['grouping'] ?? []); + foreach ($grouping as $fieldName) { + $columns[] = "$fieldName:label"; + } if (isset($fields['description'])) { $columns[] = 'description'; } @@ -78,6 +84,12 @@ class DefaultDisplaySubscriber extends \Civi\Core\Service\AutoService implements 'key' => $columnField, ]; } + // Include entity id on the second line + $e->display['settings']['columns'][1] = [ + 'type' => 'field', + 'key' => $idField, + 'rewrite' => "#[$idField]" . (isset($columns[1]) ? " [$columns[1]]" : ''), + ]; // Default icons $iconFields = CoreUtil::getInfoItem($entityName, 'icon_field') ?? []; diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayAutocomplete.html b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayAutocomplete.html index a59abe35fb..7cf9be2c1b 100644 --- a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayAutocomplete.html +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayAutocomplete.html @@ -1,6 +1,4 @@ -
-
@@ -8,6 +6,10 @@ {{:: ts('Fields') }}
+

+ {{:: ts("The top-most line will be shown as the searchable title (combine multiple fields using rewrite + tokens).") }} + {{:: ts("Other lines will be shown below in smaller text, and will not be searchable (except for ID which is always searchable).") }} +

diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/UtilsTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/UtilsTest.php new file mode 100644 index 0000000000..8f517f52a0 --- /dev/null +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/UtilsTest.php @@ -0,0 +1,57 @@ +installMe(__DIR__) + ->apply(); + } + + public function tokenExamples(): array { + return [ + [ + '', + [], + ], + [ + 'Hello :]', + [], + ], + [ + '[whatever:', + [], + ], + [ + '#[id] [participant_id.role_id:label] [id]', + ['id', 'participant_id.role_id:label'], + ], + [ + '[contact_id.display_name] - [event_id.title]', + ['contact_id.display_name', 'event_id.title'], + ], + ]; + } + + /** + * @dataProvider tokenExamples + */ + public function testGetTokens($input, $expected) { + $method = new \ReflectionMethod('\Civi\Api4\Generic\AutocompleteAction', 'getTokens'); + $method->setAccessible(TRUE); + + $action = Contact::autocomplete(); + $this->assertEquals($expected, $method->invoke($action, $input)); + } + +} diff --git a/tests/phpunit/api/v4/Action/AutocompleteTest.php b/tests/phpunit/api/v4/Action/AutocompleteTest.php index 1ec4c2c5f3..9c5c7d5c92 100644 --- a/tests/phpunit/api/v4/Action/AutocompleteTest.php +++ b/tests/phpunit/api/v4/Action/AutocompleteTest.php @@ -232,8 +232,8 @@ class AutocompleteTest extends Api4TestBase implements HookInterface, Transactio $this->assertEquals(1, $result3->countMatched()); } - public function testAutocompleteIdField() { - $label = uniqid(); + public function testAutocompleteWithDifferentKey() { + $label = $this->randomLetters(); $sample = $this->saveTestRecords('SavedSearch', [ 'records' => [ ['name' => 'c', 'label' => "C $label"], @@ -241,7 +241,7 @@ class AutocompleteTest extends Api4TestBase implements HookInterface, Transactio ['name' => 'b', 'label' => "B $label"], ], 'defaults' => ['api_entity' => 'Contact'], - ]); + ])->indexBy('name'); $result1 = SavedSearch::autocomplete() ->setInput($label) @@ -252,15 +252,69 @@ class AutocompleteTest extends Api4TestBase implements HookInterface, Transactio $this->assertEquals('b', $result1[1]['id']); $this->assertEquals('c', $result1[2]['id']); + // Try searching by ID - should only get one result + $result1 = SavedSearch::autocomplete() + ->setInput((string) $sample['b']['id']) + ->setKey('name') + ->execute(); + $this->assertCount(1, $result1); + $this->assertEquals('b', $result1[0]['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']); + $this->assertEquals($sample['a']['id'], $result2[0]['id']); + $this->assertEquals($sample['b']['id'], $result2[1]['id']); + $this->assertEquals($sample['c']['id'], $result2[2]['id']); + } + + public function testContactAutocompleteById() { + $firstName = $this->randomLetters(); + + $contacts = $this->saveTestRecords('Contact', [ + 'records' => array_fill(0, 15, ['first_name' => $firstName]), + ]); + + $cid = $contacts[11]['id']; + + Contact::save(FALSE) + ->addRecord(['id' => $contacts[0]['id'], 'last_name' => "Aaaac$cid"]) + ->addRecord(['id' => $contacts[14]['id'], 'last_name' => "Aaaab$cid"]) + ->addRecord(['id' => $contacts[6]['id'], 'last_name' => "Aaaaa$cid"]) + ->execute(); + + $result = Contact::autocomplete() + ->setInput((string) $cid) + ->execute(); + + // Exact match should be at beginning of the list + $this->assertContains($cid, $result->column('id')); + $this->assertEquals($cid, $result[0]['id']); + // If by chance there are other matching contacts in the db, skip over them + foreach ($result as $i => $row) { + if ($row['id'] == $contacts[6]['id']) { + break; + } + } + // The other 3 matches should be in order + $this->assertEquals($contacts[6]['id'], $result[$i]['id']); + $this->assertEquals($contacts[14]['id'], $result[$i + 1]['id']); + $this->assertEquals($contacts[0]['id'], $result[$i + 2]['id']); + + // Ensure partial match doesn't work (end of id) + $result = Contact::autocomplete() + ->setInput(substr((string) $cid, -1)) + ->execute(); + $this->assertNotContains($cid, $result->column('id')); + + // Ensure partial match doesn't work (beginning of id) + $result = Contact::autocomplete() + ->setInput(substr((string) $cid, 0, 1)) + ->execute(); + $this->assertNotContains($cid, $result->column('id')); } } diff --git a/tests/phpunit/api/v4/Api4TestBase.php b/tests/phpunit/api/v4/Api4TestBase.php index 49b5aab024..41a7b7e594 100644 --- a/tests/phpunit/api/v4/Api4TestBase.php +++ b/tests/phpunit/api/v4/Api4TestBase.php @@ -158,6 +158,15 @@ class Api4TestBase extends \PHPUnit\Framework\TestCase implements HeadlessInterf return $saved; } + /** + * Generate some random lowercase letters + * @param int $len + * @return string + */ + public function randomLetters(int $len = 10) { + return \CRM_Utils_String::createRandom($len, implode('', range('a', 'z'))); + } + /** * Get the required fields for the api entity + action. * @@ -352,10 +361,10 @@ class Api4TestBase extends \PHPUnit\Framework\TestCase implements HeadlessInterf return random_int(1, 2000); case 'String': - return \CRM_Utils_String::createRandom(10, implode('', range('a', 'z'))); + return $this->randomLetters(); case 'Text': - return \CRM_Utils_String::createRandom(100, implode('', range('a', 'z'))); + return $this->randomLetters(100); case 'Money': return sprintf('%d.%2d', rand(0, 2000), rand(10, 99)); -- 2.25.1