SearchKit - Filter search listing by author, adding support for OR in filters
authorColeman Watts <coleman@civicrm.org>
Mon, 6 Sep 2021 23:40:10 +0000 (19:40 -0400)
committerColeman Watts <coleman@civicrm.org>
Tue, 7 Sep 2021 21:12:22 +0000 (17:12 -0400)
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/ang/crmSearchAdmin/searchListing/crmSearchAdminSearchListing.component.js
ext/search_kit/ang/crmSearchAdmin/searchListing/crmSearchAdminSearchListing.html
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php

index d18254c7c1b968dfeeb3af62c9fb24be0392d7c3..cdd605f723b8853b26278c1e88e5da597396c581 100644 (file)
@@ -186,17 +186,19 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    * Applies supplied filters to the where clause
    */
   protected function applyFilters() {
+    // Allow all filters that are included in SELECT clause or are fields on the Afform.
+    $allowedFilters = array_merge($this->getSelectAliases(), $this->getAfformFilters());
+
     // Ignore empty strings
     $filters = array_filter($this->filters, [$this, 'hasValue']);
     if (!$filters) {
       return;
     }
 
-    // Process all filters that are included in SELECT clause or are allowed by the Afform.
-    $allowedFilters = array_merge($this->getSelectAliases(), $this->getAfformFilters());
-    foreach ($filters as $fieldName => $value) {
-      if (in_array($fieldName, $allowedFilters, TRUE)) {
-        $this->applyFilter($fieldName, $value);
+    foreach ($filters as $key => $value) {
+      $fieldNames = explode(',', $key);
+      if (in_array($key, $allowedFilters, TRUE) || !array_diff($fieldNames, $allowedFilters)) {
+        $this->applyFilter($fieldNames, $value);
       }
     }
   }
@@ -221,13 +223,16 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
   }
 
   /**
-   * @param string $fieldName
+   * @param array $fieldNames
+   *   If multiple field names are given they will be combined in an OR clause
    * @param mixed $value
    */
-  private function applyFilter(string $fieldName, $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) {
@@ -248,44 +253,57 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
       $clause =& $this->savedSearch['api_params']['where'];
     }
 
-    $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'])) {
-          $clause[] = [$fieldName, 'IN', $value];
+    $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];
+          }
         }
-        // Use an OR group of CONTAINS for array fields
+        // Operator => Value array
         else {
-          $orGroup = [];
-          foreach ($value as $val) {
-            $orGroup[] = [$fieldName, 'CONTAINS', $val];
+          $andGroup = [];
+          foreach ($value as $operator => $val) {
+            $andGroup[] = [$fieldName, $operator, $val];
           }
-          $clause[] = ['OR', $orGroup];
+          $filterClauses[] = ['AND', $andGroup];
         }
       }
-      // Operator => Value array
+      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 {
-        foreach ($value as $operator => $val) {
-          $clause[] = [$fieldName, $operator, $val];
-        }
+        $filterClauses[] = [$fieldName, 'LIKE', $value . '%'];
       }
     }
-    elseif (!empty($field['serialize'])) {
-      $clause[] = [$fieldName, 'CONTAINS', $value];
-    }
-    elseif (!empty($field['options']) || in_array($dataType, ['Integer', 'Boolean', 'Date', 'Timestamp'])) {
-      $clause[] = [$fieldName, '=', $value];
-    }
-    elseif ($prefixWithWildcard) {
-      $clause[] = [$fieldName, 'CONTAINS', $value];
+    // Single field
+    if (count($filterClauses) === 1) {
+      $clause[] = $filterClauses[0];
     }
     else {
-      $clause[] = [$fieldName, 'LIKE', $value . '%'];
+      $clause[] = ['OR', $filterClauses];
     }
   }
 
@@ -383,11 +401,11 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     $filterAttr = $afform['searchDisplay']['filters'] ?? NULL;
     if ($filterAttr && is_string($filterAttr) && $filterAttr[0] === '{') {
       foreach (\CRM_Utils_JS::decode($filterAttr) as $filterKey => $filterVal) {
-        $filterKeys[] = $filterKey;
         // Automatically apply filters from the markup if they have a value
         // (if it's a javascript variable it will have come back from decode() as NULL and we'll ignore it).
+        unset($this->filters[$filterKey]);
         if ($this->hasValue($filterVal)) {
-          $this->applyFilter($filterKey, $filterVal);
+          $this->applyFilter(explode(',', $filterKey), $filterVal);
         }
       }
     }
