SearchKit - Expose default display to the UI
authorColeman Watts <coleman@civicrm.org>
Thu, 28 Oct 2021 13:52:25 +0000 (09:52 -0400)
committerColeman Watts <coleman@civicrm.org>
Sun, 31 Oct 2021 18:24:38 +0000 (14:24 -0400)
Adds a 'view' link to the searchKit admin listing, which links to the default display
Adds a 'view' dropdown to the compose search screen, with links to the default + all other displays
Updates Afform to work with default search displays

23 files changed:
Civi/Api4/Generic/AbstractGetAction.php
Civi/Api4/Generic/Traits/SelectParamTrait.php [new file with mode: 0644]
ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/afform/admin/ang/afAdmin/afAdminList.controller.js
ext/afform/admin/ang/afAdmin/afAdminList.html
ext/afform/admin/ang/afGuiEditor.js
ext/afform/admin/ang/afGuiEditor/afGuiEditor.component.js
ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiContainer.component.js
ext/afform/admin/ang/afGuiEditor/elements/afGuiSearchDisplay.component.js
ext/afform/core/Civi/Api4/Action/Afform/Get.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
ext/search_kit/Civi/Api4/Action/SearchDisplay/GetDefault.php
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.html
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.html
ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js
ext/search_kit/ang/crmSearchAdmin/searchListing/afforms.html
ext/search_kit/ang/crmSearchAdmin/searchListing/buttons.html
ext/search_kit/ang/crmSearchAdmin/searchListing/displays.html
ext/search_kit/ang/crmSearchPage.module.js
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchDisplayTest.php [new file with mode: 0644]

index 5fd927468302cec43c826dce4293cf47bbccdfe3..8dfff86d61b86da2f292952500831b825a6ad3f7 100644 (file)
 
 namespace Civi\Api4\Generic;
 
-use Civi\Api4\Utils\SelectUtil;
-
 /**
  * Base class for all `Get` api actions.
  *
  * @package Civi\Api4\Generic
- *
- * @method $this setSelect(array $selects) Set array of fields to be selected (wildcard * allowed)
- * @method array getSelect()
  */
 abstract class AbstractGetAction extends AbstractQueryAction {
 
-  /**
-   * Fields to return for each $ENTITY. Defaults to all fields `[*]`.
-   *
-   * Use the * wildcard by itself to select all available fields, or use it to match similarly-named fields.
-   * E.g. `is_*` will match fields named is_primary, is_active, etc.
-   *
-   * Set to `["row_count"]` to return only the number of $ENTITIES found.
-   *
-   * @var array
-   */
-  protected $select = [];
+  use Traits\SelectParamTrait;
 
   /**
    * Only return the number of found items.
@@ -65,37 +50,6 @@ abstract class AbstractGetAction extends AbstractQueryAction {
     }
   }
 
-  /**
-   * Adds all standard fields matched by the * wildcard
-   *
-   * Note: this function only deals with simple wildcard expressions.
-   * It ignores those containing special characters like dots or parentheses,
-   * they are handled separately in Api4SelectQuery.
-   *
-   * @throws \API_Exception
-   */
-  protected function expandSelectClauseWildcards() {
-    if (!$this->select) {
-      $this->select = ['*'];
-    }
-    // Get expressions containing wildcards but no dots or parentheses
-    $wildFields = array_filter($this->select, function($item) {
-      return strpos($item, '*') !== FALSE && strpos($item, '.') === FALSE && strpos($item, '(') === FALSE && strpos($item, ' ') === FALSE;
-    });
-    if ($wildFields) {
-      // Wildcards should not match "Extra" fields
-      $standardFields = array_filter(array_map(function($field) {
-        return $field['type'] === 'Extra' ? NULL : $field['name'];
-      }, $this->entityFields()));
-      foreach ($wildFields as $item) {
-        $pos = array_search($item, array_values($this->select));
-        $matches = SelectUtil::getMatchingFields($item, $standardFields);
-        array_splice($this->select, $pos, 1, $matches);
-      }
-    }
-    $this->select = array_unique($this->select);
-  }
-
   /**
    * Helper to parse the WHERE param for getRecords to perform simple pre-filtering.
    *
@@ -166,14 +120,4 @@ abstract class AbstractGetAction extends AbstractQueryAction {
     return FALSE;
   }
 
-  /**
-   * Add one or more fields to be selected (wildcard * allowed)
-   * @param string ...$fieldNames
-   * @return $this
-   */
-  public function addSelect(string ...$fieldNames) {
-    $this->select = array_merge($this->select, $fieldNames);
-    return $this;
-  }
-
 }
