APIv4 - Add autocomplete action
authorColeman Watts <coleman@civicrm.org>
Wed, 27 Jul 2022 22:56:44 +0000 (18:56 -0400)
committerColeman Watts <coleman@civicrm.org>
Wed, 10 Aug 2022 02:28:44 +0000 (22:28 -0400)
APIv4 action to supply data to the new autocomplete widget.

Civi/Api4/Generic/AutocompleteAction.php [new file with mode: 0644]
Civi/Api4/Generic/BasicEntity.php
Civi/Api4/Generic/DAOEntity.php
Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php [moved from ext/search_kit/Civi/Api4/Action/SearchDisplay/SavedSearchInspectorTrait.php with 60% similarity]
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php

diff --git a/Civi/Api4/Generic/AutocompleteAction.php b/Civi/Api4/Generic/AutocompleteAction.php
new file mode 100644 (file)
index 0000000..339245f
--- /dev/null
@@ -0,0 +1,122 @@
+<?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\Generic;
+
+use Civi\Api4\Utils\CoreUtil;
+
+/**
+ * Retrieve $ENTITIES for an autocomplete form field.
+ */
+class AutocompleteAction extends AbstractAction {
+  use Traits\SavedSearchInspectorTrait;
+
+  /**
+   * Autocomplete search input for search mode
+   *
+   * @var string
+   */
+  protected $input = '';
+
+  /**
+   * Array of ids for render mode
+   *
+   * @var array
+   */
+  protected $ids;
+
+  /**
+   * @var int
+   */
+  protected $page = 1;
+
+  /**
+   * Name of SavedSearch to use for filtering.
+   * @var string
+   */
+  protected $savedSearch;
+
+  /**
+   * @var string
+   */
+  protected $formName;
+
+  /**
+   * @var string
+   */
+  protected $fieldName;
+
+  /**
+   * Fetch results.
+   *
+   * @param \Civi\Api4\Generic\Result $result
+   */
+  public function _run(Result $result) {
+    $entityName = $this->getEntityName();
+    $fields = CoreUtil::getApiClass($entityName)::get()->entityFields();
+    $idField = CoreUtil::getIdFieldName($entityName);
+    $labelField = CoreUtil::getInfoItem($entityName, 'label_field');
+    $map = [
+      'id' => $idField,
+      'text' => $labelField,
+    ];
+    // FIXME: Use metadata
+    if (isset($fields['description'])) {
+      $map['description'] = 'description';
+    }
+    if (isset($fields['color'])) {
+      $map['color'] = 'color';
+    }
+    if (isset($fields['icon'])) {
+      $map['icon'] = 'icon';
+    }
+
+    if (!$this->savedSearch) {
+      $this->savedSearch = ['api_entity' => $entityName];
+    }
+    $this->loadSavedSearch();
+    // Render mode: fetch by id
+    if ($this->ids) {
+      $this->_apiParams['where'][] = [$idField, 'IN', $this->ids];
+      $resultsPerPage = 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;
+      if (strlen($this->input)) {
+        $prefix = \Civi::settings()->get('includeWildCardInName') ? '%' : '';
+        $this->_apiParams['where'][] = [$labelField, 'LIKE', $prefix . $this->input . '%'];
+      }
+    }
+    if (empty($this->_apiParams['having'])) {
+      $this->_apiParams['select'] = array_values($map);
+    }
+    else {
+      $this->_apiParams['select'] = array_merge($this->_apiParams['select'], array_values($map));
+    }
+    $this->_apiParams['checkPermissions'] = $this->getCheckPermissions();
+    $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];
+      }
+      $result[] = $mapped;
+    }
+    $result->setCountMatched($apiResult->countFetched());
+  }
+
+}
index 066ef04e7a5c54b379ccc72857337b2f351b51b4..87ae77c443624fbef9b55ca570f08d9eabf94cb9 100644 (file)
@@ -136,6 +136,15 @@ abstract class BasicEntity extends AbstractEntity {
       ->setCheckPermissions($checkPermissions);
   }
 
