From dd2e872d998aaaa9c09a8aa0c2c44fe4c248e353 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Fri, 25 Mar 2022 11:46:13 -0400 Subject: [PATCH] SearchKit - Add icon support This allows each column to have one or more icons, based on a field value or a conditional rule. --- .../SearchDisplay/AbstractRunAction.php | 70 ++++++++++-- ext/search_kit/Civi/Search/Admin.php | 2 +- .../crmSearchAdmin.component.js | 2 +- .../displays/colType/field.html | 1 + .../common/searchAdminIcons.component.js | 103 ++++++++++++++++++ .../displays/common/searchAdminIcons.html | 51 +++++++++ .../ang/crmSearchDisplay/colType/field.html | 5 +- .../api/v4/SearchDisplay/SearchRunTest.php | 88 ++++++++++++++- js/jquery/jquery.crmIconPicker.js | 6 +- 9 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.component.js create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.html diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php index 8ae0fae974..a47c799cef 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php @@ -261,6 +261,9 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { if ($cssClass) { $out['cssClass'] = implode(' ', $cssClass); } + if (!empty($column['icons'])) { + $out['icons'] = $this->getColumnIcons($column['icons'], $data); + } return $out; } @@ -295,7 +298,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { foreach ($styleRules as $clause) { $cssClass = $clause[0] ?? ''; if ($cssClass) { - $condition = $this->getCssRuleCondition($clause); + $condition = $this->getRuleCondition(array_slice($clause, 1)); if (is_null($condition[0]) || (ArrayQueryActionTrait::filterCompare($data, $condition))) { $classes[] = $cssClass; } @@ -304,21 +307,46 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { return $classes; } + /** + * Evaluates conditional style rules + * + * @param array{icon: string, field: string, if: array, side: string}[] $icons + * @param array $data + * @return array + */ + protected function getColumnIcons(array $icons, array $data) { + $result = []; + foreach ($icons as $icon) { + $iconClass = $icon['icon'] ?? NULL; + if (!$iconClass && !empty($icon['field'])) { + $iconClass = $data[$icon['field']] ?? NULL; + } + if ($iconClass) { + $condition = $this->getRuleCondition($icon['if'] ?? []); + if (!is_null($condition[0]) && !(ArrayQueryActionTrait::filterCompare($data, $condition))) { + continue; + } + $result[] = ['class' => $iconClass, 'side' => $icon['side'] ?? 'left']; + } + } + return $result; + } + /** * Returns the condition of a cssRules * * @param array $clause * @return array */ - protected function getCssRuleCondition($clause) { - $fieldKey = $clause[1] ?? NULL; + protected function getRuleCondition($clause) { + $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. if ($fieldKey && $this->canAggregate($fieldKey)) { - $clause[2] = 'CONTAINS'; - $fieldKey = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $clause[1]); + $clause[1] = 'CONTAINS'; + $fieldKey = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $clause[0]); } - return [$fieldKey, $clause[2] ?? 'IS NOT EMPTY', $clause[3] ?? NULL]; + return [$fieldKey, $clause[1] ?? 'IS NOT EMPTY', $clause[2] ?? NULL]; } /** @@ -339,6 +367,27 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { return $select; } + /** + * Return fields needed for calculating a column's icons + * + * @param array $icons + * @return array + */ + protected function getIconsSelect($icons) { + $select = []; + foreach ($icons as $icon) { + if (!empty($icon['field'])) { + $select[] = $icon['field']; + } + $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; + } + } + return $select; + } + /** * Format a field value as links * @param $column @@ -840,10 +889,11 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { $additions[] = $editable['id_path']; } } - // Add style conditions for the column - foreach ($this->getCssRulesSelect($column['cssRules'] ?? []) as $addition) { - $additions[] = $addition; - } + // Add style & icon conditions for the column + $additions = array_merge($additions, + $this->getCssRulesSelect($column['cssRules'] ?? []), + $this->getIconsSelect($column['icons'] ?? []) + ); } // Add fields referenced via token $tokens = $this->getTokens($possibleTokens); diff --git a/ext/search_kit/Civi/Search/Admin.php b/ext/search_kit/Civi/Search/Admin.php index d3070bc1a0..75447624d7 100644 --- a/ext/search_kit/Civi/Search/Admin.php +++ b/ext/search_kit/Civi/Search/Admin.php @@ -135,7 +135,7 @@ class Admin { $entity['links'] = array_values($links); } $getFields = civicrm_api4($entity['name'], 'getFields', [ - 'select' => ['name', 'title', 'label', 'description', 'type', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly', 'operators', 'nullable'], + 'select' => ['name', 'title', 'label', 'description', 'type', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly', 'operators', 'suffixes', 'nullable'], 'where' => [['name', 'NOT IN', ['api_key', 'hash']]], 'orderBy' => ['label'], ]); diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js index f4f57db3ac..bf6311ebbb 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js @@ -501,7 +501,7 @@ prefix = typeof prefix === 'undefined' ? '' : prefix; _.each(fields, function(field) { var item = { - id: prefix + field.name + (field.options ? suffix : ''), + id: prefix + field.name + (field.suffixes && _.includes(field.suffixes, suffix.replace(':', '')) ? suffix : ''), text: field.label, description: field.description }; diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html b/ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html index 99fb1cb387..e433778597 100644 --- a/ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html +++ b/ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html @@ -51,4 +51,5 @@ {{:: ts('In-Place Edit') }} + diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.component.js b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.component.js new file mode 100644 index 0000000000..d8a1e5eb69 --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.component.js @@ -0,0 +1,103 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('crmSearchAdmin').component('searchAdminIcons', { + bindings: { + item: '<' + }, + require: { + crmSearchAdmin: '^crmSearchAdmin' + }, + templateUrl: '~/crmSearchAdmin/displays/common/searchAdminIcons.html', + controller: function($scope, $element, $timeout, searchMeta) { + var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'), + ctrl = this; + + this.getField = searchMeta.getField; + + this.fields = function() { + var allFields = ctrl.crmSearchAdmin.getAllFields(':name', ['Field', 'Custom', 'Extra', 'Pseudo']); + return { + results: ctrl.crmSearchAdmin.getSelectFields().concat(allFields) + }; + }; + + function initWidgets() { + CRM.loadScript(CRM.config.resourceBase + 'js/jquery/jquery.crmIconPicker.js').then(function() { + $('.crm-search-admin-field-icon > input.crm-icon-picker[ng-model]', $element).crmIconPicker(); + }); + } + + this.$onInit = function() { + $element.on('hidden.bs.dropdown', function() { + $timeout(function() { + ctrl.menuOpen = false; + }); + }); + var allFields = ctrl.crmSearchAdmin.getAllFields(':icon'), + entityLabel = searchMeta.getEntity(ctrl.crmSearchAdmin.savedSearch.api_entity).title; + // Gather all fields with an icon + function getIconFields(iconFields, group, i) { + if (group.children) { + // Use singular title for main entity + entityLabel = i ? group.text : entityLabel; + _.transform(group.children, function(iconFields, field) { + if (field.id && _.endsWith(field.id, 'icon')) { + field.text = entityLabel + ' - ' + field.text; + iconFields.push(field); + } + }, iconFields); + } + } + ctrl.iconFields = _.transform(allFields, getIconFields, []); + ctrl.iconFieldMap = _.indexBy(ctrl.iconFields, 'id'); + $timeout(initWidgets); + }; + + this.onSelectField = function(clause) { + if (clause[0]) { + clause[1] = '='; + clause.length = 2; + } else { + clause.length = 0; + } + }; + + this.addIcon = function(field) { + ctrl.item.icons = ctrl.item.icons || []; + if (field) { + ctrl.item.icons.push({field: field, side: 'left'}); + } + else { + searchMeta.pickIcon().then(function(icon) { + if (icon) { + ctrl.item.icons.push({icon: icon, side: 'left', if: []}); + $timeout(initWidgets); + } + }); + } + }; + + this.pickIcon = function(index) { + var item = ctrl.item.icons[index]; + searchMeta.pickIcon().then(function(icon) { + if (icon) { + item.icon = icon; + delete item.field; + item.if = item.if || []; + $timeout(initWidgets); + } + }); + }; + + this.setIconField = function(field, index) { + var item = ctrl.item.icons[index]; + delete item.icon; + delete item.if; + item.field = field; + }; + + } + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.html b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.html new file mode 100644 index 0000000000..98d7676884 --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.html @@ -0,0 +1,51 @@ +
+ +
+
+ + +
+
+
+ +
+ +
+ + + +
+ +
+
+ +
+ + +
+
diff --git a/ext/search_kit/ang/crmSearchDisplay/colType/field.html b/ext/search_kit/ang/crmSearchDisplay/colType/field.html index cee95047ff..77ca4faf20 100644 --- a/ext/search_kit/ang/crmSearchDisplay/colType/field.html +++ b/ext/search_kit/ang/crmSearchDisplay/colType/field.html @@ -1,11 +1,14 @@ + {{:: $ctrl.formatFieldValue(colData) }} + - {{:: link.text }}, + + {{:: link.text }}, 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 02fda6f709..0f7cc73637 100644 --- a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php @@ -841,7 +841,93 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter } /** - * Test conditional styles + * Test conditional and field-based icons + */ + public function testIcons() { + $subject = uniqid(__FUNCTION__); + + $source = Contact::create(FALSE)->execute()->first(); + + $activities = [ + ['activity_type_id:name' => 'Meeting', 'subject' => $subject, 'status_id:name' => 'Scheduled'], + ['activity_type_id:name' => 'Phone Call', 'subject' => $subject, 'status_id:name' => 'Completed'], + ]; + Activity::save(FALSE) + ->addDefault('source_contact_id', $source['id']) + ->setRecords($activities)->execute(); + + $search = [ + 'api_entity' => 'Activity', + 'api_params' => [ + 'version' => 4, + 'select' => [ + 'id', + ], + 'orderBy' => [], + 'where' => [], + 'groupBy' => [], + 'join' => [], + 'having' => [], + ], + ]; + + $display = [ + 'type' => 'table', + 'settings' => [ + 'actions' => TRUE, + 'limit' => 50, + 'classes' => [ + 'table', + 'table-striped', + ], + 'pager' => [ + 'show_count' => TRUE, + 'expose_limit' => TRUE, + ], + 'sort' => [], + 'columns' => [ + [ + 'type' => 'field', + 'key' => 'id', + 'dataType' => 'Integer', + 'label' => 'Activity ID', + 'sortable' => TRUE, + 'icons' => [ + [ + 'field' => 'activity_type_id:icon', + 'side' => 'left', + ], + [ + 'icon' => 'fa-star', + 'side' => 'right', + 'if' => [ + 'status_id:name', + '=', + 'Completed', + ], + ], + ], + ], + ], + ], + 'acl_bypass' => FALSE, + ]; + + $result = SearchDisplay::Run(FALSE) + ->setSavedSearch($search) + ->setDisplay($display) + ->setReturn('page:1') + ->setSort([['id', 'ASC']]) + ->execute(); + + // Icon based on activity type + $this->assertEquals([['class' => 'fa-slideshare', 'side' => 'left']], $result[0]['columns'][0]['icons']); + // Activity type icon + conditional icon based on status + $this->assertEquals([['class' => 'fa-phone', 'side' => 'left'], ['class' => 'fa-star', 'side' => 'right']], $result[1]['columns'][0]['icons']); + } + + /** + * Test value substitutions with empty fields & placeholders */ public function testPlaceholderFields() { $lastName = uniqid(__FUNCTION__); diff --git a/js/jquery/jquery.crmIconPicker.js b/js/jquery/jquery.crmIconPicker.js index 2cc4bd536c..e1526e2907 100644 --- a/js/jquery/jquery.crmIconPicker.js +++ b/js/jquery/jquery.crmIconPicker.js @@ -27,8 +27,9 @@ } var $input = $(this), + classes = ($input.attr('class') || '').replace('crm-icon-picker', ''), $button = $('').button().removeClass('ui-corner-all').attr('title', $input.attr('title')), - $style = $(''), + $style = $('').addClass(classes), options = [ {key: 'fa-rotate-90', value: ts('Rotate right')}, {key: 'fa-rotate-270', value: ts('Rotate left')}, @@ -90,7 +91,8 @@ '
' + '' + '' + - '' + + // Add "No Icon" button unless field is required + ($input.is('[required]') ? '' : '') + '
' + '
' ); -- 2.25.1