diff --git a/Civi/Api4/Generic/Traits/SelectParamTrait.php b/Civi/Api4/Generic/Traits/SelectParamTrait.php
new file mode 100644 (file)
index 0000000..7ecd154
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Generic\Traits;
+
+use Civi\Api4\Utils\SelectUtil;
+
+/**
+ * @method $this setSelect(array $selects) Set array of fields to be selected (wildcard * allowed)
+ * @method array getSelect()
+ * @package Civi\Api4\Generic
+ */
+trait SelectParamTrait {
+
+  /**
+   * Fields to return for each $ENTITY. Defaults to all fields `[*]`.
+   *
+   * Use the * wildcard by itself to select all available fields, or use it to match similarly-named fields.
+   * E.g. `is_*` will match fields named is_primary, is_active, etc.
+   *
+   * Set to `["row_count"]` to return only the number of $ENTITIES found.
+   *
+   * @var array
+   */
+  protected $select = [];
+
+  /**
+   * Add one or more fields to be selected (wildcard * allowed)
+   * @param string ...$fieldNames
+   * @return $this
+   */
+  public function addSelect(string ...$fieldNames) {
+    $this->select = array_merge($this->select, $fieldNames);
+    return $this;
+  }
+
+  /**
+   * Adds all standard fields matched by the * wildcard
+   *
+   * Note: this function only deals with simple wildcard expressions.
+   * It ignores those containing special characters like dots or parentheses,
+   * they are handled separately in Api4SelectQuery.
+   *
+   * @throws \API_Exception
+   */
+  protected function expandSelectClauseWildcards() {
+    if (!$this->select) {
+      $this->select = ['*'];
+    }
+    // Get expressions containing wildcards but no dots or parentheses
+    $wildFields = array_filter($this->select, function($item) {
+      return strpos($item, '*') !== FALSE && strpos($item, '.') === FALSE && strpos($item, '(') === FALSE && strpos($item, ' ') === FALSE;
+    });
+    if ($wildFields) {
+      // Wildcards should not match "Extra" fields
+      $standardFields = array_filter(array_map(function($field) {
+        return $field['type'] === 'Extra' ? NULL : $field['name'];
+      }, $this->entityFields()));
+      foreach ($wildFields as $item) {
+        $pos = array_search($item, array_values($this->select));
+        $matches = SelectUtil::getMatchingFields($item, $standardFields);
+        array_splice($this->select, $pos, 1, $matches);
+      }
+    }
+    $this->select = array_unique($this->select);
+  }
+
+}
index 46f4d3279d2380b9f54c4644b3807dac49a208f8..5c73354997c1d09a7574e440dcb6e8b761ad8ba8 100644 (file)
@@ -174,18 +174,25 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
         }
       }
       foreach ($displayTags as $displayTag) {
-        $display = \Civi\Api4\SearchDisplay::get(FALSE)
-          ->addWhere('name', '=', $displayTag['display-name'])
-          ->addWhere('saved_search.name', '=', $displayTag['search-name'])
-          ->addSelect('*', 'type:name', 'type:icon', 'saved_search.name', 'saved_search.api_entity', 'saved_search.api_params')
+        if (isset($displayTag['display-name']) && strlen($displayTag['display-name'])) {
+          $displayGet = \Civi\Api4\SearchDisplay::get(FALSE)
+            ->addWhere('name', '=', $displayTag['display-name'])
+            ->addWhere('saved_search_id.name', '=', $displayTag['search-name']);
+        }
+        else {
+          $displayGet = \Civi\Api4\SearchDisplay::getDefault(FALSE)
+            ->setSavedSearch($displayTag['search-name']);
+        }
+        $display = $displayGet
+          ->addSelect('*', 'type:name', 'type:icon', 'saved_search_id.name', 'saved_search_id.api_entity', 'saved_search_id.api_params')
           ->execute()->first();
-        $display['calc_fields'] = $this->getCalcFields($display['saved_search.api_entity'], $display['saved_search.api_params']);
+        $display['calc_fields'] = $this->getCalcFields($display['saved_search_id.api_entity'], $display['saved_search_id.api_params']);
         $info['search_displays'][] = $display;
         if ($newForm) {
           $info['definition']['layout'][0]['#children'][] = $displayTag + ['#tag' => $display['type:name']];
         }
-        $entities[] = $display['saved_search.api_entity'];
-        foreach ($display['saved_search.api_params']['join'] ?? [] as $join) {
+        $entities[] = $display['saved_search_id.api_entity'];
+        foreach ($display['saved_search_id.api_params']['join'] ?? [] as $join) {
           $entities[] = explode(' AS ', $join[0])[0];
         }
       }
index c48d9c55863d35a9632c293fdeaf894e6f6e5f77..abf0064c2e9134162ed69d315d500b33d20c315f 100644 (file)
       }
 
       if (ctrl.tab === 'search') {
-        crmApi4('SearchDisplay', 'get', {
-          select: ['name', 'label', 'type:icon', 'saved_search.name', 'saved_search.label']
-        }).then(function(searchDisplays) {
-          _.each(searchDisplays, function(searchDisplay) {
-            links.push({
-              url: '#create/search/' + searchDisplay['saved_search.name'] + '.' + searchDisplay.name,
-              label: searchDisplay['saved_search.label'] + ': ' + searchDisplay.label,
-              icon: searchDisplay['type:icon']
-            });
+        var searchNames = [];
+        // Non-aggregated query will return the same search multiple times - once per display
+        crmApi4('SavedSearch', 'get', {
+          select: ['name', 'label', 'display.name', 'display.label', 'display.type:icon'],
+          where: [['api_entity', 'IS NOT NULL'], ['api_params', 'IS NOT NULL']],
+          join: [['SearchDisplay AS display', 'LEFT', ['id', '=', 'display.saved_search_id']]],
+          orderBy: {'label':'ASC'}
+        }).then(function(searches) {
+          _.each(searches, function(search) {
+            // Add default display for each search (track searchNames in a var to just add once per search)
+            if (!_.includes(searchNames, search.name)) {
+              searchNames.push(search.name);
+              links.push({
+                url: '#create/search/' + search.name,
+                label: search.label + ': ' + ts('Search results table'),
+                icon: 'fa-table'
+              });
+            }
+            // If the search has no displays (other than the default) this will be empty
+            if (search['display.name']) {
+              links.push({
+                url: '#create/search/' + search.name + '.' + search['display.name'],
+                label: search.label + ': ' + search['display.label'],
+                icon: search['display.type:icon']
+              });
+            }
           });
-          $scope.types.search.options = _.sortBy(links, 'Label');
+          $scope.types.search.options = links;
         });
       }
     };
index a40a04e3888935d2de4821d78ba4b4b658869fd3..b6459ccb27e1c5ee0f507330b3df7d8d650a7f08 100644 (file)
@@ -75,7 +75,7 @@
       <td>{{:: afform.placement.join(', ') }}</td>
       <td class="text-right">
         <a ng-if="afform.type !== 'system'" href="#/edit/{{:: afform.name }}" class="btn btn-xs btn-primary">{{:: ts('Edit') }}</a>
-        <a ng-if="afform.type !== 'system'" href="#/clone/{{:: afform.name }}" class="btn btn-xs btn-primary">{{:: ts('Clone') }}</a>
+        <a ng-if="afform.type !== 'system'" href="#/clone/{{:: afform.name }}" class="btn btn-xs btn-secondary">{{:: ts('Clone') }}</a>
         <a href ng-if="afform.has_local" class="btn btn-xs btn-danger" crm-confirm="{type: afform.has_base ? 'revert' : 'delete', obj: afform}" on-yes="$ctrl.revert(afform)">
           {{ afform.has_base ? ts('Revert') : ts('Delete') }}
         </a>
index aca9acb706dc6f9411ee004ee57f8d025e975339..b21e5a4447fb89939f5c0593633df623aec81842 100644 (file)
             );
           }
           _.each(data.search_displays, function(display) {
-            CRM.afGuiEditor.searchDisplays[display['saved_search.name'] + '.' + display.name] = display;
+            CRM.afGuiEditor.searchDisplays[display['saved_search_id.name'] + (display.name ? '.' + display.name : '')] = display;
           });
         },
 
           return fields[fieldName] || fields[fieldName.substr(fieldName.indexOf('.') + 1)];
         },
 