index 933fa52889f466bb47620a4d58ce1a8bc6e4cfbb..f80bb648780d02a838afc9a8739a76e2728716c0 100644 (file)
@@ -27,6 +27,9 @@
             'api_entity',
             'api_entity:label',
             'api_params',
+            // These two need to be in the select clause so they are allowed as filters
+            'created_id.display_name',
+            'modified_id.display_name',
             'created_date',
             'modified_date',
             'DATE(created_date) AS date_created',
index eb888f0000377ad6b4db72231403d2ddaf8f05c2..473f38b89e40f020a8d10bde942c34b85cd7abff 100644 (file)
@@ -1,7 +1,8 @@
 <div id="bootstrap-theme" class="crm-search">
   <h1 crm-page-title>{{:: ts('Saved Searches') }}</h1>
   <div class="form-inline">
-    <input class="form-control" type="search" id="search-list-filter" ng-model="$ctrl.filters.label" placeholder="{{:: ts('Filter by label...') }}">
+    <input class="form-control" type="search" ng-model="$ctrl.filters.label" placeholder="{{:: ts('Filter by label...') }}">
+    <input class="form-control" type="search" ng-model="$ctrl.filters['created_id.display_name,modified_id.display_name']" placeholder="{{:: ts('Filter by author...') }}">
     <input class="form-control collapsible-optgroups" ng-model="$ctrl.filters.api_entity" ng-list crm-ui-select="{multiple: true, data: $ctrl.entitySelect, placeholder: ts('Filter by entity...')}">
     <input class="form-control" ng-model="$ctrl.filters.tags" ng-list crm-ui-select="{multiple: true, data: $ctrl.getTags, placeholder: ts('Filter by tags...')}">
     <a class="btn btn-primary pull-right" href="#/create/Contact/">
index c22b32e6de3106da3a8a11feea738b5886536d51..c9bb14a4a90eac27d2a25269aabcda0b5a60494e 100644 (file)
@@ -41,7 +41,7 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
       ['first_name' => 'One', 'last_name' => $lastName, 'contact_sub_type' => ['Tester', 'Bot']],
       ['first_name' => 'Two', 'last_name' => $lastName, 'contact_sub_type' => ['Tester']],
       ['first_name' => 'Three', 'last_name' => $lastName, 'contact_sub_type' => ['Bot']],
-      ['first_name' => 'Four', 'last_name' => $lastName],
+      ['first_name' => 'Four', 'middle_name' => 'None', 'last_name' => $lastName],
     ];
     Contact::save(FALSE)->setRecords($sampleData)->execute();
 
@@ -52,7 +52,7 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
         'api_entity' => 'Contact',
         'api_params' => [
           'version' => 4,
-          'select' => ['id', 'first_name', 'last_name', 'contact_sub_type:label', 'is_deceased'],
+          'select' => ['id', 'first_name', 'middle_name', 'last_name', 'contact_sub_type:label', 'is_deceased'],
           'where' => [],
         ],
       ],
@@ -110,18 +110,24 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
     $this->assertEquals(FALSE, $result[0]['is_deceased']['raw']);
     $this->assertEquals(ts('No'), $result[0]['is_deceased']['view']);
 
-    $params['filters'] = ['id' => ['>' => $result[0]['id']['raw'], '<=' => $result[1]['id']['raw'] + 1]];
+    $params['filters'] = ['last_name' => $lastName, 'id' => ['>' => $result[0]['id']['raw'], '<=' => $result[1]['id']['raw'] + 1]];
     $params['sort'] = [['first_name', 'ASC']];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(2, $result);
     $this->assertEquals('Three', $result[0]['first_name']['raw']);
     $this->assertEquals('Two', $result[1]['first_name']['raw']);
 
-    $params['filters'] = ['contact_sub_type:label' => ['Tester', 'Bot']];
+    $params['filters'] = ['last_name' => $lastName, 'contact_sub_type:label' => ['Tester', 'Bot']];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(3, $result);
 
-    $params['filters'] = ['contact_sub_type' => ['Tester']];
+    // Comma indicates first_name OR last_name
+    $params['filters'] = ['first_name,last_name' => $lastName, 'contact_sub_type' => ['Tester']];
+    $result = civicrm_api4('SearchDisplay', 'run', $params);
+    $this->assertCount(2, $result);
+
+    // Comma indicates first_name OR middle_name, matches "One" or "None"
+    $params['filters'] = ['first_name,middle_name' => 'one', 'last_name' => $lastName];
     $result = civicrm_api4('SearchDisplay', 'run', $params);
     $this->assertCount(2, $result);
   }