SearchKit - Support aggregated icon fields
authorcolemanw <coleman@civicrm.org>
Mon, 11 Sep 2023 19:04:56 +0000 (15:04 -0400)
committercolemanw <coleman@civicrm.org>
Tue, 12 Sep 2023 01:52:46 +0000 (21:52 -0400)
Civi/Api4/Generic/AutocompleteAction.php
Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/ang/crmSearchDisplay/colType/field.html
ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php

index 313f6a67ab3547ab1cefd6f509d9a956e03184b3..096ffa0551adba803c24fea6783ba4384e249b51 100644 (file)
@@ -186,7 +186,7 @@ class AutocompleteAction extends AbstractAction {
       $item = [
         'id' => $row['data'][$keyField],
         'label' => $row['columns'][0]['val'],
-        'icon' => $row['columns'][0]['icons'][0]['class'] ?? NULL,
+        'icon' => $row['columns'][0]['icons']['left'][0] ?? NULL,
         'description' => [],
       ];
       foreach (array_slice($row['columns'], 1) as $col) {
index e1763f4c0260ff40afb658034360e778102e059a..d0e943a9751b850b46759b34dea9990d49ccdb1a 100644 (file)
@@ -107,16 +107,18 @@ trait ArrayQueryActionTrait {
   /**
    * @param array $row
    * @param array $condition
+   * @param int $index
    * @return bool
    * @throws \Civi\API\Exception\NotImplementedException
    */
-  public static function filterCompare($row, $condition) {
-    if (!is_array($condition)) {
-      throw new NotImplementedException('Unexpected where syntax; expecting array.');
-    }
+  public static function filterCompare(array $row, array $condition, int $index = NULL): bool {
     $value = $row[$condition[0]] ?? NULL;
     $operator = $condition[1];
     $expected = $condition[2] ?? NULL;
+    // Comparison for aggregated values
+    if (isset($index) && is_array($value) && $operator !== 'IN' && $operator !== 'NOT IN') {
+      $value = $value[$index] ?? NULL;
+    }
     switch ($operator) {
       case '=':
       case '!=':
@@ -180,7 +182,7 @@ trait ArrayQueryActionTrait {
         }
         elseif (is_string($value) || is_numeric($value)) {
           // Lowercase check if string contains string
-          return (strpos(strtolower((string) $value), strtolower((string) $expected)) !== FALSE) == ($operator == 'CONTAINS');
+          return (str_contains(strtolower((string) $value), strtolower((string) $expected))) == ($operator == 'CONTAINS');
         }
         return ($value == $expected) == ($operator == 'CONTAINS');
 
index f42d77a15e1f3cfbc646713b74bd9d8f4b0e7c16..337f3c052fea02f599be0920e2cfdcd4ecf661ae 100644 (file)
@@ -234,6 +234,17 @@ trait SavedSearchInspectorTrait {
     return !in_array($idField, $apiParams['groupBy']);
   }
 
+  private function renameIfAggregate(string $fieldPath, bool $asSelect = FALSE): string {
+    $renamed = $fieldPath;
+    if ($this->canAggregate($fieldPath)) {
+      $renamed = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $fieldPath);
+      if ($asSelect) {
+        $renamed = "GROUP_CONCAT(UNIQUE $fieldPath) AS $renamed";
+      }
+    }
+    return $renamed;
+  }
+
   /**
    * @param string|array $fieldName
    *   If multiple field names are given they will be combined in an OR clause
index 78ae7ae59b8a7df586fd3ccafc1dd26eb45fc106..80bd24c4d332f85ea4b84fa2cd39616de995e472 100644 (file)
@@ -213,7 +213,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    * @return array{val: mixed, links: array, edit: array, label: string, title: string, image: array, cssClass: string}
    */
   private function formatColumn($column, $data) {
-    $column += ['rewrite' => NULL, 'label' => NULL];
+    $column += ['rewrite' => NULL, 'label' => NULL, 'key' => ''];
     $out = [];
     switch ($column['type']) {
       case 'field':
@@ -286,7 +286,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
       $out['cssClass'] = implode(' ', $cssClass);
     }
     if (!empty($column['icons'])) {
-      $out['icons'] = $this->getColumnIcons($column['icons'], $data);
+      $out['icons'] = $this->getColumnIcons($column, $data, $out);
     }
     return $out;
   }
@@ -339,44 +339,79 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    * If more than one per side is given, latter icons are treated as fallbacks
    * and only shown if prior ones are missing.
    *
-   * @param array{icon: string, field: string, if: array, side: string}[] $icons
+   * @param array $column
    * @param array $data
+   * @param array $out
    * @return array
    */
-  protected function getColumnIcons(array $icons, array $data) {
+  protected function getColumnIcons(array $column, array $data, array $out): array {
+    // Column is either outputting an array of links, or a plain value
+    // Links are always an array. Value could be, if field is multivalued or aggregated.
+    $value = $out['links'] ?? $out['val'] ?? NULL;
+    // Get 0-indexed keys of the values (pad so we have at least one)
+    $keys = array_pad(array_keys(array_values((array) $value)), 1, 0);
     $result = [];
-    // Reverse order so latter icons become fallbacks and earlier ones take priority
-    foreach (array_reverse($icons) as $icon) {
-      $iconClass = $icon['icon'] ?? NULL;
-      if (!$iconClass && !empty($icon['field']) && !empty($data[$icon['field']])) {
-        // Icon field may be multivalued e.g. contact_sub_type
-        $iconClass = \CRM_Utils_Array::first(array_filter((array) $data[$icon['field']]));
+    foreach (['left', 'right'] as $side) {
+      foreach ($keys as $index) {
+        $result[$side][$index] = $this->getColumnIcon($column['icons'], $side, $index, $data, is_array($value));
       }
-      if ($iconClass) {
-        $condition = $this->getRuleCondition($icon['if'] ?? []);
-        if (!is_null($condition[0]) && !(self::filterCompare($data, $condition))) {
+      // Drop if empty
+      if (!array_filter($result[$side])) {
+        unset($result[$side]);
+      }
+    }
+    return $result;
+  }
+
+  private function getColumnIcon(array $icons, string $side, int $index, array $data, bool $isMulti): ?string {
+    // Latter icons are fallbacks, earlier ones take priority
+    foreach ($icons as $icon) {
+      $iconClass = NULL;
+      $icon += ['side' => 'left', 'icon' => NULL];
+      if ($icon['side'] !== $side) {
+        continue;
+      }
+      $iconField = !empty($icon['field']) ? $this->renameIfAggregate($icon['field']) : NULL;
+      if (!empty($iconField) && !empty($data[$iconField])) {
+        // Icon field may be multivalued e.g. contact_sub_type, or it may be aggregated
+        // If both base field and icon field are multivalued, use corresponding index
+        if ($isMulti && is_array($data[$iconField])) {
+          $iconClass = $data[$iconField][$index] ?? NULL;
+        }
+        // Otherwise get a single value
+        else {
+          $iconClass = \CRM_Utils_Array::first(array_filter((array) $data[$iconField]));
+        }
+      }
+      $iconClass = $iconClass ?? $icon['icon'];
+      if ($iconClass && !empty($icon['if'])) {
+        $condition = $this->getRuleCondition($icon['if'], $isMulti);
+        if (!is_null($condition[0]) && !(self::filterCompare($data, $condition, $isMulti ? $index : NULL))) {
           continue;
         }
-        $side = $icon['side'] ?? 'left';
-        $result[$side] = ['class' => $iconClass, 'side' => $side];
+      }
+      if ($iconClass) {
+        return $iconClass;
       }
     }
-    return array_values($result);
+    return NULL;
   }
 
   /**
    * Returns the condition of a cssRules
    *
    * @param array $clause
+   * @param bool $isMulti
    * @return array
    */
-  protected function getRuleCondition($clause) {
+  protected function getRuleCondition($clause, bool $isMulti = FALSE): array {
     $fieldKey = $clause[0] ?? NULL;
-    // For fields used in group by, add aggregation and change operator to CONTAINS
-    // NOTE: This doesn't support any other operators for aggregated fields.
+    // For fields used in group by, add aggregation and change comparison operator to CONTAINS
     if ($fieldKey && $this->canAggregate($fieldKey)) {
-      $clause[1] = 'CONTAINS';
-      $fieldKey = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $clause[0]);
+      if (!$isMulti && !empty($clause[1]) && !in_array($clause[1], ['IS EMPTY', 'IS NOT EMPTY'], TRUE)) {
+        $clause[1] = 'CONTAINS';
+      }
+      $fieldKey = $this->renameIfAggregate($fieldKey);
     }
     return [$fieldKey, $clause[1] ?? 'IS NOT EMPTY', $clause[2] ?? NULL];
   }
@@ -393,7 +428,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
       $fieldKey = $clause[1] ?? NULL;
       if ($fieldKey) {
         // For fields used in group by, add aggregation
-        $select[] = $this->canAggregate($fieldKey) ? "GROUP_CONCAT($fieldKey) AS GROUP_CONCAT_" . str_replace(['.', ':'], '_', $fieldKey) : $fieldKey;
+        $select[] = $this->renameIfAggregate($fieldKey, TRUE);
       }
     }
     return $select;
@@ -409,12 +444,12 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     $select = [];
     foreach ($icons as $icon) {
       if (!empty($icon['field'])) {
-        $select[] = $icon['field'];
+        $select[] = $this->renameIfAggregate($icon['field'], TRUE);
       }
       $fieldKey = $icon['if'][0] ?? NULL;
       if ($fieldKey) {
         // For fields used in group by, add aggregation
-        $select[] = $this->canAggregate($fieldKey) ? "GROUP_CONCAT($fieldKey) AS GROUP_CONCAT_" . str_replace(['.', ':'], '_', $fieldKey) : $fieldKey;
+        $select[] = $this->renameIfAggregate($fieldKey, TRUE);
       }
     }
     return $select;
@@ -462,7 +497,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
   }
 
   private function formatLink(array $link, array $data, string $text = NULL, $index = 0): ?array {
-    $link = $this->getLinkInfo($link, $data, $index);
+    $link = $this->getLinkInfo($link);
     if (!$this->checkLinkAccess($link, $data, $index)) {
       return NULL;
     }
@@ -471,10 +506,10 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     $path = $this->replaceTokens($link['path'], $data, 'url', $index);
     if ($path) {
       $link['url'] = $this->getUrl($path);
-      $keys = ['url', 'text', 'title', 'target', 'style', 'icon'];
+      $keys = ['url', 'text', 'title', 'target', 'style'];
     }
     else {
-      $keys = ['task', 'text', 'title', 'style', 'icon'];
+      $keys = ['task', 'text', 'title', 'style'];
     }
     $link = array_intersect_key($link, array_flip($keys));
     return array_filter($link);
@@ -591,8 +626,8 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
   }
 
   /**
-   * @param array{path: string, entity: string, action: string, task: string, join: string, target: string, style: string, icon: string, title: string, text: string} $link
-   * @return array{path: string, entity: string, action: string, task: string, join: string, target: string, style: string, icon: string, title: string, text: string, prefix: string}
+   * @param array{path: string, entity: string, action: string, task: string, join: string, target: string, style: string, title: string, text: string} $link
+   * @return array{path: string, entity: string, action: string, task: string, join: string, target: string, style: string, title: string, text: string, prefix: string}
    */
   private function getLinkInfo(array $link): array {
     $link += [
@@ -1255,7 +1290,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     if (!$this->getSelectExpression($expr)) {
       // Tokens for aggregated columns start with 'GROUP_CONCAT_'
       if (strpos($expr, 'GROUP_CONCAT_') === 0) {
-        $expr = 'GROUP_CONCAT(' . $this->getJoinFromAlias(explode('_', $expr, 3)[2]) . ') AS ' . $expr;
+        $expr = 'GROUP_CONCAT(UNIQUE ' . $this->getJoinFromAlias(explode('_', $expr, 3)[2]) . ') AS ' . $expr;
       }
       $this->_apiParams['select'][] = $expr;
       // Force-reset cache so it gets rebuilt with the new select param
index ceb1664696b988be39ec0b0961bd0b2446b6995e..c4b66bbd300644643c3a9b1bfdae4c92f86d2d0c 100644 (file)
@@ -1,14 +1,23 @@
 <crm-search-display-editable row="row" col="colData" cancel="$ctrl.editing = null;" ng-if="colData.edit && $ctrl.isEditing(rowIndex, colIndex)"></crm-search-display-editable>
 <span ng-if="!colData.links && !$ctrl.isEditing(rowIndex, colIndex)" ng-class="{'crm-editable-enabled': colData.edit && !$ctrl.editing, 'crm-editable-disabled': colData.edit && $ctrl.editing}" ng-click="colData.edit && !$ctrl.editing && ($ctrl.editing = [rowIndex, colIndex])">
-  <i ng-repeat="icon in colData.icons" ng-if="icon.side === 'left'" class="crm-i {{:: icon['class'] }}"></i>
-  {{:: $ctrl.formatFieldValue(colData) }}
-  <i ng-repeat="icon in colData.icons" ng-if="icon.side === 'right'" class="crm-i {{:: icon['class'] }}"></i>
+  <span ng-if="!$ctrl.isArray(colData.val)">
+    <i ng-if="colData.icons.left[0]" class="crm-i {{:: colData.icons.left[0] }}"></i>
+    {{:: colData.val }}
+    <i ng-if="colData.icons.right[$index]" class="crm-i {{:: colData.icons.right[$index] }}"></i>
+  </span>
+  <span ng-if="$ctrl.isArray(colData.val)">
+    <span ng-repeat="val in colData.val track by $index">
+      <i ng-if="colData.icons.left[$index]" class="crm-i {{:: colData.icons.left[$index] }}"></i>
+        {{:: val }}<i ng-if="colData.icons.right[$index]" class="crm-i {{:: colData.icons.right[$index] }}"> </i><span ng-if="!$last">,
+      </span>
+    </span>
+  </span>
 </span>
 <span ng-if="colData.links && !$ctrl.isEditing(rowIndex, colIndex)">
   <span ng-repeat="link in colData.links">
     <a target="{{:: link.target }}" ng-href="{{:: link.url }}" title="{{:: link.title }}" ng-click="$ctrl.onClickLink(link, row.key, $event)">
-      <i ng-repeat="icon in colData.icons" ng-if="icon.side === 'left'" class="crm-i {{:: icon['class'] }}"></i>
-      {{:: link.text }}<i ng-repeat="icon in colData.icons" ng-if="icon.side === 'right'" class="crm-i {{:: icon['class'] }}"></i></a><span ng-if="!$last">,
+      <i ng-if="colData.icons.left[$index]" class="crm-i {{:: colData.icons.left[$index] }}"></i>
+      {{:: link.text }}<i ng-if="colData.icons.right[$index]" class="crm-i {{:: colData.icons.right[$index] }}"> </i></a><span ng-if="!$last">,
     </span>
   </span>
 </span>
index e7d067bf7abf5b40abde8ce0d0abce1e9ad8f506..dca944afacb727de3e99cc5c9206badd55128381 100644 (file)
@@ -30,6 +30,7 @@
         _.each(ctrl.onInitialize, function(callback) {
           callback.call(ctrl, $scope, $element);
         });
+        this.isArray = angular.isArray;
 
         // _.debounce used here to trigger the initial search immediately but prevent subsequent launches within 300ms
         this.getResultsPronto = _.debounce(ctrl.runSearch, 300, {leading: true, trailing: false});
index 82f33532cce5517a6d5932e0c71f34e1320981a4..23537d69875d646f6425d4b6a7558fcb6ca7c2a1 100644 (file)
@@ -1232,9 +1232,9 @@ class SearchRunTest extends Api4TestBase implements TransactionalInterface {
       ->execute();
 
     // Icon based on activity type
-    $this->assertEquals([['class' => 'fa-slideshare', 'side' => 'left']], $result[0]['columns'][0]['icons']);
+    $this->assertEquals(['left' => ['fa-slideshare']], $result[0]['columns'][0]['icons']);
     // Activity type icon + conditional icon based on status
-    $this->assertEquals([['class' => 'fa-star', 'side' => 'right'], ['class' => 'fa-phone', 'side' => 'left']], $result[1]['columns'][0]['icons']);
+    $this->assertEquals(['right' => ['fa-star'], 'left' => ['fa-phone']], $result[1]['columns'][0]['icons']);
   }
 
   /**
@@ -1845,11 +1845,11 @@ class SearchRunTest extends Api4TestBase implements TransactionalInterface {
 
     // Contacts will be returned in order by sort_name
     $this->assertEquals('Both', $result[0]['columns'][0]['val']);
-    $this->assertEquals('fa-star', $result[0]['columns'][0]['icons'][0]['class']);
+    $this->assertEquals('fa-star', $result[0]['columns'][0]['icons']['left'][0]);
     $this->assertEquals('No icon', $result[1]['columns'][0]['val']);
-    $this->assertEquals('fa-user', $result[1]['columns'][0]['icons'][0]['class']);
+    $this->assertEquals('fa-user', $result[1]['columns'][0]['icons']['left'][0]);
     $this->assertEquals('Starry', $result[2]['columns'][0]['val']);
-    $this->assertEquals('fa-star', $result[2]['columns'][0]['icons'][0]['class']);
+    $this->assertEquals('fa-star', $result[2]['columns'][0]['icons']['left'][0]);
   }
 
   public function testKeyIsReturned(): void {
@@ -1895,6 +1895,125 @@ class SearchRunTest extends Api4TestBase implements TransactionalInterface {
     $this->assertEquals($id, $result[0]['key']);
   }
 
+  public function testRunWithEntityFile(): void {
+    $cid = $this->createTestRecord('Contact')['id'];
+    $notes = $this->saveTestRecords('Note', [
+      'records' => 2,
+      'defaults' => ['entity_table' => 'civicrm_contact', 'entity_id' => $cid],
+    ])->column('id');
+
+    foreach (['text/plain' => 'txt', 'image/png' => 'png', 'image/foo' => 'foo'] as $mimeType => $ext) {
+      // FIXME: Use api4 when available
+      civicrm_api3('Attachment', 'create', [
+        'entity_table' => 'civicrm_note',
+        'entity_id' => $notes[0],
+        'name' => 'test_file.' . $ext,
+        'mime_type' => $mimeType,
+        'content' => 'hello',
+      ])['id'];
+    }
+
+    $params = [
+      'checkPermissions' => FALSE,
+      'debug' => TRUE,
+      'return' => 'page:1',
+      'savedSearch' => [
+        'api_entity' => 'Note',
+        'api_params' => [
+          'version' => 4,
+          'select' => [
+            'id',
+            'subject',
+            'note',
+            'note_date',
+            'modified_date',
+            'contact_id.sort_name',
+            'GROUP_CONCAT(UNIQUE Note_EntityFile_File_01.file_name) AS GROUP_CONCAT_Note_EntityFile_File_01_file_name',
+            'GROUP_CONCAT(UNIQUE Note_EntityFile_File_01.url) AS GROUP_CONCAT_Note_EntityFile_File_01_url',
+            'GROUP_CONCAT(UNIQUE Note_EntityFile_File_01.icon) AS GROUP_CONCAT_Note_EntityFile_File_01_icon',
+          ],
+          'where' => [
+            ['entity_id', 'IN', [$cid]],
+            ['entity_table:name', '=', 'Contact'],
+          ],
+          'groupBy' => [
+            'id',
+          ],
+          'join' => [
+            [
+              'File AS Note_EntityFile_File_01',
+              'LEFT',
+              'EntityFile',
+              [
+                'id',
+                '=',
+                'Note_EntityFile_File_01.entity_id',
+              ],
+              [
+                'Note_EntityFile_File_01.entity_table',
+                '=',
+                "'civicrm_note'",
+              ],
+            ],
+          ],
+          'having' => [],
+        ],
+      ],
+      'display' => [
+        'type' => 'table',
+        'label' => '',
+        'settings' => [
+          'limit' => 20,
+          'pager' => TRUE,
+          'actions' => TRUE,
+          'columns' => [
+            [
+              'type' => 'field',
+              'key' => 'id',
+              'dataType' => 'Integer',
+              'label' => 'ID',
+              'sortable' => TRUE,
+            ],
+            [
+              'type' => 'field',
+              'key' => 'GROUP_CONCAT_Note_EntityFile_File_01_file_name',
+              'dataType' => 'String',
+              'label' => ts('Attachments'),
+              'sortable' => TRUE,
+              'link' => [
+                'path' => '[GROUP_CONCAT_Note_EntityFile_File_01_url]',
+                'entity' => '',
+                'action' => '',
+                'join' => '',
+                'target' => '',
+              ],
+              'icons' => [
+                [
+                  'field' => 'Note_EntityFile_File_01.icon',
+                  'side' => 'left',
+                ],
+                [
+                  'icon' => 'fa-search',
+                  'side' => 'right',
+                  'if' => ['Note_EntityFile_File_01.is_image'],
+                ],
+              ],
+            ],
+          ],
+          'sort' => [
+            ['id', 'ASC'],
+          ],
+        ],
+      ],
+      'afform' => NULL,
+    ];
+
+    $result = civicrm_api4('SearchDisplay', 'run', $params);
+    $this->assertCount(2, $result);
+    $this->assertEquals(['fa-file-text-o', 'fa-file-image-o', 'fa-file-image-o'], $result[0]['columns'][1]['icons']['left']);
+    $this->assertEquals([NULL, 'fa-search', NULL], $result[0]['columns'][1]['icons']['right']);
+  }
+
   /**
    * Returns all contacts in VIEW mode but only specified contact for EDIT.
    *