+  /**
+   * @param bool $checkPermissions
+   * @return AutocompleteAction
+   */
+  public static function autocomplete($checkPermissions = TRUE) {
+    return (new AutocompleteAction(static::getEntityName(), __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
   /**
    * @inheritDoc
    */
index 3bec3919bbdd129a52cdb1535c0c09aeb2af68f3..ea7473e87a9fee0608c3d712f9e4eb4335373ff9 100644 (file)
@@ -90,4 +90,13 @@ abstract class DAOEntity extends AbstractEntity {
       ->setCheckPermissions($checkPermissions);
   }
 
+  /**
+   * @param bool $checkPermissions
+   * @return AutocompleteAction
+   */
+  public static function autocomplete($checkPermissions = TRUE) {
+    return (new AutocompleteAction(static::getEntityName(), __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
 }
similarity index 60%
rename from ext/search_kit/Civi/Api4/Action/SearchDisplay/SavedSearchInspectorTrait.php
rename to Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
index 1f935a4619290728e9ea699a8e6731c31616cf50..aeb9648d5ef6edeb8f366ecca683e165b5dc1103 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Civi\Api4\Action\SearchDisplay;
+namespace Civi\Api4\Generic\Traits;
 
 use Civi\API\Request;
 use Civi\Api4\Query\SqlExpression;
@@ -176,4 +176,102 @@ trait SavedSearchInspectorTrait {
     return !in_array($idField, $apiParams['groupBy']);
   }
 
+  /**
+   * @param array $fieldNames
+   *   If multiple field names are given they will be combined in an OR clause
+   * @param mixed $value
+   */
+  protected function applyFilter(array $fieldNames, $value) {
+    // Global setting determines if % wildcard should be added to both sides (default) or only the end of a search string
+    $prefixWithWildcard = \Civi::settings()->get('includeWildCardInName');
+
+    // Based on the first field, decide which clause to add this condition to
+    $fieldName = $fieldNames[0];
+    $field = $this->getField($fieldName);
+    // If field is not found it must be an aggregated column & belongs in the HAVING clause.
+    if (!$field) {
+      $this->_apiParams += ['having' => []];
+      $clause =& $this->_apiParams['having'];
+    }
+    // If field belongs to an EXCLUDE join, it should be added as a join condition
+    else {
+      $prefix = strpos($fieldName, '.') ? explode('.', $fieldName)[0] : NULL;
+      foreach ($this->_apiParams['join'] ?? [] as $idx => $join) {
+        if (($join[1] ?? 'LEFT') === 'EXCLUDE' && (explode(' AS ', $join[0])[1] ?? '') === $prefix) {
+          $clause =& $this->_apiParams['join'][$idx];
+        }
+      }
+    }
+    // Default: add filter to WHERE clause
+    if (!isset($clause)) {
+      $clause =& $this->_apiParams['where'];
+    }
+
+    $filterClauses = [];
+
+    foreach ($fieldNames as $fieldName) {
+      $field = $this->getField($fieldName);
+      $dataType = $field['data_type'] ?? NULL;
+      // Array is either associative `OP => VAL` or sequential `IN (...)`
+      if (is_array($value)) {
+        $value = array_filter($value, [$this, 'hasValue']);
+        // If array does not contain operators as keys, assume array of values
+        if (array_diff_key($value, array_flip(CoreUtil::getOperators()))) {
+          // Use IN for regular fields
+          if (empty($field['serialize'])) {
+            $filterClauses[] = [$fieldName, 'IN', $value];
+          }
+          // Use an OR group of CONTAINS for array fields
+          else {
+            $orGroup = [];
+            foreach ($value as $val) {
+              $orGroup[] = [$fieldName, 'CONTAINS', $val];
+            }
+            $filterClauses[] = ['OR', $orGroup];
+          }
+        }
+        // Operator => Value array
+        else {
+          $andGroup = [];
+          foreach ($value as $operator => $val) {
+            $andGroup[] = [$fieldName, $operator, $val];
+          }
+          $filterClauses[] = ['AND', $andGroup];
+        }
+      }
+      elseif (!empty($field['serialize'])) {
+        $filterClauses[] = [$fieldName, 'CONTAINS', $value];
+      }
+      elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
+        $filterClauses[] = [$fieldName, '=', $value];
+      }
+      elseif ($prefixWithWildcard) {
+        $filterClauses[] = [$fieldName, 'CONTAINS', $value];
+      }
+      else {
+        $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
+      }
+    }
+    // Single field
+    if (count($filterClauses) === 1) {
+      $clause[] = $filterClauses[0];
+    }
+    else {
+      $clause[] = ['OR', $filterClauses];
+    }
+  }
+
+  /**
+   * Checks if a filter contains a non-empty value
+   *
+   * "Empty" search values are [], '', and NULL.
+   * Also recursively checks arrays to ensure they contain at least one non-empty value.
+   *
+   * @param $value
+   * @return bool
+   */
+  protected function hasValue($value) {
+    return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue']));
+  }
+
 }
index 5df9ef6d1ef64c63283eae3a04f6ebf4d9c189a5..69526ce04fa5a18276773cefcff3174c9734d6d6 100644 (file)
@@ -3,7 +3,6 @@
 namespace Civi\Api4\Action\SearchDisplay;
 
 use Civi\API\Exception\UnauthorizedException;
-use Civi\Api4\Generic\Traits\ArrayQueryActionTrait;
 use Civi\Api4\Query\SqlField;
 use Civi\Api4\SearchDisplay;
 use Civi\Api4\Utils\CoreUtil;
@@ -26,8 +25,8 @@ use Civi\Api4\Utils\FormattingUtil;
  */
 abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
 
-  use SavedSearchInspectorTrait;
-  use ArrayQueryActionTrait;
+  use \Civi\Api4\Generic\Traits\SavedSearchInspectorTrait;
+  use \Civi\Api4\Generic\Traits\ArrayQueryActionTrait;
 
   /**
    * Either the name of the display or an array containing the display definition (for preview mode)
@@ -828,91 +827,6 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     return $result;
   }
 
-  /**
-   * @param array $fieldNames
-   *   If multiple field names are given they will be combined in an OR clause
-   * @param mixed $value
-   */
-  private function applyFilter(array $fieldNames, $value) {
-    // Global setting determines if % wildcard should be added to both sides (default) or only the end of a search string
-    $prefixWithWildcard = \Civi::settings()->get('includeWildCardInName');
-
-    // Based on the first field, decide which clause to add this condition to
-    $fieldName = $fieldNames[0];
-    $field = $this->getField($fieldName);
-    // If field is not found it must be an aggregated column & belongs in the HAVING clause.
-    if (!$field) {
-      $this->_apiParams += ['having' => []];
-      $clause =& $this->_apiParams['having'];
-    }
-    // If field belongs to an EXCLUDE join, it should be added as a join condition
-    else {
-      $prefix = strpos($fieldName, '.') ? explode('.', $fieldName)[0] : NULL;
-      foreach ($this->_apiParams['join'] ?? [] as $idx => $join) {
-        if (($join[1] ?? 'LEFT') === 'EXCLUDE' && (explode(' AS ', $join[0])[1] ?? '') === $prefix) {
-          $clause =& $this->_apiParams['join'][$idx];
-        }
-      }
-    }
-    // Default: add filter to WHERE clause
-    if (!isset($clause)) {
-      $clause =& $this->_apiParams['where'];
-    }
-
-    $filterClauses = [];
-
-    foreach ($fieldNames as $fieldName) {
-      $field = $this->getField($fieldName);
-      $dataType = $field['data_type'] ?? NULL;
-      // Array is either associative `OP => VAL` or sequential `IN (...)`
-      if (is_array($value)) {
-        $value = array_filter($value, [$this, 'hasValue']);
-        // If array does not contain operators as keys, assume array of values
-        if (array_diff_key($value, array_flip(CoreUtil::getOperators()))) {
-          // Use IN for regular fields
-          if (empty($field['serialize'])) {
-            $filterClauses[] = [$fieldName, 'IN', $value];
-          }
-          // Use an OR group of CONTAINS for array fields
-          else {
-            $orGroup = [];
-            foreach ($value as $val) {
-              $orGroup[] = [$fieldName, 'CONTAINS', $val];
-            }
-            $filterClauses[] = ['OR', $orGroup];
-          }
-        }
-        // Operator => Value array
-        else {
-          $andGroup = [];
-          foreach ($value as $operator => $val) {
-            $andGroup[] = [$fieldName, $operator, $val];
-          }
-          $filterClauses[] = ['AND', $andGroup];
-        }
-      }
-      elseif (!empty($field['serialize'])) {
-        $filterClauses[] = [$fieldName, 'CONTAINS', $value];
-      }
-      elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
-        $filterClauses[] = [$fieldName, '=', $value];
-      }
-      elseif ($prefixWithWildcard) {
-        $filterClauses[] = [$fieldName, 'CONTAINS', $value];
-      }
-      else {
-        $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
-      }
-    }
-    // Single field
-    if (count($filterClauses) === 1) {
-      $clause[] = $filterClauses[0];
-    }
-    else {
-      $clause[] = ['OR', $filterClauses];
-    }
-  }
-
   /**
    * Transforms the SORT param (which is expected to be an array of arrays)
    * to the ORDER BY clause (which is an associative array of [field => DIR]
@@ -1047,19 +961,6 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     return $result ?: $alias;
   }
 
-  /**
-   * Checks if a filter contains a non-empty value
-   *
-   * "Empty" search values are [], '', and NULL.
-   * Also recursively checks arrays to ensure they contain at least one non-empty value.
-   *
-   * @param $value
-   * @return bool
-   */
-  private function hasValue($value) {
-    return $value !== '' && $value !== NULL && (!is_array($value) || array_filter($value, [$this, 'hasValue']));
-  }
-
   /**
    * Returns a list of afform fields used as search filters
    *
index 56dc8967b97500f699459ed5dcc5cdfaaea383d0..2f387cfce7b07ad010de3b56e45c28d1607f17f6 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Civi\Api4\Action\SearchDisplay;
 
+use Civi\Api4\Generic\Traits\SavedSearchInspectorTrait;
 use Civi\Api4\SavedSearch;
 use Civi\Api4\Utils\FormattingUtil;
 use Civi\Search\Display;