From 69cdce2842e3d14712a80af1530d0ca5351d0dd9 Mon Sep 17 00:00:00 2001 From: colemanw Date: Thu, 14 Sep 2023 08:22:16 -0400 Subject: [PATCH] SearchKit - Toolbar buttons for search displays 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 --- Civi/Api4/Utils/FormattingUtil.php | 12 +-- .../SearchDisplay/AbstractRunAction.php | 4 +- .../Api4/Action/SearchDisplay/GetDefault.php | 5 +- .../Civi/Api4/Action/SearchDisplay/Run.php | 41 ++++++++++ .../Api4/Result/SearchDisplayRunResult.php | 9 ++- ext/search_kit/Civi/Search/Display.php | 11 ++- .../crmSearchAdmin.component.js | 5 +- .../crmSearchAdminDisplay.component.js | 24 +----- .../searchAdminToolbarConfig.component.js | 43 +++++++++++ .../common/searchAdminToolbarConfig.html | 11 +++ .../displays/common/searchButtonConfig.html | 9 --- .../displays/searchAdminDisplayGrid.html | 1 + .../displays/searchAdminDisplayList.html | 1 + .../displays/searchAdminDisplayTable.html | 1 + .../ang/crmSearchDisplay/AddButton.html | 4 - .../ang/crmSearchDisplay/toolbar.html | 4 + .../traits/searchDisplayBaseTrait.service.js | 32 ++++---- .../crmSearchDisplayGrid.html | 2 +- .../crmSearchDisplayList.html | 2 +- .../crmSearchDisplayTable.html | 2 +- .../api/v4/SearchDisplay/SearchRunTest.php | 74 +++++++++++++++++++ 21 files changed, 228 insertions(+), 69 deletions(-) create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.component.js create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.html delete mode 100644 ext/search_kit/ang/crmSearchDisplay/AddButton.html create mode 100644 ext/search_kit/ang/crmSearchDisplay/toolbar.html diff --git a/Civi/Api4/Utils/FormattingUtil.php b/Civi/Api4/Utils/FormattingUtil.php index 0376bb044e..e05f3bd7f5 100644 --- a/Civi/Api4/Utils/FormattingUtil.php +++ b/Civi/Api4/Utils/FormattingUtil.php @@ -80,16 +80,16 @@ class FormattingUtil { * 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 @@ -113,14 +113,14 @@ class FormattingUtil { // 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; } @@ -144,7 +144,7 @@ class FormattingUtil { } $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); } } diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php index 961d9f26cb..401d8b1f54 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php @@ -507,7 +507,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { 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; @@ -517,7 +517,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction { $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']; diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php index cad99c7697..22fe2ac8b9 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php @@ -146,9 +146,10 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction { */ 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; } @@ -158,7 +159,7 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction { 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; } diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php index 3a66af498d..0b51c78458 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php @@ -5,6 +5,7 @@ namespace Civi\Api4\Action\SearchDisplay; 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. @@ -119,10 +120,50 @@ class Run extends AbstractRunAction { $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; } } diff --git a/ext/search_kit/Civi/Api4/Result/SearchDisplayRunResult.php b/ext/search_kit/Civi/Api4/Result/SearchDisplayRunResult.php index d1c5fa37b4..4b27eda094 100644 --- a/ext/search_kit/Civi/Api4/Result/SearchDisplayRunResult.php +++ b/ext/search_kit/Civi/Api4/Result/SearchDisplayRunResult.php @@ -13,14 +13,21 @@ 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; + } diff --git a/ext/search_kit/Civi/Search/Display.php b/ext/search_kit/Civi/Search/Display.php index 4c2ba295a5..413a3c5d9a 100644 --- a/ext/search_kit/Civi/Search/Display.php +++ b/ext/search_kit/Civi/Search/Display.php @@ -69,7 +69,10 @@ class Display { } // 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 = [ @@ -78,7 +81,7 @@ class Display { '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 @@ -87,7 +90,11 @@ class Display { } $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; } diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js index 970a83a86d..0f55130611 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js @@ -665,7 +665,7 @@ // 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); } @@ -712,7 +712,8 @@ } } }); - 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() { diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js index 5fb2ced4af..1c49b0ecbe 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js @@ -248,7 +248,10 @@ 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) { @@ -289,25 +292,6 @@ }); }; - 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) { diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.component.js b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.component.js new file mode 100644 index 0000000000..0bacfb6b9a --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.component.js @@ -0,0 +1,43 @@ +(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._); diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.html b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.html new file mode 100644 index 0000000000..fa2c2f40a4 --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.html @@ -0,0 +1,11 @@ +
+
+
+ +
+
+ +
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/common/searchButtonConfig.html b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchButtonConfig.html index 268c3a1e08..a5271a9915 100644 --- a/ext/search_kit/ang/crmSearchAdmin/displays/common/searchButtonConfig.html +++ b/ext/search_kit/ang/crmSearchAdmin/displays/common/searchButtonConfig.html @@ -19,12 +19,3 @@ -
-
- -
-
- diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayGrid.html b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayGrid.html index fdc5eeed2c..a2a3cf6a11 100644 --- a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayGrid.html +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayGrid.html @@ -13,6 +13,7 @@ +
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayList.html b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayList.html index 3253059f54..fa385a3297 100644 --- a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayList.html +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayList.html @@ -17,6 +17,7 @@ +
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html index 737dbdd479..27e9f4da5d 100644 --- a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html @@ -31,6 +31,7 @@ +
diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php index e96b79ef3a..8a4dad8a3b 100644 --- a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php @@ -1904,6 +1904,80 @@ class SearchRunTest extends Api4TestBase implements TransactionalInterface { $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', [ -- 2.25.1