SearchKit - Toolbar buttons for search displays
authorcolemanw <coleman@civicrm.org>
Thu, 14 Sep 2023 12:22:16 +0000 (08:22 -0400)
committercolemanw <coleman@civicrm.org>
Thu, 14 Sep 2023 13:37:17 +0000 (09:37 -0400)
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

21 files changed:
Civi/Api4/Utils/FormattingUtil.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php
ext/search_kit/Civi/Api4/Result/SearchDisplayRunResult.php
ext/search_kit/Civi/Search/Display.php
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.component.js [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/common/searchAdminToolbarConfig.html [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/common/searchButtonConfig.html
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayGrid.html
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayList.html
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html
ext/search_kit/ang/crmSearchDisplay/AddButton.html [deleted file]
ext/search_kit/ang/crmSearchDisplay/toolbar.html [new file with mode: 0644]
ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js
ext/search_kit/ang/crmSearchDisplayGrid/crmSearchDisplayGrid.html
ext/search_kit/ang/crmSearchDisplayList/crmSearchDisplayList.html
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTable.html
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php

index 0376bb044efb6d98649abdc83ed8332c8e0cacee..e05f3bd7f5bf821a0c9820c23e7e463927c2a7f5 100644 (file)
@@ -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);
     }
   }
index 961d9f26cb19ebf1445f5f0c98a144f31b760125..401d8b1f54920441342c640b26c80947575819c4 100644 (file)
@@ -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'];
index cad99c7697004b6523030b9df62fb85e9c40d953..22fe2ac8b91a51b81b89e3a3becf928f8470a2ee 100644 (file)
@@ -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;
           }
index 3a66af498d7ef38f8f04f3dd5291d30c50bba643..0b51c78458842c56806077134a90c8326d957bb8 100644 (file)
@@ -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;
   }
 
 }
index d1c5fa37b4434b62e6985a40e7eba0011420a468..4b27eda0949ee4ac103c2ef5ffb64bee6a232f9a 100644 (file)
 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;
+
 }
index 4c2ba295a5c40dcc9b73ab2104ae12aa357a463d..413a3c5d9aefe545af5781d8223e9c3d6347a847 100644 (file)
@@ -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;
   }
 
index 970a83a86d6078eef67db5e2271814a444a0d016..0f551306111944a9f266646eab7599cd7ffa90e2 100644 (file)
 
     // 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() {
index 5fb2ced4af9c6a4780a1320687c0d88de49bd40f..1c49b0ecbe3d1c0a01ac70bf7cd3b1f90948dc2e 100644 (file)
 
       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) {
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 (file)
index 0000000..0bacfb6
--- /dev/null
@@ -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 (file)
index 0000000..fa2c2f4
--- /dev/null
@@ -0,0 +1,11 @@
+<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>
index 268c3a1e08a7c6737f9e342c8a8501afa9eb8f7d..a5271a991584122e7164733ae1009f0d0fb30286 100644 (file)
     </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') }}">
index fdc5eeed2c4ec0ea7a75ae34f3326e29227299e2..a2a3cf6a11ef31347d04051273b39efe6ab48805 100644 (file)
@@ -13,6 +13,7 @@
   </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>
index 3253059f54098e7551bffb32bea8472b0f4d7389..fa385a3297a02483fd8433d4015d200cb8d47d35 100644 (file)
@@ -17,6 +17,7 @@
   </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>
index 737dbdd47973e1cfff2dc3e4100a42122b0132fc..27e9f4da5d952e52277f4f057190a0d80dcdd064 100644 (file)
@@ -31,6 +31,7 @@
     <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>
diff --git a/ext/search_kit/ang/crmSearchDisplay/AddButton.html b/ext/search_kit/ang/crmSearchDisplay/AddButton.html
deleted file mode 100644 (file)
index 0c77988..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<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>
diff --git a/ext/search_kit/ang/crmSearchDisplay/toolbar.html b/ext/search_kit/ang/crmSearchDisplay/toolbar.html
new file mode 100644 (file)
index 0000000..f839f26
--- /dev/null
@@ -0,0 +1,4 @@
+<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>
index dca944afacb727de3e99cc5c9206badd55128381..ec7eee2d192b1befe257af2d692a073002f262c8 100644 (file)
         };
       },
 
-      // 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);
index e1399d26c81388cf2a75a0b535336f0d0123dcb6..786caa43ef3e3d9ba53dc744ac1d1bfe5048213f 100644 (file)
@@ -3,7 +3,7 @@
   <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}}"
index 2924630ffc67d5ef6066f7c90c55acb414f9dd6a..a4cfdb6459d4f814765b48287904ba93fa54e3e6 100644 (file)
@@ -3,7 +3,7 @@
   <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>
index 8f9437ea41ecfc20e9eac1128a3d6dc7a8841890..81cfafe4a1ac611ddab6220b305b4488d8933e4f 100644 (file)
@@ -4,7 +4,7 @@
     <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>
index e96b79ef3a01908d9a2225d3c0d54cd340955a78..8a4dad8a3bb158f9504a2ccf0a168b9f66205c76 100644 (file)
@@ -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', [