return $result;
default:
- return $this->filterCompare($row, $filters);
+ return self::filterCompare($row, $filters);
}
}
* @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.');
}
font-weight: bold;
}
+.crm-container .font-bold {
+ font-weight: bold !important;
+}
+
.crm-container .font-italic {
font-style: italic;
}
}
.crm-container .strikethrough {
- text-decoration: line-through;
+ text-decoration: line-through !important;
}
.crm-container input.ng-invalid.ng-dirty,
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;
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];
*/
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'])) {
$out = $this->formatLinksColumn($column, $data);
break;
}
+ $cssClass = $this->getCssStyles($column['cssRules'] ?? [], $data);
if (!empty($column['alignment'])) {
$cssClass[] = $column['alignment'];
}
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
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
$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);
}
}
}
- return $result;
+ return $result ?: $alias;
}
/**
}
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,
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 () {
<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;">
{{:: ts('In-Place Edit') }}
</label>
</div>
+<search-admin-css-rules label="{{:: ts('Style') }}" item="col" default="col.key"></search-admin-css-rules>
--- /dev/null
+(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._);
--- /dev/null
+<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>
{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
</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">
-<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 }}
-<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 }}
<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">
width: 30px;
padding: 2px 4px;
}
+
+#bootstrap-theme button.crm-search-admin-combo-button {
+ min-width: 200px;
+ text-align: left;
+}
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;
// 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']);
+ }
+
}