From: colemanw Date: Mon, 11 Sep 2023 19:04:56 +0000 (-0400) Subject: SearchKit - Support aggregated icon fields X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=93d1d455e7ef5a8768f7e287a50016992b6bd69c;p=civicrm-core.git SearchKit - Support aggregated icon fields --- diff --git a/Civi/Api4/Generic/AutocompleteAction.php b/Civi/Api4/Generic/AutocompleteAction.php index 313f6a67ab..096ffa0551 100644 --- a/Civi/Api4/Generic/AutocompleteAction.php +++ b/Civi/Api4/Generic/AutocompleteAction.php @@ -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) { diff --git a/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php index e1763f4c02..d0e943a975 100644 --- a/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php +++ b/Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php @@ -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'); diff --git a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php index f42d77a15e..337f3c052f 100644 --- a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php +++ b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php @@ -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 diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php index 78ae7ae59b..80bd24c4d3 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php @@ -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 diff --git a/ext/search_kit/ang/crmSearchDisplay/colType/field.html b/ext/search_kit/ang/crmSearchDisplay/colType/field.html index ceb1664696..c4b66bbd30 100644 --- a/ext/search_kit/ang/crmSearchDisplay/colType/field.html +++ b/ext/search_kit/ang/crmSearchDisplay/colType/field.html @@ -1,14 +1,23 @@ - - {{:: $ctrl.formatFieldValue(colData) }} - + + + {{:: colData.val }} + + + + + + {{:: val }} , + + + - - {{:: link.text }}, + + {{:: link.text }} , diff --git a/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js b/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js index e7d067bf7a..dca944afac 100644 --- a/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js +++ b/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js @@ -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}); diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php index 82f33532cc..23537d6987 100644 --- a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php @@ -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. *