SearchKit - Add icon support
authorColeman Watts <coleman@civicrm.org>
Fri, 25 Mar 2022 15:46:13 +0000 (11:46 -0400)
committerColeman Watts <coleman@civicrm.org>
Fri, 1 Apr 2022 17:07:14 +0000 (13:07 -0400)
This allows each column to have one or more icons, based on a field value or a conditional rule.

ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html
ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.component.js [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminIcons.html [new file with mode: 0644]
ext/search_kit/ang/crmSearchDisplay/colType/field.html
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php
js/jquery/jquery.crmIconPicker.js

index 8ae0fae97422a5aab2f38a0a821f45624b143c64..a47c799cef8f72b04e320efc40351fda6dce0e36 100644 (file)
@@ -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);
index d3070bc1a06f3a12b73c292cd8b97ce2277e45cf..75447624d792ac04099580621f0b53d4012614e8 100644 (file)
@@ -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'],
         ]);
index f4f57db3ac5f746eaf15081c43a358d041a32e35..bf6311ebbbc1c3c696e8479777efb4ada33343ef 100644 (file)
           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
             };
index 99fb1cb38752dbd003a3f5671fcc0323c9ed3908..e4337785976e089da70f74fd6edf847aebcc9fda 100644 (file)
@@ -51,4 +51,5 @@
     {{:: ts('In-Place Edit') }}
   </label>
 </div>
+<search-admin-icons item="col"></search-admin-icons>
 <search-admin-css-rules label="{{:: ts('Style') }}" item="col" default="col.key"></search-admin-css-rules>
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 (file)
index 0000000..d8a1e5e
--- /dev/null
@@ -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 (file)
index 0000000..98d7676
--- /dev/null
@@ -0,0 +1,51 @@
+<div class="form-inline" ng-repeat="icon in $ctrl.item.icons">
+  <label>{{:: ts('Icon') }}</label>
+  <div class="input-group">
+    <div class="input-group-btn">
+      <button type="button" ng-click="$ctrl.menuOpen = true" class="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        <span>{{ icon.field ? $ctrl.iconFieldMap[icon.field].text : ts('Choose...') }}</span> <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu" ng-if="$ctrl.menuOpen">
+        <li ng-repeat="field in $ctrl.iconFields">
+          <a href ng-click="$ctrl.setIconField(field.id, $parent.$index)">{{:: field.text }}</a>
+        </li>
+        <li class="divider" ng-show="$ctrl.iconFields.length" role="separator"></li>
+        <li>
+          <a href ng-click="$ctrl.pickIcon($index)">{{:: ts('Choose Icon...') }}</a>
+        </li>
+      </ul>
+    </div>
+  </div>
+  <div class="form-group crm-search-admin-field-icon" ng-if="icon.icon">
+    <input required ng-model="icon.icon" class="form-control crm-icon-picker">
+  </div>
+  <select class="form-control" ng-model="icon.side" title="{{:: ts('Show icon on left or right side of the field') }}">
+    <option value="left">{{:: ts('Align left') }}</option>
+    <option value="right">{{:: ts('Align right') }}</option>
+  </select>
+  <div class="form-group" ng-if="icon.if">
+    <label>{{:: ts('If') }}</label>
+    <input class="form-control collapsible-optgroups" ng-model="icon.if[0]" crm-ui-select="::{data: $ctrl.fields, allowClear: true, placeholder: ts('Always')}" ng-change="$ctrl.onSelectField(icon.if)" />
+    <crm-search-condition ng-if="icon.if[0]" clause="icon.if" field="$ctrl.getField(icon.if[0])" offset="1" option-key="'name'" format="$ctrl.format" class="form-group"></crm-search-condition>
+  </div>
+  <button type="button" class="btn btn-xs btn-danger-outline" ng-click="$ctrl.item.icons.splice($index, 1);" title="{{:: ts('Remove icon') }}">
+    <i class="crm-i fa-times"></i>
+  </button>
+</div>
+<div class="form-inline" title="{{:: ts('Add icon(s) to this column') }}">
+  <label>{{:: ts('Icon') }}</label>
+  <div class="btn-group">
+    <button type="button" ng-click="$ctrl.menuOpen = true" class="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <span>{{ $ctrl.item.icons && $ctrl.item.icons.length ? ts('Add') : ts('None') }}</span> <span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu" ng-if="$ctrl.menuOpen">
+      <li ng-repeat="field in $ctrl.iconFields">
+        <a href ng-click="$ctrl.addIcon(field.id)">{{:: field.text }}</a>
+      </li>
+      <li class="divider" ng-show="$ctrl.iconFields.length" role="separator"></li>
+      <li>
+        <a href ng-click="$ctrl.addIcon()">{{:: ts('Choose Icon...') }}</a>
+      </li>
+    </ul>
+  </div>
+</div>
index cee95047ffe407723f49f6b5f9a21c7c81374196..77ca4faf2017351aadf9df9f6ed85cef9e5a86cc 100644 (file)
@@ -1,11 +1,14 @@
 <crm-search-display-editable row="row" col="colData" do-save="$ctrl.runSearch([apiCall], {}, row)" cancel="$ctrl.editing = null;" ng-if="colData.edit && $ctrl.editing && $ctrl.editing[0] === rowIndex && $ctrl.editing[1] === colIndex"></crm-search-display-editable>
 <span ng-if="::!colData.links" ng-class="{'crm-editable-enabled': 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>
 <span ng-if="::colData.links">
   <span ng-repeat="link in colData.links">
     <a target="{{:: link.target }}" href="{{:: link.url }}">
-      {{:: link.text }}</a><span ng-if="!$last">,
+      <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">,
     </span>
   </span>
 </span>
index 02fda6f709af8fad017fd2e68ffbe5fb8badb3a5..0f7cc73637c972ff51c625029434bcb11e6aa0fb 100644 (file)
@@ -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__);
index 2cc4bd536cbd597dc504c9b5f5dbbc44a03fde7b..e1526e290703573f185a9836197e940615d1a1e5 100644 (file)
@@ -27,8 +27,9 @@
       }
 
       var $input = $(this),
+        classes = ($input.attr('class') || '').replace('crm-icon-picker', ''),
         $button = $('<a class="crm-icon-picker-button" href="#" />').button().removeClass('ui-corner-all').attr('title', $input.attr('title')),
-        $style = $('<select class="crm-form-select"></select>'),
+        $style = $('<select class="crm-form-select"></select>').addClass(classes),
         options = [
           {key: 'fa-rotate-90', value: ts('Rotate right')},
           {key: 'fa-rotate-270', value: ts('Rotate left')},
@@ -90,7 +91,8 @@
             '<div class="icon-ctrls crm-clearfix">' +
             '<input class="crm-form-text" name="search" placeholder="&#xf002"/>' +
             '<select class="crm-form-select"></select>' +
-            '<button type="button" class="cancel" title=""><i class="crm-i fa-ban" aria-hidden="true"></i> ' + ts('No icon') + '</button>' +
+            // Add "No Icon" button unless field is required
+            ($input.is('[required]') ? '' : '<button type="button" class="cancel" title=""><i class="crm-i fa-ban" aria-hidden="true"></i> ' + ts('No icon') + '</button>') +
             '</div>' +
             '<div class="icons"></div>'
           );