This expands on the concept of the "Add New" button at the top of some displays,
to allow a variety of buttons in a row (aka toolbar).
Before: addButton was pretty simple, only one allowed
After: Toolbar buttons can evaluate tokens, check permissions and conditional rules
* This is used by read AND write actions (Get, Create, Update, Replace)
*
* @param $value
- * @param string|null $fieldName
+ * @param string|null $fieldPath
* @param array $fieldSpec
* @param array $params
* @param string|null $operator (only for 'get' actions)
* @param null $index (for recursive loops)
* @throws \CRM_Core_Exception
*/
- public static function formatInputValue(&$value, ?string $fieldName, array $fieldSpec, array $params = [], &$operator = NULL, $index = NULL) {
+ public static function formatInputValue(&$value, ?string $fieldPath, array $fieldSpec, array $params = [], &$operator = NULL, $index = NULL) {
// Evaluate pseudoconstant suffix
- $suffix = str_replace(':', '', strstr(($fieldName ?? ''), ':'));
+ $suffix = str_replace(':', '', strstr(($fieldPath ?? ''), ':'));
$fk = $fieldSpec['name'] == 'id' ? $fieldSpec['entity'] : $fieldSpec['fk_entity'] ?? NULL;
// Handle special 'current_domain' option. See SpecFormatter::getOptions
// Convert option list suffix to value
if ($suffix) {
- $options = self::getPseudoconstantList($fieldSpec, $fieldName, $params, $operator ? 'get' : 'create');
+ $options = self::getPseudoconstantList($fieldSpec, $fieldPath, $params, $operator ? 'get' : 'create');
$value = self::replacePseudoconstant($options, $value, TRUE);
return;
}
elseif (is_array($value)) {
$i = 0;
foreach ($value as &$val) {
- self::formatInputValue($val, $fieldName, $fieldSpec, $params, $operator, $i++);
+ self::formatInputValue($val, $fieldPath, $fieldSpec, $params, $operator, $i++);
}
return;
}
}
$hic = \CRM_Utils_API_HTMLInputCoder::singleton();
- if (is_string($value) && $fieldName && !$hic->isSkippedField($fieldSpec['name'])) {
+ if (is_string($value) && $fieldPath && !$hic->isSkippedField($fieldSpec['name'])) {
$value = $hic->encodeValue($value);
}
}
return $out;
}
- private function formatLink(array $link, array $data, string $text = NULL, $index = 0): ?array {
+ protected function formatLink(array $link, array $data, string $text = NULL, $index = 0): ?array {
$link = $this->getLinkInfo($link);
if (!$this->checkLinkAccess($link, $data, $index)) {
return NULL;
$path = $this->replaceTokens($link['path'], $data, 'url', $index);
if ($path) {
$link['url'] = $this->getUrl($path);
- $keys = ['url', 'text', 'title', 'target', 'icon', 'style'];
+ $keys = ['url', 'text', 'title', 'target', 'icon', 'style', 'autoOpen'];
}
else {
$keys = ['task', 'text', 'title', 'icon', 'style'];
*/
public function getLinksMenu() {
$menu = [];
+ $discard = array_flip(['add', 'browse']);
$mainEntity = $this->savedSearch['api_entity'] ?? NULL;
if ($mainEntity && !$this->canAggregate(CoreUtil::getIdFieldName($mainEntity))) {
- foreach (Display::getEntityLinks($mainEntity, TRUE) as $link) {
+ foreach (array_diff_key(Display::getEntityLinks($mainEntity, TRUE), $discard) as $link) {
$link['join'] = NULL;
$menu[] = $link;
}
if (!$this->canAggregate($join['alias'] . '.' . CoreUtil::getIdFieldName($join['entity']))) {
foreach (array_filter(array_intersect_key($join, $keys)) as $joinEntity) {
$joinLabel = $this->getJoinLabel($join['alias']);
- foreach (Display::getEntityLinks($joinEntity, $joinLabel) as $link) {
+ foreach (array_diff_key(Display::getEntityLinks($joinEntity, $joinLabel), $discard) as $link) {
$link['join'] = $join['alias'];
$menu[] = $link;
}
use Civi\API\Request;
use Civi\Api4\Query\Api4SelectQuery;
use Civi\Api4\Utils\CoreUtil;
+use Civi\Api4\Utils\FormattingUtil;
/**
* Load the results for rendering a SearchDisplay.
$result->setCountMatched($apiResult->countFetched());
$apiResult = array_slice((array) $apiResult, 0, $apiParams['limit'] - 1);
}
+ if ($pagerMode === 'page') {
+ $result->toolbar = $this->formatToolbar();
+ }
$result->exchangeArray($this->formatResult($apiResult));
$result->labels = $this->filterLabels;
}
+ }
+ private function formatToolbar(): array {
+ $toolbar = $data = [];
+ $settings = $this->display['settings'];
+ // If no toolbar, early return
+ if (empty($settings['toolbar']) && empty($settings['addButton']['path'])) {
+ return [];
+ }
+ // There is no row data, but some values can be inferred from query filters
+ // First pass: gather raw data from the where clause
+ foreach ($this->_apiParams['where'] as $clause) {
+ if ($clause[1] === '=' || $clause[1] === 'IN') {
+ $data[$clause[0]] = $clause[2];
+ }
+ }
+ // Second pass: format values (because data from first pass could be useful to FormattingUtil)
+ foreach ($this->_apiParams['where'] as $clause) {
+ if ($clause[1] === '=' || $clause[1] === 'IN') {
+ [$fieldPath] = explode(':', $clause[0]);
+ $fieldSpec = $this->getField($fieldPath);
+ $data[$fieldPath] = $clause[2];
+ if ($fieldSpec) {
+ FormattingUtil::formatInputValue($data[$fieldPath], $clause[0], $fieldSpec, $data, $clause[1]);
+ }
+ }
+ }
+ // Support legacy 'addButton' setting
+ if (empty($settings['toolbar']) && !empty($settings['addButton']['path'])) {
+ $settings['toolbar'][] = $settings['addButton'] + ['style' => 'primary', 'target' => 'crm-popup'];
+ }
+ foreach ($settings['toolbar'] ?? [] as $button) {
+ $button = $this->formatLink($button, $data);
+ if ($button) {
+ $toolbar[] = $button;
+ }
+ }
+ return $toolbar;
}
}
namespace Civi\Api4\Result;
/**
- * Class ReplaceResult
+ * Specialized APIv4 Result object for SearchDisplay::run
*
* @package Civi\Api4\Result
*/
class SearchDisplayRunResult extends \Civi\Api4\Generic\Result {
/**
+ * Contextual labels for use in page title
* @var array
*/
public $labels = [];
+ /**
+ * Rendered toolbar buttons
+ * @var array|null
+ */
+ public $toolbar = NULL;
+
}
}
// If addLabel is false the placeholder needs to be passed through to javascript
$label = $addLabel ?: '%1';
- unset($paths['add'], $paths['browse']);
+ $styles = [
+ 'delete' => 'danger',
+ 'add' => 'primary',
+ ];
foreach (array_keys($paths) as $actionName) {
$actionKey = \CRM_Core_Action::mapItem($actionName);
$link = [
'text' => \CRM_Core_Action::getTitle($actionKey, $label),
'icon' => \CRM_Core_Action::getIcon($actionKey),
'weight' => \CRM_Core_Action::getWeight($actionKey),
- 'style' => $actionName === 'delete' ? 'danger' : 'default',
+ 'style' => $styles[$actionName] ?? 'default',
'target' => 'crm-popup',
];
// Contacts and cases are too cumbersome to view in a popup
}
$links[$actionName] = $link;
}
+ // Sort by weight, then discard it
uasort($links, ['CRM_Utils_Sort', 'cmpFunc']);
+ foreach ($links as $index => $link) {
+ unset($links[$index]['weight']);
+ }
return $links;
}
// Build a list of all possible links to main entity & join entities
// @return {Array}
- this.buildLinks = function() {
+ this.buildLinks = function(isRow) {
function addTitle(link, entityName) {
link.text = link.text.replace('%1', entityName);
}
}
}
});
- return links;
+ // Filter links according to usage - add & browse only make sense outside of a row
+ return _.filter(links, (link) => ['add', 'browse'].includes(link.action) !== isRow);
};
function loadAfforms() {
this.getLinks = function(columnKey) {
if (!ctrl.links) {
- ctrl.links = {'*': ctrl.crmSearchAdmin.buildLinks(), '0': []};
+ ctrl.links = {
+ '*': ctrl.crmSearchAdmin.buildLinks(true),
+ '0': []
+ };
ctrl.links[''] = _.filter(ctrl.links['*'], {join: ''});
searchMeta.getSearchTasks(ctrl.savedSearch.api_entity).then(function(tasks) {
_.each(tasks, function (task) {
});
};
- this.toggleAddButton = function() {
- if (ctrl.display.settings.addButton && ctrl.display.settings.addButton.path) {
- delete ctrl.display.settings.addButton;
- } else {
- var entity = searchMeta.getBaseEntity();
- ctrl.display.settings.addButton = {
- path: entity.addPath || 'civicrm/',
- text: ts('Add %1', {1: entity.title}),
- icon: 'fa-plus'
- };
- }
- };
-
- this.onChangeAddButtonPath = function() {
- if (!ctrl.display.settings.addButton.path) {
- delete ctrl.display.settings.addButton;
- }
- };
-
// Helper function to sort active from hidden columns and initialize each column with defaults
this.initColumns = function(defaults) {
if (!ctrl.display.settings.columns) {
--- /dev/null
+(function(angular, $, _) {
+ "use strict";
+
+ angular.module('crmSearchAdmin').component('searchAdminToolbarConfig', {
+ bindings: {
+ display: '<',
+ apiEntity: '<',
+ apiParams: '<'
+ },
+ require: {
+ crmSearchAdmin: '^crmSearchAdmin'
+ },
+ templateUrl: '~/crmSearchAdmin/displays/common/searchAdminToolbarConfig.html',
+ controller: function($scope, searchMeta) {
+ var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
+ ctrl = this;
+
+ this.$onInit = function() {
+ this.links = ctrl.crmSearchAdmin.buildLinks(false);
+ // Migrate legacy setting
+ if (ctrl.display.settings.addButton) {
+ if (!ctrl.display.settings.toolbar && ctrl.display.settings.addButton.path) {
+ ctrl.display.settings.addButton.style = 'primary';
+ ctrl.display.settings.toolbar = [ctrl.display.settings.addButton];
+ }
+ delete ctrl.display.settings.addButton;
+ }
+ };
+
+ this.toggleToolbar = function() {
+ if (ctrl.display.settings.toolbar) {
+ delete ctrl.display.settings.toolbar;
+ } else {
+ ctrl.display.settings.toolbar = _.filter(ctrl.links, function(link) {
+ return link.action === 'add' && !link.join;
+ });
+ }
+ };
+
+ }
+ });
+
+})(angular, CRM.$, CRM._);
--- /dev/null
+<fieldset>
+ <div class="form-inline">
+ <div class="checkbox-inline form-control">
+ <label>
+ <input type="checkbox" ng-checked="!!$ctrl.display.settings.toolbar" ng-click="$ctrl.toggleToolbar()">
+ <span>{{:: ts('Toolbar') }}</span>
+ </label>
+ </div>
+ </div>
+ <crm-search-admin-link-group ng-if="$ctrl.display.settings.toolbar" links="$ctrl.links" group="$ctrl.display.settings.toolbar" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></crm-search-admin-link-group>
+</fieldset>
</label>
</div>
</div>
-<div class="input-group">
- <div class="checkbox-inline form-control" title="{{:: ts('Display a button for creating a new record') }}">
- <label>
- <input type="checkbox" ng-checked="$ctrl.display.settings.addButton.path" ng-click="$ctrl.parent.toggleAddButton()">
- <span>{{:: ts('"Add New" Button') }}</span>
- </label>
- </div>
-</div>
-<input class="form-control" ng-if="$ctrl.display.settings.addButton.path" ng-model="$ctrl.display.settings.addButton.path" ng-change="$ctrl.onChangeAddButtonPath()" ng-model-options="{updateOn: 'blur'}" title="{{:: ts('Path') }}" placeholder="{{:: ts('Path') }}">
</div>
<search-admin-pager-config display="$ctrl.display"></search-admin-pager-config>
<search-admin-placeholder-config display="$ctrl.display"></search-admin-placeholder-config>
+ <search-admin-toolbar-config display="$ctrl.display" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></search-admin-toolbar-config>
</fieldset>
<fieldset class="crm-search-admin-edit-columns-wrapper">
<legend>
</div>
<search-admin-pager-config display="$ctrl.display"></search-admin-pager-config>
<search-admin-placeholder-config display="$ctrl.display"></search-admin-placeholder-config>
+ <search-admin-toolbar-config display="$ctrl.display" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></search-admin-toolbar-config>
</fieldset>
<fieldset class="crm-search-admin-edit-columns-wrapper">
<legend>
<label for="crm-search-admin-display-no-results-text">{{:: ts('No Results Text') }}</label>
<input class="form-control crm-flex-1" id="crm-search-admin-display-no-results-text" ng-model="$ctrl.display.settings.noResultsText" placeholder="{{:: ts('None found.') }}">
</div>
+ <search-admin-toolbar-config display="$ctrl.display" api-entity="$ctrl.apiEntity" api-params="$ctrl.apiParams"></search-admin-toolbar-config>
<div class="form-inline">
<div class="checkbox-inline form-control" title="{{:: ts('Shows grand totals or other statistics, configured per-column.') }}">
<label>
+++ /dev/null
-<a ng-href="{{ $ctrl.getButtonUrl() }}" class="btn btn-primary" target="crm-popup">
- <i ng-if="$ctrl.settings.addButton.icon" class="crm-i {{:: $ctrl.settings.addButton.icon }}"></i>
- {{:: $ctrl.settings.addButton.text }}
-</a>
--- /dev/null
+<a ng-repeat="link in $ctrl.toolbar" ng-href="{{:: link.url }}" class="btn btn-{{:: link.style }}" target="{{:: link.target }}">
+ <i ng-if="link.icon" class="crm-i {{:: link.icon }}"></i>
+ {{:: link.text }}
+</a>
};
},
- // Get path for the addButton
- getButtonUrl: function() {
- var path = this.settings.addButton.path,
- filters = this.getFilters();
- _.each(filters, function(value, key) {
- path = path.replace('[' + key + ']', value);
- });
- return CRM.url(path);
- },
-
onClickSearchButton: function() {
this.rowCount = null;
this.page = 1;
ctrl.rowCount = result.count;
});
}
- // If there are no results on initial load, open the "addNew" link if configured as "autoOpen"
- if (!ctrl.results.length && requestId === 1 && ctrl.settings.addButton && ctrl.settings.addButton.autoOpen) {
- CRM.loadForm(ctrl.getButtonUrl())
- .on('crmFormSuccess', function() {
- ctrl.rowCount = null;
- ctrl.getResultsPronto();
- });
- }
+ }
+ // Process toolbar
+ if (apiResults.run.toolbar) {
+ ctrl.toolbar = apiResults.run.toolbar;
+ // If there are no results on initial load, open an "autoOpen" toolbar link
+ ctrl.toolbar.forEach((link) => {
+ if (link.autoOpen && requestId === 1 && !ctrl.results.length) {
+ CRM.loadForm(link.url)
+ .on('crmFormSuccess', () => {
+ ctrl.rowCount = null;
+ ctrl.getResultsPronto();
+ });
+ }
+ });
}
_.each(ctrl.onPostRun, function(callback) {
callback.call(ctrl, apiResults, 'success', editedRow);
<div class="form-inline">
<div class="btn-group" ng-include="'~/crmSearchDisplay/SearchButton.html'" ng-if="$ctrl.settings.button"></div>
<span ng-if="$ctrl.settings.headerCount" ng-include="'~/crmSearchDisplay/ResultCount.html'"></span>
- <div class="btn-group pull-right" ng-include="'~/crmSearchDisplay/AddButton.html'" ng-if="$ctrl.settings.addButton.path"></div>
+ <div class="btn-group pull-right" ng-include="'~/crmSearchDisplay/toolbar.html'" ng-if="$ctrl.toolbar"></div>
</div>
<div
class="crm-search-display-grid-container crm-search-display-grid-layout-{{$ctrl.settings.colno}}"
<div class="form-inline">
<div class="btn-group" ng-include="'~/crmSearchDisplay/SearchButton.html'" ng-if="$ctrl.settings.button"></div>
<span ng-if="$ctrl.settings.headerCount" ng-include="'~/crmSearchDisplay/ResultCount.html'"></span>
- <div class="btn-group pull-right" ng-include="'~/crmSearchDisplay/AddButton.html'" ng-if="$ctrl.settings.addButton.path"></div>
+ <div class="btn-group pull-right" ng-include="'~/crmSearchDisplay/toolbar.html'" ng-if="$ctrl.toolbar"></div>
</div>
<ol ng-if=":: $ctrl.settings.style === 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayList' + ($ctrl.loading ? 'Loading' : 'Items') + '.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ol>
<ul ng-if=":: $ctrl.settings.style !== 'ol'" ng-include="'~/crmSearchDisplayList/crmSearchDisplayList' + ($ctrl.loading ? 'Loading' : 'Items') + '.html'" ng-style="{'list-style': $ctrl.settings.symbol}"></ul>
<div class="btn-group" ng-include="'~/crmSearchDisplay/SearchButton.html'" ng-if="$ctrl.settings.button"></div>
<crm-search-tasks-menu ng-if="$ctrl.settings.actions && $ctrl.taskManager" ids="$ctrl.selectedRows" task-manager="$ctrl.taskManager"></crm-search-tasks-menu>
<span ng-if="$ctrl.settings.headerCount" ng-include="'~/crmSearchDisplay/ResultCount.html'"></span>
- <div class="btn-group pull-right" ng-include="'~/crmSearchDisplay/AddButton.html'" ng-if="$ctrl.settings.addButton.path"></div>
+ <div class="btn-group pull-right" ng-include="'~/crmSearchDisplay/toolbar.html'" ng-if="$ctrl.toolbar"></div>
</div>
<table class="{{:: $ctrl.settings.classes.join(' ') }}">
<thead>
$this->assertEquals($id, $result[0]['key']);
}
+ public function testRunWithToolbar(): void {
+ $params = [
+ 'checkPermissions' => FALSE,
+ 'return' => 'page:1',
+ 'savedSearch' => [
+ 'api_entity' => 'Contact',
+ 'api_params' => [
+ 'version' => 4,
+ 'select' => ['first_name', 'contact_type'],
+ ],
+ ],
+ 'display' => [
+ 'type' => 'table',
+ 'label' => '',
+ 'settings' => [
+ 'actions' => TRUE,
+ 'pager' => [],
+ 'toolbar' => [
+ [
+ 'entity' => 'Contact',
+ 'action' => 'add',
+ 'text' => 'Add Contact',
+ 'target' => 'crm-popup',
+ 'icon' => 'fa-plus',
+ 'style' => 'primary',
+ ],
+ ],
+ 'columns' => [
+ [
+ 'key' => 'first_name',
+ 'label' => 'First',
+ 'dataType' => 'String',
+ 'type' => 'field',
+ ],
+ ],
+ 'sort' => [],
+ ],
+ ],
+ 'filters' => ['contact_type' => 'Individual'],
+ ];
+ $result = civicrm_api4('SearchDisplay', 'run', $params);
+ $this->assertCount(1, $result->toolbar);
+ $button = $result->toolbar[0];
+ $this->assertEquals('crm-popup', $button['target']);
+ $this->assertEquals('fa-plus', $button['icon']);
+ $this->assertEquals('primary', $button['style']);
+ $this->assertEquals('Add Contact', $button['text']);
+ $this->assertStringContainsString('=Individual', $button['url']);
+
+ // Try with pseudoconstant (for proper test the label needs to be different from the name)
+ ContactType::update(FALSE)
+ ->addValue('label', 'Disorganization')
+ ->addWhere('name', '=', 'Organization')
+ ->execute();
+ $params['filters'] = ['contact_type:label' => 'Disorganization'];
+ $result = civicrm_api4('SearchDisplay', 'run', $params);
+ $button = $result->toolbar[0];
+ $this->assertStringContainsString('=Organization', $button['url']);
+
+ // Test legacy 'addButton' setting
+ $params['display']['settings']['toolbar'] = NULL;
+ $params['display']['settings']['addButton'] = [
+ 'path' => 'civicrm/test/url?test=[contact_type]',
+ 'text' => 'Test',
+ 'icon' => 'fa-old',
+ 'autoOpen' => TRUE,
+ ];
+ $result = civicrm_api4('SearchDisplay', 'run', $params);
+ $this->assertCount(1, $result->toolbar);
+ $button = $result->toolbar[0];
+ $this->assertStringContainsString('test=Organization', $button['url']);
+ $this->assertTrue($button['autoOpen']);
+ }
+
public function testRunWithEntityFile(): void {
$cid = $this->createTestRecord('Contact')['id'];
$notes = $this->saveTestRecords('Note', [