This allows each column to have one or more icons, based on a field value or a conditional rule.
if ($cssClass) {
$out['cssClass'] = implode(' ', $cssClass);
}
+ if (!empty($column['icons'])) {
+ $out['icons'] = $this->getColumnIcons($column['icons'], $data);
+ }
return $out;
}
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;
}
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];
}
/**
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
$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);
$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'],
]);
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
};
{{:: 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>
--- /dev/null
+(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._);
--- /dev/null
+<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>
<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>
}
/**
- * 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__);
}
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')},
'<div class="icon-ctrls crm-clearfix">' +
'<input class="crm-form-text" name="search" placeholder=""/>' +
'<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>'
);