+        getSearchDisplay: function(searchName, displayName) {
+          return CRM.afGuiEditor.searchDisplays[searchName + (displayName ? '.' + displayName : '')];
+        },
+
         // Recursively searches a collection and its children using _.filter
         // Returns an array of all matches, or an object if the indexBy param is used
         findRecursive: function findRecursive(collection, predicate, indexBy) {
index 6ed1d027bdbaa3657cfc0926b61414df4ab8ba2e..f2c4e9dcc2726f5e7501cd44b275fda47c53dea3 100644 (file)
       };
 
       function getSearchFilterOptions() {
-        var searchDisplay = editor.meta.searchDisplays[editor.searchDisplay['search-name'] + '.' + editor.searchDisplay['display-name']],
+        var searchDisplay = afGui.getSearchDisplay(editor.searchDisplay['search-name'], editor.searchDisplay['display-name']),
           entityCount = {},
           options = [];
 
-        addFields(searchDisplay['saved_search.api_entity'], '');
+        addFields(searchDisplay['saved_search_id.api_entity'], '');
 
-        _.each(searchDisplay['saved_search.api_params'].join, function(join) {
+        _.each(searchDisplay['saved_search_id.api_params'].join, function(join) {
           var joinInfo = join[0].split(' AS ');
           addFields(joinInfo[0], joinInfo[1] + '.');
         });
index 14937f6540dd921c133cf5675e6ed249df05dc60..d74672828408bfb629140d684d9af75b1ec90a81 100644 (file)
@@ -52,7 +52,7 @@
 
       function buildFieldList(search) {
         $scope.fieldList.length = 0;
-        var entity = afGui.getEntity(ctrl.display['saved_search.api_entity']),
+        var entity = afGui.getEntity(ctrl.display['saved_search_id.api_entity']),
           entityCount = {};
         entityCount[entity.entity] = 1;
         $scope.fieldList.push({
@@ -61,7 +61,7 @@
           fields: filterFields(entity.fields)
         });
 
-        _.each(ctrl.display['saved_search.api_params'].join, function(join) {
+        _.each(ctrl.display['saved_search_id.api_params'].join, function(join) {
           var joinInfo = join[0].split(' AS '),
             entity = afGui.getEntity(joinInfo[0]),
             alias = joinInfo[1];
index f38a8a1b4fcb80ae7cc34c6133a681fbaa5587d8..f076241ace2d5b0bb652743aae576101821fcbf1 100644 (file)
           prefix = _.includes(fieldName, '.') ? fieldName.split('.')[0] : null;
         _.each(afGui.meta.searchDisplays, function(searchDisplay) {
           if (prefix) {
-            _.each(searchDisplay['saved_search.api_params'].join, function(join) {
+            _.each(searchDisplay['saved_search_id.api_params'].join, function(join) {
               var joinInfo = join[0].split(' AS ');
               if (prefix === joinInfo[1]) {
                 entityType = joinInfo[0];
               }
             });
           }
-          if (!entityType && fieldName && afGui.getField(searchDisplay['saved_search.api_entity'], fieldName)) {
-            entityType = searchDisplay['saved_search.api_entity'];
+          if (!entityType && fieldName && afGui.getField(searchDisplay['saved_search_id.api_entity'], fieldName)) {
+            entityType = searchDisplay['saved_search_id.api_entity'];
           }
           if (entityType) {
             return false;
           }
         });
-        return entityType || _.map(afGui.meta.searchDisplays, 'saved_search.api_entity')[0];
+        return entityType || _.map(afGui.meta.searchDisplays, 'saved_search_id.api_entity')[0];
       };
 
     }
index c85212f81fd9343e22628db75aa6bfbb62f4a32d..0dc66702f1659507617ab0546afd54849f94a4e2 100644 (file)
@@ -12,7 +12,7 @@
         ctrl = this;
 
       this.$onInit = function() {
-        ctrl.display = afGui.meta.searchDisplays[ctrl.node['search-name'] + '.' + ctrl.node['display-name']];
+        ctrl.display = afGui.getSearchDisplay(ctrl.node['search-name'], ctrl.node['display-name']);
         ctrl.editUrl = CRM.url('civicrm/admin/search#/edit/' + ctrl.display.saved_search_id);
       };
 
index cd5ed834242ad48abd2885f97974aa53ef24a586..629083e655d51b9f4af2c6e517d8756d626d8d9e 100644 (file)
@@ -174,9 +174,10 @@ class Get extends \Civi\Api4\Generic\BasicGetAction {
     foreach ($tags[0] ?? [] as $tag) {
       $searchName = $displayName = [];
       preg_match('/search-name\\s*=\\s*[\'"]([^\'"]+)[\'"]/', $tag, $searchName);
+      // Note: display name will be blank when using the default (autogenerated) display
       preg_match('/display-name\\s*=\\s*[\'"]([^\'"]+)[\'"]/', $tag, $displayName);
-      if (!empty($searchName[1]) && !empty($displayName[1])) {
-        $searchDisplays[] = $searchName[1] . '.' . $displayName[1];
+      if (!empty($searchName[1])) {
+        $searchDisplays[] = $searchName[1] . (empty($displayName[1]) ? '' : '.' . $displayName[1]);
       }
     }
     return $searchDisplays;
index 309de3013f8b256735b0befd66273a1f6854eecd..d775985b64e164b516031d27548524f077403b0d 100644 (file)
@@ -85,9 +85,9 @@ abstract class AbstractRunAction extends \Civi\Api4\Generic\AbstractAction {
     }
     elseif (is_null($this->display)) {
       $this->display = SearchDisplay::getDefault(FALSE)
+        ->addSelect('*', 'type:name')
         ->setSavedSearch($this->savedSearch)
         ->execute()->first();
-      $this->display['type:name'] = 'crm-search-display-table';
     }
     // Displays with acl_bypass must be embedded on an afform which the user has access to
     if (
index 7618682a4c5a4abcd28c785c668edea5a2f9dcec..2e0ec091529934600a2489a52b794313ca3521f4 100644 (file)
@@ -20,6 +20,8 @@ use Civi\API\Exception\UnauthorizedException;
 class GetDefault extends \Civi\Api4\Generic\AbstractAction {
 
   use SavedSearchInspectorTrait;
+  use \Civi\Api4\Generic\Traits\ArrayQueryActionTrait;
+  use \Civi\Api4\Generic\Traits\SelectParamTrait;
 
   /**
    * Either the name of the savedSearch or an array containing the savedSearch definition (for preview mode)
@@ -43,6 +45,7 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
       throw new UnauthorizedException('Access denied');
     }
     $this->loadSavedSearch();
+    $this->expandSelectClauseWildcards();
     // Use label from saved search
     $label = $this->savedSearch['label'] ?? '';
     // Fall back on entity title as label
@@ -50,13 +53,16 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
       $label = CoreUtil::getInfoItem($this->savedSearch['api_entity'], 'title_plural');
     }
     $display = [
-      'saved_search_id' => $this->savedSearch['id'] ?? NULL,
+      'id' => NULL,
       'name' => NULL,
+      'saved_search_id' => $this->savedSearch['id'] ?? NULL,
       'label' => $label,
       'type' => 'table',
+      'type:label' => E::ts('Table'),
+      'type:name' => 'crm-search-display-table',
+      'type:icon' => 'fa-table',
       'acl_bypass' => FALSE,
       'settings' => [
-        'button' => E::ts('Search'),
         'actions' => TRUE,
         'limit' => \Civi::settings()->get('default_pager_size'),
         'classes' => ['table', 'table-striped'],
@@ -67,6 +73,10 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
         'columns' => [],
       ],
     ];
+    // Allow implicit-join-style selection of saved search fields
+    foreach ($this->savedSearch as $key => $val) {
+      $display['saved_search_id.' . $key] = $val;
+    }
     foreach ($this->getSelectClause() as $key => $clause) {
       $display['settings']['columns'][] = $this->configureColumn($clause, $key);
     }
@@ -79,7 +89,7 @@ class GetDefault extends \Civi\Api4\Generic\AbstractAction {
       'alignment' => 'text-right',
       'links' => $this->getLinksMenu(),
     ];
-    $result[] = $display;
+    $result->exchangeArray($this->selectArray([$display]));
   }
 
   /**
index d072d936113ba6031d5af12868e06ea979cfdd3b..235e3d63046b81e56b79407793dfdc2dc7d461a6 100644 (file)
@@ -16,6 +16,8 @@
       this.afformEnabled = CRM.crmSearchAdmin.afformEnabled;
       this.afformAdminEnabled = CRM.crmSearchAdmin.afformAdminEnabled;
       this.displayTypes = _.indexBy(CRM.crmSearchAdmin.displayTypes, 'id');
+      this.searchDisplayPath = CRM.url('civicrm/search');
+      this.afformPath = CRM.url('civicrm/admin/afform');
 
       $scope.controls = {tab: 'compose', joinType: 'LEFT'};
       $scope.joinTypes = [
           }
           if (display.trashed && afformLoad) {
             afformLoad.then(function() {
-              if (ctrl.afforms[display.name]) {
-                var msg = ctrl.afforms[display.name].length === 1 ?
-                  ts('Form "%1" will be deleted if the embedded display "%2" is deleted.', {1: ctrl.afforms[display.name][0].title, 2: display.label}) :
-                  ts('%1 forms will be deleted if the embedded display "%2" is deleted.', {1: ctrl.afforms[display.name].length, 2: display.label});
+              var displayForms = _.filter(ctrl.afforms, function(form) {
+                return _.includes(form.displays, ctrl.savedSearch.name + '.' + display.name);
+              });
+              if (displayForms.length) {
+                var msg = displayForms.length === 1 ?
+                  ts('Form "%1" will be deleted if the embedded display "%2" is deleted.', {1: displayForms[0].title, 2: display.label}) :
+                  ts('%1 forms will be deleted if the embedded display "%2" is deleted.', {1: displayForms.length, 2: display.label});
                 CRM.alert(msg, ts('Display embedded'), 'alert');
               }
             });
       };
 
       function loadAfforms() {
+        ctrl.afforms = null;
         if (ctrl.afformEnabled && ctrl.savedSearch.id) {
           var findDisplays = _.transform(ctrl.savedSearch.displays, function(findDisplays, display) {
             if (display.id && display.name) {
             select: ['name', 'title', 'search_displays'],
             where: [['OR', findDisplays]]
           }).then(function(afforms) {
-            ctrl.afforms = {};
+            ctrl.afforms = [];
             _.each(afforms, function(afform) {
-              _.each(_.uniq(afform.search_displays), function(searchNameDisplayName) {
-                var displayName = searchNameDisplayName.split('.')[1] || '';
-                ctrl.afforms[displayName] = ctrl.afforms[displayName] || [];
-                ctrl.afforms[displayName].push({
-                  title: afform.title,
-                  link: ctrl.afformAdminEnabled ? CRM.url('civicrm/admin/afform#/edit/' + afform.name) : '',
-                });
+              ctrl.afforms.push({
+                title: afform.title,
+                displays: afform.search_displays,
+                link: ctrl.afformAdminEnabled ? CRM.url('civicrm/admin/afform#/edit/' + afform.name) : '',
               });
             });
+            ctrl.afformCount = ctrl.afforms.length;
           });
         }
       }
 
-      // Creating an Afform opens a new tab, so when switching back to this tab, re-check for Afforms
+      // Creating an Afform opens a new tab, so when switching back after > 10 sec, re-check for Afforms
       $(window).on('focus', _.debounce(function() {
         $scope.$apply(loadAfforms);
       }, 10000, {leading: true, trailing: false}));
index ca6c2a2c4b5e8fb66df627abf75a5ef7396390ef..0f4695c235568232d3f1ab669e567c6ffc943338 100644 (file)
       <div class="crm-flex-4 form-inline">
         <label for="crm-search-main-entity">{{:: ts('Search for') }}</label>
         <input id="crm-search-main-entity" class="form-control huge collapsible-optgroups" ng-model="$ctrl.savedSearch.api_entity" crm-ui-select="::{allowClear: false, data: mainEntitySelect}" ng-disabled="$ctrl.savedSearch.id" />
-        <div class="btn-group btn-group-md pull-right">
-          <button type="button" class="btn" ng-class="{'btn-primary': status === 'unsaved', 'btn-warning': status === 'saving', 'btn-success': status === 'saved'}" ng-disabled="status !== 'unsaved'" ng-click="$ctrl.save()">
-            <i class="crm-i" ng-class="{'fa-check': status !== 'saving', 'fa-spin fa-spinner': status === 'saving'}"></i>
-            <span ng-if="status === 'saved'">{{:: ts('Saved') }}</span>
-            <span ng-if="status === 'unsaved'">{{:: ts('Save') }}</span>
-            <span ng-if="status === 'saving'">{{:: ts('Saving...') }}</span>
-          </button>
+
+        <div class="form-group pull-right">
+
+          <div class="btn-group" ng-if="$ctrl.afformEnabled && $ctrl.savedSearch.id">
+            <button type="button" ng-click="$ctrl.openAfformMenu = true;" class="btn dropdown-toggle btn-primary-outline" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+              <i class="crm-i fa-list-alt"></i>
+              {{ ($ctrl.afformCount !== undefined) ? ($ctrl.afformCount === 1 ? ts('1 Form') : ts('%1 Forms', {1: $ctrl.afformCount})) : ts('Forms...') }}
+              <span class="caret"></span>
+            </button>
+            <ul class="dropdown-menu dropdown-menu-right" ng-if=":: $ctrl.openAfformMenu">
+              <li ng-if=":: $ctrl.afformAdminEnabled">
+                <a target="_blank" href="{{:: $ctrl.afformPath + '#/create/search/' + $ctrl.savedSearch.name }}">
+                  <i class="fa fa-plus"></i> {{:: ts('Create form for search results table') }}
+                </a>
+              </li>
+              <li ng-repeat="display in $ctrl.savedSearch.displays" ng-if="$ctrl.afformAdminEnabled && display.id">
+                <a target="_blank" href="{{:: $ctrl.afformPath + '#/create/search/' + $ctrl.savedSearch.name + '.' + display.name }}">
+                  <i class="fa fa-plus"></i> {{:: ts('Create form for %1', {1: display.label}) }}
+                </a>
+              </li>
+              <li class="divider" role="separator" ng-if="$ctrl.afformAdminEnabled && $ctrl.afforms.length"></li>
+              <li ng-if="!$ctrl.afforms" class="disabled">
+                <a href>
+                  <i class="crm-i fa-spinner fa-spin"></i>
+                </a>
+              </li>
+              <li ng-repeat="afform in $ctrl.afforms" title="{{:: $ctrl.afformAdminEnabled ? ts('Edit form') : '' }}">
+                <a target="_blank" ng-href="{{:: afform.link }}">
+                  <i class="crm-i {{:: $ctrl.afformAdminEnabled ? 'fa-pencil-square-o' : 'fa-list-alt' }}"></i>
+                  {{:: afform.title }}
+                </a>
+              </li>
+            </ul>
+          </div>
+
+          <div class="btn-group" ng-if="$ctrl.savedSearch.id">
+            <a ng-href="{{ $ctrl.searchDisplayPath + '#/display/' + $ctrl.savedSearch.name }}" target="_blank" class="btn btn-primary-outline" title="{{:: ts('View search results table') }}">
+              <i class="crm-i fa-external-link"></i>
+              {{:: ts('View') }}
+            </a>
+            <button type="button" ng-click="$ctrl.openDisplayMenu = true;" class="btn btn-primary-outline dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+              <span class="caret"></span>
+            </button>
+            <ul class="dropdown-menu dropdown-menu-right" ng-if=":: $ctrl.openDisplayMenu">
+              <li title="{{:: ts('View search results table') }}">
+                <a ng-href="{{ $ctrl.searchDisplayPath + '#/display/' + $ctrl.savedSearch.name }}" target="_blank">
+                  <i class="crm-i fa-table"></i>
+                  {{:: ts('Search results table') }}
+                </a>
+              </li>
+              <li ng-repeat="display in $ctrl.savedSearch.displays" ng-if="display.id" ng-class="{disabled: display.acl_bypass}" title="{{:: display.acl_bypass ? ts('Display has permissions disabled') : ts('View display') }}">
+                <a ng-href="{{ display.acl_bypass ? '' : $ctrl.searchDisplayPath + '#/display/' + $ctrl.savedSearch.name + '/' + display.name }}" target="_blank">
+                  <i class="crm-i {{ display.acl_bypass ? 'fa-unlock' : $ctrl.displayTypes[display.type].icon }}"></i>
+                  {{ display.label }}
+                </a>
+              </li>
+            </ul>
+          </div>
+
+          <div class="btn-group">
+            <button type="button" class="btn" ng-class="{'btn-primary': status === 'unsaved', 'btn-warning': status === 'saving', 'btn-success': status === 'saved'}" ng-disabled="status !== 'unsaved'" ng-click="$ctrl.save()">
+              <i class="crm-i" ng-class="{'fa-check': status !== 'saving', 'fa-spin fa-spinner': status === 'saving'}"></i>
+              <span ng-if="status === 'saved'">{{:: ts('Saved') }}</span>
+              <span ng-if="status === 'unsaved'">{{:: ts('Save') }}</span>
+              <span ng-if="status === 'saving'">{{:: ts('Saving...') }}</span>
+            </button>
+          </div>
+
         </div>
+
       </div>
     </div>
     <div class="crm-flex-box">
index c7b2548e539353e2d9cebb2ba33a33e429a5639a..d45a4be1cfaeed256ace51b38d078e2931a1a59d 100644 (file)
@@ -37,7 +37,6 @@
         ctrl = this,
         afforms;
 
-      this.afformPath = CRM.url('civicrm/admin/afform');
       this.isSuperAdmin = CRM.checkPerm('all CiviCRM permissions and ACLs');
       this.aclBypassHelp = ts('Only users with "all CiviCRM permissions and ACLs" can disable permission checks.');
 
         }
       };
 
-      // @return {Array}
-      this.getAfforms = function() {
-        if (ctrl.display.name && ctrl.crmSearchAdmin.afforms) {
-          if (!afforms || (ctrl.crmSearchAdmin.afforms[ctrl.display.name] && afforms !== ctrl.crmSearchAdmin.afforms[ctrl.display.name])) {
-            afforms = ctrl.crmSearchAdmin.afforms[ctrl.display.name] || [];
-          }
-        }
-        return afforms;
-      };
-
       $scope.$watch('$ctrl.display.settings', function() {
         ctrl.stale = true;
       }, true);
index 17110c51dc486453b1b8f80aed0c0e6fe4668443..6393bf24b9740803f25a4a830b67d214d5faccce 100644 (file)
       <i class="crm-i fa-unlock"></i>
       {{:: ts('Anyone who can view this display will be able to see all results, regardless of their permission level.') }}
     </div>
-    <div class="btn-group pull-right" ng-if="$ctrl.crmSearchAdmin.afformEnabled && $ctrl.display.name">
-      <button type="button" ng-click="$ctrl.openAfformMenu = true;" class="btn dropdown-toggle btn-primary-outline" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-        <i class="crm-i fa-list-alt"></i>
-        {{ $ctrl.getAfforms() ? ($ctrl.getAfforms().length === 1 ? ts('1 Form') : ts('%1 Forms', {1: $ctrl.getAfforms().length})) : ts('Forms...') }}
-        <span class="caret"></span>
-      </button>
-      <ul class="dropdown-menu" ng-if=":: $ctrl.openAfformMenu">
-        <li ng-if=":: $ctrl.crmSearchAdmin.afformAdminEnabled">
-          <a target="_blank" href="{{:: $ctrl.afformPath + '#/create/search/' + $ctrl.crmSearchAdmin.savedSearch.name + '.' + $ctrl.display.name }}">
-            <i class="fa fa-plus"></i> {{:: ts('Create form for this display') }}
-          </a>
-        </li>
-        <li class="divider" role="separator" ng-if=":: $ctrl.crmSearchAdmin.afformAdminEnabled"></li>
-        <li ng-if="!$ctrl.getAfforms()" class="disabled">
-          <a href>
-            <i class="crm-i fa-spinner fa-spin"></i>
-          </a>
-        </li>
-        <li ng-repeat="afform in $ctrl.getAfforms()" title="{{:: $ctrl.crmSearchAdmin.afformAdminEnabled ? ts('Edit form') : '' }}">
-          <a target="_blank" ng-href="{{:: afform.link }}">
-            <i class="crm-i {{:: $ctrl.crmSearchAdmin.afformAdminEnabled ? 'fa-pencil-square-o' : 'fa-list-alt' }}"></i>
-            {{:: afform.title }}
-          </a>
-        </li>
-      </ul>
-    </div>
   </div>
 
 </fieldset>
index 5c0ee89aee756366ddcd0dc7359ab38fcca156da..0744b7aff048bf169940038cee3aceefa7c965b0 100644 (file)
@@ -18,6 +18,8 @@
       function buildSettings() {
         ctrl.apiEntity = ctrl.search.api_entity;
         ctrl.settings = _.cloneDeep(CRM.crmSearchAdmin.defaultDisplay.settings);
+        ctrl.settings.button = ts('Search');
+        // The default-display settings contain just one column (the last one, with the links menu)
         ctrl.settings.columns = _.transform(ctrl.search.api_params.select, function(columns, fieldExpr) {
           columns.push(searchMeta.fieldToColumn(fieldExpr, {label: true, sortable: true}));
         }).concat(ctrl.settings.columns);
index 211d50a69c91d0c8cdadf038898d00273776bb80..9912d36d5ce86d623e07135d541b8905c3713072 100644 (file)
@@ -1,9 +1,14 @@
-<div class="btn-group" ng-if=":: row.data.display_name">
+<div class="btn-group">
   <button type="button" ng-click="$ctrl.loadAfforms(); row.openAfformMenu = true;" class="btn btn-xs dropdown-toggle btn-primary-outline" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
     {{ $ctrl.afforms ? (row.afform_count === 1 ? ts('1 Form') : ts('%1 Forms', {1: row.afform_count})) : ts('Forms...') }}
     <span class="caret"></span>
   </button>
   <ul class="dropdown-menu" ng-if=":: row.openAfformMenu">
+    <li ng-if="::$ctrl.afformAdminEnabled">
+      <a target="_blank" href="{{:: $ctrl.afformPath + '#/create/search/' + row.data.name }}">
+        <i class="fa fa-plus"></i> {{:: ts('Create form for search results table') }}
+      </a>
+    </li>
     <li ng-repeat="display_name in row.data.display_name" ng-if="::$ctrl.afformAdminEnabled">
       <a target="_blank" href="{{:: $ctrl.afformPath + '#/create/search/' + row.data.name + '.' + display_name }}">
         <i class="fa fa-plus"></i> {{:: ts('Create form for %1', {1: row.data.display_label[$index]}) }}
index 1317ab41d22adf08438864300a494354bc1a1c96..29cf615ec786c0ed38869c92cbef228dd83b1349 100644 (file)
@@ -1,7 +1,10 @@
-<a class="btn btn-xs btn-default" href="#/edit/{{:: row.data.id }}" ng-if="row.permissionToEdit">
+<a class="btn btn-xs btn-default" title="{{:: ts('View search results table') }}" ng-href="{{:: $ctrl.searchDisplayPath + '#/display/' + row.data.name }}" target="_blank">
+  {{:: ts('View') }}
+</a>
+<a class="btn btn-xs btn-primary" href="#/edit/{{:: row.data.id }}" ng-if="row.permissionToEdit">
   {{:: ts('Edit') }}
 </a>
-<a class="btn btn-xs btn-default" href="#/create/{{:: row.data.api_entity + '?params=' + $ctrl.encode(row.data.api_params) }}">
+<a class="btn btn-xs btn-secondary" href="#/create/{{:: row.data.api_entity + '?params=' + $ctrl.encode(row.data.api_params) }}">
   {{:: ts('Clone') }}
 </a>
 <a href class="btn btn-xs btn-danger" ng-click="$ctrl.confirmDelete(row)">
index 3960174eecc098ecb2dea629427c344c7d44494d..b1c3b51b5cf94d55353bfb12cc82fd4aa1277837 100644 (file)
@@ -8,7 +8,7 @@
   <ul class="dropdown-menu" ng-if=":: row.openDisplayMenu">
     <li ng-repeat="display_name in row.data.display_name" ng-class="{disabled: row.data.display_acl_bypass[$index]}" title="{{:: row.data.display_acl_bypass[$index] ? ts('Display has permissions disabled') : ts('View display') }}">
       <a ng-href="{{:: row.data.display_acl_bypass[$index] ? '' : $ctrl.searchDisplayPath + '#/display/' + row.data.name + '/' + display_name }}" target="_blank">
-        <i class="fa {{:: row.display_icon.rw[$index] }}"></i>
+        <i class="crm-i {{:: row.data.display_icon[$index] }}"></i>
         {{:: row.data.display_label[$index] }}
       </a>
     </li>
index 064ca8bea43c6c93ae77614fe229b5054404eb04..bc144c4fc9fec842d6481e94e83c612a2c817623 100644 (file)
@@ -6,30 +6,42 @@
 
     .config(function($routeProvider) {
       // Load & render a SearchDisplay
-      $routeProvider.when('/display/:savedSearchName/:displayName', {
+      $routeProvider.when('/display/:savedSearchName/:displayName?', {
         controller: 'crmSearchPageDisplay',
         // Dynamic template generates the directive for each display type
         template: '<h1 crm-page-title>{{:: $ctrl.display.label }}</h1>\n' +
           '<div ng-include="\'~/crmSearchPage/displayType/\' + $ctrl.display.type + \'.html\'" id="bootstrap-theme"></div>',
         resolve: {
           // Load saved search display
-          display: function($route, crmApi4) {
+          info: function($route, crmApi4) {
             var params = $route.current.params;
-            return crmApi4('SearchDisplay', 'get', {
-              where: [['name', '=', params.displayName], ['saved_search_id.name', '=', params.savedSearchName]],
-              select: ['*', 'saved_search_id.api_entity', 'saved_search_id.name']
-            }, 0);
+            var apiCalls = {
+              search: ['SavedSearch', 'get', {
+                select: ['name', 'api_entity'],
+                where: [['name', '=', params.savedSearchName]]
+              }, 0]
+            };
+            if (params.displayName) {
+              apiCalls.display = ['SearchDisplay', 'get', {
+                where: [['name', '=', params.displayName], ['saved_search_id.name', '=', params.savedSearchName]],
+              }, 0];
+            } else {
+              apiCalls.display = ['SearchDisplay', 'getDefault', {
+                savedSearch: params.savedSearchName,
+              }, 0];
+            }
+            return crmApi4(apiCalls);
           }
         }
       });
     })
 
     // Controller for displaying a search
-    .controller('crmSearchPageDisplay', function($scope, $location, display) {
+    .controller('crmSearchPageDisplay', function($scope, $location, info) {
       var ctrl = $scope.$ctrl = this;
-      this.display = display;
-      this.searchName = display['saved_search_id.name'];
-      this.apiEntity = display['saved_search_id.api_entity'];
+      this.display = info.display;
+      this.searchName = info.search.name;
+      this.apiEntity = info.search.api_entity;
 
       $scope.$watch(function() {return $location.search();}, function(params) {
         ctrl.filters = params;
diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchDisplayTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchDisplayTest.php
new file mode 100644 (file)
index 0000000..c75d98f
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+namespace api\v4\SearchDisplay;
+
+use Civi\Api4\SearchDisplay;
+use Civi\Test\HeadlessInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class SearchDisplayTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, TransactionalInterface {
+
+  public function setUpHeadless() {
+    return \Civi\Test::headless()
+      ->installMe(__DIR__)
+      ->apply();
+  }
+
+  public function testGetDefault() {
+    $params = [
+      'api_entity' => 'Contact',
+      'api_params' => [
+        'version' => 4,
+        'select' => ['first_name', 'last_name', 'contact_sub_type:label', 'gender_id'],
+        'where' => [],
+      ],
+    ];
+    $display = SearchDisplay::getDefault(FALSE)
+      ->setSavedSearch($params)
+      ->addSelect('*', 'saved_search_id.api_entity', 'type:name')
+      ->execute()->single();
+
+    $this->assertCount(5, $display['settings']['columns']);
+    $this->assertEquals('Contacts', $display['label']);
+    $this->assertEquals('crm-search-display-table', $display['type:name']);
+    $this->assertEquals('Contact', $display['saved_search_id.api_entity']);
+  }
+
+}