SearchKit - Conditional style rules per-row & per-field
authorColeman Watts <coleman@civicrm.org>
Sun, 31 Oct 2021 16:29:00 +0000 (12:29 -0400)
committerColeman Watts <coleman@civicrm.org>
Tue, 2 Nov 2021 12:49:36 +0000 (08:49 -0400)
16 files changed:
Civi/Api4/Generic/Traits/ArrayQueryActionTrait.php
css/civicrm.css
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/ang/crmSearchAdmin.module.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminLinkSelect.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminLinkSelect.html
ext/search_kit/ang/crmSearchAdmin/displays/colType/field.html
ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminCssRules.component.js [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminCssRules.html [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html
ext/search_kit/ang/crmSearchDisplayGrid/crmSearchDisplayGridItems.html
ext/search_kit/ang/crmSearchDisplayList/crmSearchDisplayListItems.html
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTableBody.html
ext/search_kit/css/crmSearchAdmin.css
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php

index 467fd6054cfad3d0827215ae55299d588044e354..70a53af8b382cb39fa33c9b83a4d28ddc54075a3 100644 (file)
@@ -87,7 +87,7 @@ trait ArrayQueryActionTrait {
         return $result;
 
       default:
-        return $this->filterCompare($row, $filters);
+        return self::filterCompare($row, $filters);
     }
   }
 
@@ -97,7 +97,7 @@ trait ArrayQueryActionTrait {
    * @return bool
    * @throws \Civi\API\Exception\NotImplementedException
    */
-  private function filterCompare($row, $condition) {
+  public static function filterCompare($row, $condition) {
     if (!is_array($condition)) {
       throw new NotImplementedException('Unexpected where syntax; expecting array.');
     }
index fcac87c112f9d236ea082b202da47f04a99250e1..163095e9d0e820aed29e254162f048f7aceb73ad 100644 (file)
@@ -862,6 +862,10 @@ input.crm-form-entityref {
   font-weight: bold;
 }
 
+.crm-container .font-bold {
+  font-weight: bold !important;
+}
+
 .crm-container .font-italic {
   font-style: italic;
 }
@@ -3867,7 +3871,7 @@ span.crm-status-icon {
 }
 
 .crm-container .strikethrough {
-  text-decoration: line-through;
+  text-decoration: line-through !important;
 }
 
 .crm-container input.ng-invalid.ng-dirty,
index 4bc6287afd07708a13e975768ecb332acd55b72d..a2ab258ce808d900f1c6de4518396a9382e63300 100644 (file)
@@ -3,6 +3,7 @@
 namespace Civi\Api4\Action\SearchDisplay;
 
 use Civi\API\Exception\UnauthorizedException;
+use Civi\Api4\Generic\Traits\ArrayQueryActionTrait;
 use Civi\Api4\SearchDisplay;
 use Civi\Api4\Utils\CoreUtil;
 
@@ -123,9 +124,11 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
       foreach ($this->display['settings']['columns'] as $column) {
         $columns[] = $this->formatColumn($column, $data);
       }
+      $style = $this->getCssStyles($this->display['settings']['cssRules'] ?? [], $data);
       $row = [
         'data' => $data,
         'columns' => $columns,
+        'cssClass' => implode(' ', $style),
       ];
       if (isset($data[$keyName])) {
         $row['key'] = $data[$keyName];
@@ -162,7 +165,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    */
   private function formatColumn($column, $data) {
     $column += ['rewrite' => NULL, 'label' => NULL];
-    $out = $cssClass = [];
+    $out = [];
     switch ($column['type']) {
       case 'field':
         if (isset($column['image']) && is_array($column['image'])) {
@@ -201,6 +204,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
         $out = $this->formatLinksColumn($column, $data);
         break;
     }
+    $cssClass = $this->getCssStyles($column['cssRules'] ?? [], $data);
     if (!empty($column['alignment'])) {
       $cssClass[] = $column['alignment'];
     }
@@ -210,6 +214,66 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     return $out;
   }
 
+  /**
+   * Evaluates conditional style rules
+   *
+   * Rules are in the format ['css class', 'field_name', 'OPERATOR', 'value']
+   *
+   * @param array[] $styleRules
+   * @param array $data
+   * @return array
+   */
+  protected function getCssStyles(array $styleRules, array $data) {
+    $classes = [];
+    foreach ($styleRules as $clause) {
+      $cssClass = $clause[0] ?? '';
+      if ($cssClass) {
+        $condition = $this->getCssRuleCondition($clause);
+        if (is_null($condition[0]) || (ArrayQueryActionTrait::filterCompare($data, $condition))) {
+          $classes[] = $cssClass;
+        }
+      }
+    }
+    return $classes;
+  }
+
+  /**
+   * Returns the condition of a cssRules
+   *
+   * @param array $clause
+   * @return array
+   */
+  protected function getCssRuleCondition($clause) {
+    $fieldKey = $clause[1] ?? NULL;
+    // For fields used in group by, add aggregation and change operator from = to CONTAINS
+    // FIXME: This assumes the operator is always set to '=', which so far is all the admin UI supports.
+    // That's only a safe assumption as long as the admin UI doesn't have an operator selector.
+    // @see ang/crmSearchAdmin/displays/common/searchAdminCssRules.html
+    if ($fieldKey && $this->canAggregate($fieldKey)) {
+      $clause[2] = 'CONTAINS';
+      $fieldKey = 'GROUP_CONCAT_' . str_replace(['.', ':'], '_', $clause[1]);
+    }
+    return [$fieldKey, $clause[2] ?? 'IS NOT EMPTY', $clause[3] ?? NULL];
+  }
+
+  /**
+   * Return fields needed for the select clause by a set of css rules
+   *
+   * @param array $cssRules
+   * @return array
+   */
+  protected function getCssRulesSelect($cssRules) {
+    $select = [];
+    foreach ($cssRules as $clause) {
+      $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;
+      }
+    }
+    return $select;
+  }
+
   /**
    * Format a field value as links
    * @param $column
@@ -635,6 +699,10 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     if (!empty($this->display['settings']['actions'])) {
       $additions = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'primary_key');
     }
+    // Add style conditions for the display
+    foreach ($this->getCssRulesSelect($this->display['settings']['cssRules'] ?? []) as $addition) {
+      $additions[] = $addition;
+    }
     $possibleTokens = '';
     foreach ($this->display['settings']['columns'] as $column) {
       // Collect display values in which a token is allowed
@@ -655,6 +723,10 @@ 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 fields referenced via token
     $tokens = $this->getTokens($possibleTokens);
@@ -697,7 +769,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
         }
       }
     }
-    return $result;
+    return $result ?: $alias;
   }
 
   /**
index ee6bb116f5a10fc7cafb8d5c36e4b3e3520910ae..b920e7ba90acc74bc9a1ca0ecbed214ae15fb2ec 100644 (file)
         }
         if (field) {
           field.baseEntity = entityName;
-          return {field: field, join: join};
         }
+        return {field: field, join: join};
       }
       function parseFnArgs(info, expr) {
         var fnName = expr.split('(')[0],
           };
         } else if (arg) {
           var fieldAndJoin = getFieldAndJoin(arg, searchEntity);
-          if (fieldAndJoin) {
+          if (fieldAndJoin.field) {
             var split = arg.split(':'),
               prefixPos = split[0].lastIndexOf(fieldAndJoin.field.name);
             return {
       return {
         getEntity: getEntity,
         getField: function(fieldName, entityName) {
-          return getFieldAndJoin(fieldName, entityName).field;
+          return getFieldAndJoin(fieldName, entityName || searchEntity).field;
         },
         getJoin: getJoin,
         parseExpr: parseExpr,
index bf7c68d4ba98174ea60a85d442921c250d2e008a..8291b3af39e9d6d0055253855d1b7fd4ca5f5ccf 100644 (file)
       var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
         ctrl = this;
 
+      this.$onInit = function() {
+        $element.on('hidden.bs.dropdown', function() {
+          $scope.$apply(function() {
+            ctrl.menuOpen = false;
+          });
+        });
+      };
+
       this.setValue = function(val) {
         if (val.path) {
           $timeout(function () {
index cd4f85f2b72f5c6fa6a358a020e20880db44b32f..1fa7efac6a65c78592f58c5dcd74548540658c90 100644 (file)
@@ -1,10 +1,10 @@
 <div class="crm-flex-1 input-group" >
   <input type="text" class="form-control" ng-if="!$ctrl.link.action" ng-model="$ctrl.link.path" ng-model-options="{updateOn: 'blur'}" ng-change="$ctrl.onChange({newLink: $ctrl.link})">
   <div class="input-group-btn" style="{{ $ctrl.link.action ? '' : 'width:27px' }}">
-    <button type="button" class="btn btn-sm btn-secondary-outline dropdown-toggle" style="min-width: 200px; text-align: left;" ng-if="$ctrl.link.action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+    <button type="button" ng-click="$ctrl.menuOpen = true;" class="btn btn-sm btn-secondary-outline dropdown-toggle crm-search-admin-combo-button" ng-if="$ctrl.link.action" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
       {{ $ctrl.getLink().text }}
     </button>
-    <button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+    <button type="button" ng-click="$ctrl.menuOpen = true;" class="btn btn-sm btn-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
       <span class="caret"></span>
     </button>
     <ul class="dropdown-menu {{ $ctrl.link.action ? '' : 'dropdown-menu-right' }}" style="min-width: 223px;">
index 6d3b75b03a8ab99e20bed4d6d05954088878d0ac..fbb47263f8d4810305715c5b41291e4721e5c415 100644 (file)
@@ -52,3 +52,4 @@
     {{:: ts('In-Place Edit') }}
   </label>
 </div>
+<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/searchAdminCssRules.component.js b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminCssRules.component.js
new file mode 100644 (file)
index 0000000..b19ade1
--- /dev/null
@@ -0,0 +1,69 @@
+(function(angular, $, _) {
+  "use strict";
+
+  angular.module('crmSearchAdmin').component('searchAdminCssRules', {
+    bindings: {
+      item: '<',
+      default: '<',
+      label: '@',
+    },
+    require: {
+      crmSearchAdmin: '^crmSearchAdmin'
+    },
+    templateUrl: '~/crmSearchAdmin/displays/common/searchAdminCssRules.html',
+    controller: function($scope, $element, searchMeta) {
+      var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
+        ctrl = this;
+
+      this.getField = searchMeta.getField;
+
+      this.styles = _.transform(_.cloneDeep(CRM.crmSearchAdmin.styles), function(styles, style) {
+        if (style.key !== 'default' && style.key !== 'secondary') {
+          styles['bg-' + style.key] = style.value;
+        }
+      }, {});
+      this.styles.disabled = ts('Disabled');
+      this.styles['font-bold'] = ts('Bold');
+      this.styles['font-italic'] = ts('Italic');
+      this.styles.strikethrough = ts('Strikethrough');
+
+      this.fields = function() {
+        return {results: ctrl.crmSearchAdmin.getAllFields(':name', ['Field', 'Custom', 'Extra'])};
+      };
+
+      this.$onInit = function() {
+        $element.on('hidden.bs.dropdown', function() {
+          $scope.$apply(function() {
+            ctrl.menuOpen = false;
+          });
+        });
+      };
+
+      this.onSelectField = function(clause) {
+        if (clause[1]) {
+          clause[2] = '=';
+          clause.length = 3;
+        } else {
+          clause.length = 1;
+        }
+      };
+
+      this.addClause = function(style) {
+        var clause = [style];
+        if (ctrl.default && ctrl.getField(ctrl.default)) {
+          clause.push(ctrl.default, '=');
+        }
+        this.item.cssRules = this.item.cssRules || [];
+        this.item.cssRules.push(clause);
+      };
+
+      this.showMore = function() {
+        return !this.item.cssRules || !this.item.cssRules.length || _.last(this.item.cssRules)[1];
+      };
+
+
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminCssRules.html b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminCssRules.html
new file mode 100644 (file)
index 0000000..cf9e7db
--- /dev/null
@@ -0,0 +1,48 @@
+<div class="form-inline" ng-repeat="clause in $ctrl.item.cssRules">
+  <label>{{:: $ctrl.label }}</label>
+  <div class="input-group">
+    <input type="text" class="form-control" ng-if="!$ctrl.styles[clause[0]]" placeholder="{{:: ts('CSS class') }}" ng-model="clause[0]" ng-model-options="{updateOn: 'blur'}">
+    <div class="input-group-btn" style="{{ $ctrl.styles[clause[0]] ? '' : 'width:27px' }}">
+      <button type="button" ng-click="$ctrl.menuOpen = true" ng-if="$ctrl.styles[clause[0]]" class="btn btn-sm dropdown-toggle crm-search-admin-combo-button {{ clause[0].replace('bg-', 'btn-') }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        {{ $ctrl.styles[clause[0]] }}
+      </button>
+      <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 class="sr-only">{{:: $ctrl.label }}</span> <span class="caret"></span>
+      </button>
+      <ul class="dropdown-menu dropdown-menu-right" ng-if="$ctrl.menuOpen">
+        <li ng-repeat="(key, label) in $ctrl.styles">
+          <a href class="{{:: key }}" ng-click="clause[0] = key">{{:: label }}</a>
+        </li>
+        <li class="divider" role="separator"></li>
+        <li>
+          <a href ng-click="clause[0] = ''">{{:: ts('Other') }}</a>
+        </li>
+      </ul>
+    </div>
+  </div>
+  <label>{{:: ts('If') }}</label>
+  <input class="form-control collapsible-optgroups" ng-model="clause[1]" crm-ui-select="::{data: $ctrl.fields, allowClear: true, placeholder: ts('Always')}" ng-change="$ctrl.onSelectField(clause)" />
+  <!-- TODO: Support operators other than '=' as clause[2] -->
+  <label ng-if="clause[1]">{{:: ts('Is') }}</label>
+  <crm-search-input ng-if="clause[1]" ng-model="clause[3]" field="$ctrl.getField(clause[1])" option-key="'name'" op="clause[1]" format="$ctrl.format" class="form-group"></crm-search-input>
+  <button type="button" class="btn-xs btn-danger-outline" ng-click="$ctrl.item.cssRules.splice($index);" title="{{:: ts('Remove style') }}">
+    <i class="crm-i fa-ban"></i>
+  </button>
+</div>
+<div class="form-inline" ng-if="$ctrl.showMore()" title="{{:: ts('Set background color or text style based on a field value') }}">
+  <label>{{:: $ctrl.label }}</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.cssRules && $ctrl.item.cssRules.length ? ts('Add') : ts('None') }}</span> <span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu" ng-if="$ctrl.menuOpen">
+      <li ng-repeat="(key, label) in $ctrl.styles">
+        <a href class="{{:: key }}" ng-click="$ctrl.addClause(key)">{{:: label }}</a>
+      </li>
+      <li class="divider" role="separator"></li>
+      <li>
+        <a href ng-click="$ctrl.addClause('')">{{:: ts('Other') }}</a>
+      </li>
+    </ul>
+  </div>
+</div>
index 7c45809d270e5b2c4b93a5d0b8e979f6f2563e45..78119bbad1b8bee0c3ac659459e5214a54b4eb76 100644 (file)
@@ -21,7 +21,7 @@
         {name: 'table-striped', label: ts('Even/Odd Stripes')}
       ];
 
-      // Check if array conatains item
+      // Check if array contains item
       this.includes = _.includes;
 
       // Add or remove an item from an array
index b3234eadaa155b9e4f4f36781e145aa2baa0688c..a3090f9203261d8ac07a97b87ef294639291bdad 100644 (file)
@@ -18,6 +18,7 @@
       </label>
     </div>
   </div>
+  <search-admin-css-rules label="{{:: ts('Row Style') }}" item="$ctrl.display.settings"></search-admin-css-rules>
   <search-admin-pager-config display="$ctrl.display"></search-admin-pager-config>
 </fieldset>
 <fieldset class="crm-search-admin-edit-columns-wrapper">
index a413ad1cf606a44dfb81ff33fcba49dd23fbadb1..805a432aa23274ac8d74752c63ed6d055f0cab0b 100644 (file)
@@ -1,4 +1,4 @@
-<div ng-repeat="(rowIndex, row) in $ctrl.results">
+<div ng-repeat="(rowIndex, row) in $ctrl.results" class="{{:: row.cssClass }}">
   <div ng-repeat="(colIndex, colData) in row.columns" title="{{:: colData.title }}" class="{{:: colData.cssClass }} {{:: $ctrl.settings.columns[colIndex].break ? '' : 'crm-inline-block' }}">
     <label ng-if=":: colData.label">
       {{:: colData.label }}
index c618794ee5917ad4ffacf025708eb971c356b112..9736f7a43340fc5d7a634015f3a39183f5b3142f 100644 (file)
@@ -1,4 +1,4 @@
-<li ng-repeat="(rowIndex, row) in $ctrl.results">
+<li ng-repeat="(rowIndex, row) in $ctrl.results" class="{{:: row.cssClass }}">
   <div ng-repeat="(colIndex, colData) in row.columns" title="{{:: colData.title }}" class="{{:: colData.cssClass }} {{:: $ctrl.settings.columns[colIndex].break ? '' : 'crm-inline-block' }}">
     <label ng-if=":: colData.label">
       {{:: colData.label }}
index 8222cfb22d27478683cf1a57d0f00a39707baebc..f3f525aa6f1115c8330911cf5231bdcb0e6f12df 100644 (file)
@@ -1,8 +1,8 @@
 <tr ng-repeat="(rowIndex, row) in $ctrl.results">
-  <td ng-if=":: $ctrl.settings.actions">
+  <td ng-if=":: $ctrl.settings.actions" class="{{:: row.cssClass }}">
     <input type="checkbox" ng-checked="$ctrl.isRowSelected(row)" ng-click="$ctrl.selectRow(row)" ng-disabled="!!$ctrl.loadingAllRows">
   </td>
-  <td ng-repeat="(colIndex, colData) in row.columns" ng-include="'~/crmSearchDisplay/colType/' + $ctrl.settings.columns[colIndex].type + '.html'" title="{{:: colData.title }}" class="{{:: colData.cssClass }}">
+  <td ng-repeat="(colIndex, colData) in row.columns" ng-include="'~/crmSearchDisplay/colType/' + $ctrl.settings.columns[colIndex].type + '.html'" title="{{:: colData.title }}" class="{{:: row.cssClass }} {{:: colData.cssClass }}">
   </td>
 </tr>
 <tr ng-if="$ctrl.rowCount === 0">
index a02c9c956210008fbd50b1bb257990e402806c61..a6bb86ab700d5b930bd9efafa228517044eb35fa 100644 (file)
   width: 30px;
   padding: 2px 4px;
 }
+
+#bootstrap-theme button.crm-search-admin-combo-button {
+  min-width: 200px;
+  text-align: left;
+}
index b1cb6cc0bb0da3dc4a9fa6bcede0e77a83f6c7d7..8d5dc5554c1122241fa99b20ae22c80e102d84d7 100644 (file)
@@ -4,6 +4,7 @@ namespace api\v4\SearchDisplay;
 use Civi\API\Exception\UnauthorizedException;
 use Civi\Api4\Contact;
 use Civi\Api4\ContactType;
+use Civi\Api4\Email;
 use Civi\Api4\SavedSearch;
 use Civi\Api4\SearchDisplay;
 use Civi\Api4\UFMatch;
@@ -570,8 +571,141 @@ class SearchRunTest extends \PHPUnit\Framework\TestCase implements HeadlessInter
     // Same seed, same order every time
     for ($i = 0; $i <= 9; ++$i) {
       $repeat = civicrm_api4('SearchDisplay', 'run', $params);
-      $this->assertEquals($seeded->column('id'), $repeat->column('id'));
+      $this->assertEquals($seeded->column('data'), $repeat->column('data'));
     }
   }
 
+  /**
+   * Test conditional styles
+   */
+  public function testCssRules() {
+    $lastName = uniqid(__FUNCTION__);
+    $sampleContacts = [
+      ['first_name' => 'Zero', 'last_name' => $lastName, 'is_deceased' => TRUE],
+      ['first_name' => 'One', 'last_name' => $lastName],
+      ['first_name' => 'Two', 'last_name' => $lastName],
+      ['first_name' => 'Three', 'last_name' => $lastName],
+    ];
+    $contacts = Contact::save(FALSE)->setRecords($sampleContacts)->execute();
+    $sampleEmails = [
+      ['contact_id' => $contacts[0]['id'], 'email' => 'abc@123', 'on_hold' => 1],
+      ['contact_id' => $contacts[0]['id'], 'email' => 'def@123', 'on_hold' => 0],
+      ['contact_id' => $contacts[1]['id'], 'email' => 'ghi@123', 'on_hold' => 0],
+      ['contact_id' => $contacts[2]['id'], 'email' => 'jkl@123', 'on_hold' => 2],
+    ];
+    Email::save(FALSE)->setRecords($sampleEmails)->execute();
+
+    $search = [
+      'name' => 'Test',
+      'label' => 'Test Me',
+      'api_entity' => 'Contact',
+      'api_params' => [
+        'version' => 4,
+        'select' => [
+          'id',
+          'display_name',
+          'GROUP_CONCAT(DISTINCT Contact_Email_contact_id_01.email) AS GROUP_CONCAT_Contact_Email_contact_id_01_email',
+        ],
+        'where' => [['last_name', '=', $lastName]],
+        'groupBy' => ['id'],
+        'join' => [
+          [
+            'Email AS Contact_Email_contact_id_01',
+            'LEFT',
+            ['id', '=', 'Contact_Email_contact_id_01.contact_id'],
+          ],
+        ],
+        'having' => [],
+      ],
+      'acl_bypass' => FALSE,
+    ];
+
+    $display = [
+      'type' => 'table',
+      'settings' => [
+        'actions' => TRUE,
+        'limit' => 50,
+        'classes' => ['table', 'table-striped'],
+        'pager' => [
+          'show_count' => TRUE,
+          'expose_limit' => TRUE,
+        ],
+        'columns' => [
+          [
+            'type' => 'field',
+            'key' => 'id',
+            'dataType' => 'Integer',
+            'label' => 'Contact ID',
+            'sortable' => TRUE,
+            'alignment' => 'text-center',
+          ],
+          [
+            'type' => 'field',
+            'key' => 'display_name',
+            'dataType' => 'String',
+            'label' => 'Display Name',
+            'sortable' => TRUE,
+            'link' => [
+              'entity' => 'Contact',
+              'action' => 'view',
+              'target' => '_blank',
+            ],
+            'title' => 'View Contact',
+          ],
+          [
+            'type' => 'field',
+            'key' => 'GROUP_CONCAT_Contact_Email_contact_id_01_email',
+            'dataType' => 'String',
+            'label' => '(List) Contact Emails: Email',
+            'sortable' => TRUE,
+            'alignment' => 'text-right',
+            'cssRules' => [
+              [
+                'bg-danger',
+                'Contact_Email_contact_id_01.on_hold:name',
+                '=',
+                'On Hold Bounce',
+              ],
+              [
+                'bg-warning',
+                'Contact_Email_contact_id_01.on_hold:name',
+                '=',
+                'On Hold Opt Out',
+              ],
+            ],
+            'rewrite' => '',
+            'title' => NULL,
+          ],
+        ],
+        'cssRules' => [
+          ['strikethrough', 'is_deceased', '=', TRUE],
+        ],
+      ],
+    ];
+
+    $result = SearchDisplay::Run(FALSE)
+      ->setSavedSearch($search)
+      ->setDisplay($display)
+      ->setReturn('page:1')
+      ->setSort([['id', 'ASC']])
+      ->execute();
+
+    // Non-conditional style rule
+    $this->assertEquals('text-center', $result[0]['columns'][0]['cssClass']);
+    // First contact is deceased, gets strikethrough class
+    $this->assertEquals('strikethrough', $result[0]['cssClass']);
+    $this->assertNotEquals('strikethrough', $result[1]['cssClass']);
+    // Ensure the view contact link was formed
+    $this->assertStringContainsString('cid=' . $contacts[0]['id'], $result[0]['columns'][1]['links'][0]['url']);
+    $this->assertEquals('_blank', $result[0]['columns'][1]['links'][0]['target']);
+    // 1st column gets static + conditional style
+    $this->assertStringContainsString('text-right', $result[0]['columns'][2]['cssClass']);
+    $this->assertStringContainsString('bg-danger', $result[0]['columns'][2]['cssClass']);
+    // 2nd row gets static style but no conditional styles apply
+    $this->assertEquals('text-right', $result[1]['columns'][2]['cssClass']);
+    // 3rd column gets static + conditional style
+    $this->assertStringContainsString('text-right', $result[2]['columns'][2]['cssClass']);
+    $this->assertStringContainsString('bg-warning', $result[2]['columns'][2]['cssClass']);
+  }
+
 }