SearchKit - Show multiple 'create' links in toolbar
authorcolemanw <coleman@civicrm.org>
Fri, 22 Dec 2023 16:36:01 +0000 (11:36 -0500)
committercolemanw <coleman@civicrm.org>
Sun, 24 Dec 2023 00:51:26 +0000 (19:51 -0500)
CRM/Activity/Form/ActivityLinks.php
Civi/Api4/Action/GetLinks.php
Civi/Api4/Service/Links/ContactLinksProvider.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php
ext/search_kit/ang/crmSearchDisplay/toolbar.html
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
tests/phpunit/api/v4/Action/GetLinksTest.php

index c8b351556a02cfa34a781150eaf5945a0b8a94cf..754a0f95036c83792126f9e5fb1f72e8cae4765f 100644 (file)
@@ -35,6 +35,7 @@ class CRM_Activity_Form_ActivityLinks extends CRM_Core_Form {
     $activityLinks = \Civi\Api4\Activity::getLinks()
       ->addValue('target_contact_id', $contactId)
       ->addWhere('ui_action', '=', 'add')
+      ->setExpandMultiple(TRUE)
       ->execute();
     foreach ($activityLinks as $activityLink) {
       $activityTypes[] = [
index 9978ab4bdcd2e21e953eafc3dfe924136df7bda5..baaf6058f5d1f75f78037a7704979cf2f9af9f6d 100644 (file)
@@ -22,6 +22,10 @@ use Civi\Core\Event\GenericHookEvent;
  * Get action links for the $ENTITY entity.
  *
  * Action links are paths to forms for e.g. view, edit, or delete actions.
+ * @method string getEntityTitle()
+ * @method $this setEntityTitle(string $entityTitle)
+ * @method bool getExpandMultiple()
+ * @method $this setExpandMultiple(bool $expandMultiple)
  */
 class GetLinks extends BasicGetAction {
   use \Civi\Api4\Generic\Traits\GetSetValueTrait;
@@ -38,6 +42,12 @@ class GetLinks extends BasicGetAction {
    */
   protected $entityTitle = TRUE;
 
+  /**
+   * Should multiple links e.g. to create different subtypes all be returned?
+   * @var bool
+   */
+  protected bool $expandMultiple = FALSE;
+
   public function _run(Result $result) {
     parent::_run($result);
     // Do expensive processing *after* parent has filtered down the result per the WHERE clause
index 273eb3b17078ab949f0c0953ade761e5f516f20d..099430693a8d6b792bec0a4cb7fe0887423eadfe 100644 (file)
@@ -12,6 +12,7 @@
 
 namespace Civi\Api4\Service\Links;
 
+use Civi\API\Event\RespondEvent;
 use Civi\Api4\Utils\CoreUtil;
 use Civi\Core\Event\GenericHookEvent;
 
@@ -25,6 +26,7 @@ class ContactLinksProvider extends \Civi\Core\Service\AutoSubscriber {
   public static function getSubscribedEvents(): array {
     return [
       'civi.api4.getLinks' => 'alterContactLinks',
+      'civi.api.respond' => 'alterContactLinksResult',
     ];
   }
 
@@ -32,49 +34,74 @@ class ContactLinksProvider extends \Civi\Core\Service\AutoSubscriber {
     if (CoreUtil::isContact($e->entity)) {
       foreach ($e->links as $index => $link) {
         // Contacts are too cumbersome to view in a popup
-        if (in_array($link['ui_action'], ['view', 'update'], TRUE)) {
+        if (in_array($link['ui_action'], ['add', 'view', 'update'], TRUE)) {
           $e->links[$index]['target'] = '';
         }
       }
+    }
+  }
+
+  public static function alterContactLinksResult(RespondEvent $e): void {
+    $request = $e->getApiRequest();
+    if ($request['version'] == 4 && is_a($request, '\Civi\Api4\Action\GetLinks') && CoreUtil::isContact($request->getEntityName())) {
+      $links = (array) $e->getResponse();
+      $addLinkIndex = self::getActionIndex($links, 'add');
       // Unset the generic "add" link and replace it with links per contact-type and sub-type
-      $addLinkIndex = self::getActionIndex($e->links, 'add');
-      if ($addLinkIndex !== NULL) {
-        $addTemplate = $e->links[$addLinkIndex];
-        unset($e->links[$addLinkIndex]);
+      if (isset($addLinkIndex) && $request->getExpandMultiple()) {
+        // Use the single add link from the schema as a template
+        $addTemplate = $links[$addLinkIndex];
+        $newLinks = [];
+        $contactType = $request->getValue('contact_type') ?: $request->getEntityName();
         // For contact entity, add links for every contact type
-        if ($e->entity === 'Contact') {
-          foreach (\CRM_Contact_BAO_ContactType::basicTypes() as $contactType) {
-            self::addLinks($contactType, $addTemplate, $e);
+        if ($contactType === 'Contact') {
+          foreach (\CRM_Contact_BAO_ContactType::basicTypes() as $type) {
+            self::addLinks($newLinks, $type, $addTemplate, $request->getEntityName());
           }
         }
         // For Individual, Organization, Household entity
         else {
-          self::addLinks($e->entity, $addTemplate, $e);
+          self::addLinks($newLinks, $contactType, $addTemplate, $request->getEntityName());
         }
+        array_splice($links, $addLinkIndex, 1, $newLinks);
       }
+      $e->getResponse()->exchangeArray(array_values($links));
     }
   }
 
-  private static function addLinks(string $contactType, array $addTemplate, GenericHookEvent $e) {
-    $addTemplate['path'] = str_replace('[contact_type]', $contactType, $addTemplate['path']);
+  private static function addLinks(array &$newLinks, string $contactType, array $addTemplate, string $apiEntity) {
+    // Since this runs after api processing, not all fields may be returned,
+    // depending on the SELECT clause, so avoid undefined indexes.
+    if (!empty($addTemplate['path'])) {
+      $addTemplate['path'] = str_replace('[contact_type]', $contactType, $addTemplate['path']);
+    }
+    // Link to contact type
+    if ($apiEntity === 'Contact') {
+      $addTemplate['api_values'] = ['contact_type' => $contactType];
+    }
     $link = $addTemplate;
-    if ($e->entity === 'Contact') {
-      $link['text'] = str_replace('%1', CoreUtil::getInfoItem($contactType, 'title'), $link['text']);
-      $link['api_values'] = ['contact_type' => $contactType];
+    $link['text'] = ts('Add %1', [1 => \CRM_Contact_BAO_ContactType::getLabel($contactType)]);
+    if (array_key_exists('icon', $addTemplate)) {
       $link['icon'] = CoreUtil::getInfoItem($contactType, 'icon');
     }
-    $e->links[] = $link;
+    $newLinks[] = $link;
+    // Links to contact sub-types
     $subTypes = \CRM_Contact_BAO_ContactType::subTypeInfo($contactType);
     $labels = array_column($subTypes, 'label');
     array_multisort($labels, SORT_NATURAL, $subTypes);
     foreach ($subTypes as $subType) {
-      $addTemplate['weight']++;
+      if (isset($addTemplate['weight'])) {
+        $addTemplate['weight']++;
+      }
       $link = $addTemplate;
-      $link['path'] .= '&cst=' . $subType['name'];
-      $link['icon'] = $subType['icon'] ?? $link['icon'];
-      $link['text'] = str_replace('%1', $subType['label'], $link['text']);
-      $link['api_values'] = ['contact_sub_type' => $subType['name']];
-      $e->links[] = $link;
+      if (!empty($link['path'])) {
+        $link['path'] .= '&cst=' . $subType['name'];
+      }
+      if (array_key_exists('icon', $addTemplate)) {
+        $link['icon'] = $subType['icon'] ?? $link['icon'];
+      }
+      $link['text'] = ts('Add %1', [1 => $subType['label']]);
+      $link['api_values']['contact_sub_type'] = $subType['name'];
+      $newLinks[] = $link;
     }
   }
 
index 8dd1e9e126d6a10cb93a5a1157ec13ecd74cd94e..cb59603c59566b4db3f0dd9bc886fe0e55f37d72 100644 (file)
@@ -470,7 +470,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
   private function formatFieldLinks($column, $data, $value): array {
     $links = [];
     foreach ((array) $value as $index => $val) {
-      $link = $this->formatLink($column['link'], $data, $val, $index);
+      $link = $this->formatLink($column['link'], $data, FALSE, $val, $index);
       if ($link) {
         // Style rules get appled to each link
         if (!empty($column['cssRules'])) {
@@ -517,13 +517,13 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    *
    * @param array $link
    * @param array $data
+   * @param bool $allowMultiple
    * @param string|NULL $text
-   * @param $index
+   * @param int $index
    * @return array|null
    * @throws \CRM_Core_Exception
-   * @throws \Civi\API\Exception\NotImplementedException
    */
-  protected function formatLink(array $link, array $data, string $text = NULL, $index = 0): ?array {
+  protected function formatLink(array $link, array $data, bool $allowMultiple = FALSE, string $text = NULL, $index = 0): ?array {
     $useApi = (!empty($link['entity']) && !empty($link['action']));
     if (isset($index)) {
       foreach ($data as $key => $value) {
@@ -539,11 +539,15 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
       }
       // Hack to support links to relationships
       $linkEntity = ($link['entity'] === 'Relationship') ? 'RelationshipCache' : $link['entity'];
-      $apiInfo = civicrm_api4($linkEntity, 'getLinks', [
+      $apiInfo = (array) civicrm_api4($linkEntity, 'getLinks', [
         'checkPermissions' => $checkPermissions,
         'values' => $data,
+        'expandMultiple' => $allowMultiple,
         'where' => [['ui_action', '=', $link['action']]],
       ]);
+      if ($allowMultiple && count($apiInfo) > 1) {
+        return $this->formatMultiLink($link, $apiInfo, $data);
+      }
       $link['path'] = $apiInfo[0]['path'] ?? '';
     }
     elseif (!$this->checkLinkAccess($link, $data)) {
@@ -569,6 +573,28 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     });
   }
 
+  /**
+   * Returns an array of multiple links for use as a dropdown-select when API::getLinks returns > 1 record
+   */
+  private function formatMultiLink(array $link, array $apiInfo, array $data): array {
+    $dropdown = [
+      'text' => $this->replaceTokens($link['text'], $data, 'view') ?: $apiInfo[0]['text'],
+      'icon' => $link['icon'] ?: $link[0]['icon'],
+      'title' => $link['title'],
+      'style' => $link['style'],
+      'children' => [],
+    ];
+    foreach ($apiInfo as $child) {
+      $dropdown['children'][] = [
+        'text' => $child['text'],
+        'target' => $child['target'],
+        'icon' => $child['icon'],
+        'url' => $this->getUrl($child['path']),
+      ];
+    }
+    return $dropdown;
+  }
+
   /**
    * Check if a link should be visible to the user based on their permissions
    *
@@ -585,7 +611,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     if (empty($link['path']) && empty($link['task'])) {
       return FALSE;
     }
-    if ($link['entity'] && !empty($link['action']) && !in_array($link['action'], ['view', 'preview'], TRUE) && $this->getCheckPermissions()) {
+    if (!empty($link['entity']) && !empty($link['action']) && !in_array($link['action'], ['view', 'preview'], TRUE) && $this->getCheckPermissions()) {
       $actionName = $this->getPermittedLinkAction($link['entity'], $link['action']);
       if (!$actionName) {
         return FALSE;
@@ -1039,7 +1065,7 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
    * @param string $format view|raw|url
    * @return string
    */
-  private function replaceTokens($tokenExpr, $data, $format, $index = NULL) {
+  private function replaceTokens($tokenExpr, $data, $format) {
     foreach ($this->getTokens($tokenExpr ?? '') as $token) {
       $val = $data[$token] ?? NULL;
       if (isset($val) && $format === 'view') {
index 01d587fa22a79b8a60b03aaf899fadecd05a8635..79c5f7f416f4e2a4829c6618e568c4e3a3da2bcf 100644 (file)
@@ -162,7 +162,7 @@ class Run extends AbstractRunAction {
       if (!$this->checkLinkCondition($button, $data)) {
         continue;
       }
-      $button = $this->formatLink($button, $data);
+      $button = $this->formatLink($button, $data, TRUE);
       if ($button) {
         $toolbar[] = $button;
       }
index f839f267bd0e46a528dc2600c031f19f1a081aef..55b91e952f781cde449e4ec41a22d37607657fbe 100644 (file)
@@ -1,4 +1,19 @@
-<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>
+<div class="btn-group" ng-repeat="link in $ctrl.toolbar">
+  <a ng-if="link.url" 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>
+  <button ng-if="link.children" type="button" class="btn btn-{{:: link.style }} dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+    <i ng-if="link.icon" class="crm-i {{:: link.icon }}"></i>
+    {{:: link.text  }}
+    <span class="caret"></span>
+  </button>
+  <ul ng-if="link.children" class="dropdown-menu dropdown-menu-right">
+    <li ng-repeat="child in link.children">
+      <a ng-href="{{:: child.url }}" target="{{:: child.target }}">
+        <i ng-if="child.icon" class="crm-i {{:: child.icon }}"></i>
+        {{:: child.text }}
+      </a>
+    </li>
+  </ul>
+</div>
index 786caa43ef3e3d9ba53dc744ac1d1bfe5048213f..53d52ce09201e43f6bdbc1918584b131ac681f49 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/toolbar.html'" ng-if="$ctrl.toolbar"></div>
+    <div class="form-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 a4cfdb6459d4f814765b48287904ba93fa54e3e6..4d639d7cb8cf3b8828b80c2fe98d6ebfcf80b294 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/toolbar.html'" ng-if="$ctrl.toolbar"></div>
+    <div class="form-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 5ca040d1f6a04610f648ef45b2f3c2f216eab646..0c348ac3d15c22350327b1c3ef20f77837326cd5 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/toolbar.html'" ng-if="$ctrl.toolbar"></div>
+    <div class="form-group pull-right" ng-include="'~/crmSearchDisplay/toolbar.html'" ng-if="$ctrl.toolbar"></div>
   </div>
   <table class="{{:: $ctrl.settings.classes.join(' ') }}">
     <thead>
index b388174f9b312dda8f24cff386e92f78a27bda58..9814d53882231bf723be0c829e57538fe93c9342 100644 (file)
@@ -2055,11 +2055,12 @@ class SearchRunTest extends Api4TestBase implements TransactionalInterface {
 
     $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']);
+    $menu = $result->toolbar[0];
+    $this->assertEquals('Add Contact', $menu['text']);
+    $this->assertEquals('fa-plus', $menu['icon']);
+    $button = $menu['children'][0];
+    $this->assertEquals('fa-user', $button['icon']);
+    $this->assertEquals('Add Individual', $button['text']);
     $this->assertStringContainsString('=Individual', $button['url']);
 
     // Try with pseudoconstant (for proper test the label needs to be different from the name)
@@ -2068,9 +2069,14 @@ class SearchRunTest extends Api4TestBase implements TransactionalInterface {
       ->addWhere('name', '=', 'Organization')
       ->execute();
     $params['filters'] = ['contact_type:label' => 'Disorganization'];
+    // Use default label this time
+    unset($params['display']['settings']['toolbar'][0]['text']);
     $result = civicrm_api4('SearchDisplay', 'run', $params);
-    $button = $result->toolbar[0];
+    $menu = $result->toolbar[0];
+    $this->assertEquals('Add Disorganization', $menu['text']);
+    $button = $menu['children'][0];
     $this->assertStringContainsString('=Organization', $button['url']);
+    $this->assertEquals('Add Disorganization', $button['text']);
 
     // Test legacy 'addButton' setting
     $params['display']['settings']['toolbar'] = NULL;
index fe7355e0e513f4d175e3999e607aba0aeff7f695..e51e79b7e0ff45b2115f7570a8a77081b04b36b1 100644 (file)
@@ -34,6 +34,7 @@ class GetLinksTest extends Api4TestBase implements TransactionalInterface {
   public function testContactLinks(): void {
     $links = Contact::getLinks(FALSE)
       ->addWhere('api_action', '=', 'create')
+      ->setExpandMultiple(TRUE)
       ->execute()->indexBy('path');
     $this->assertEquals('Add Individual', $links['civicrm/contact/add?reset=1&ct=Individual']['text']);
     $this->assertEquals('fa-user', $links['civicrm/contact/add?reset=1&ct=Individual']['icon']);
@@ -68,6 +69,7 @@ class GetLinksTest extends Api4TestBase implements TransactionalInterface {
       ->execute();
     $links = Individual::getLinks(FALSE)
       ->addWhere('api_action', '=', 'create')
+      ->setExpandMultiple(TRUE)
       ->execute();
     $this->assertStringContainsString('ct=Individual', $links[0]['path']);
     $this->assertEquals('Add Individual', $links[0]['text']);