SearchKit - tabbed display for custom vs packaged searches
authorColeman Watts <coleman@civicrm.org>
Fri, 5 Nov 2021 19:49:28 +0000 (15:49 -0400)
committerColeman Watts <coleman@civicrm.org>
Fri, 5 Nov 2021 19:49:28 +0000 (15:49 -0400)
Splits the SearchKit admin UI in to 2 tabs - one for custom (locally-defined) searches,
and one for packaged searches. Adds a "revert" button for packaged searches.

CRM/Core/ManagedEntities.php
ext/search_kit/Civi/Api4/SearchDisplay.php
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchAdmin/searchListing/buttons.html
ext/search_kit/ang/crmSearchAdmin/searchListing/crmSearchAdminSearchListing.component.js
ext/search_kit/ang/crmSearchAdmin/searchListing/crmSearchAdminSearchListing.html

index a4d2096ae7ed116c6e6ffec0f2fcc4a3e331958a..9b54a26ff9ab5f1b3adf5287a25f7beddd6bd1cf 100644 (file)
@@ -143,6 +143,7 @@ class CRM_Core_ManagedEntities {
     $mgd = new \CRM_Core_DAO_Managed();
     $mgd->copyValues($params);
     $mgd->find(TRUE);
+    $this->loadDeclarations();
     $declarations = CRM_Utils_Array::findAll($this->declarations, [
       'module' => $mgd->module,
       'name' => $mgd->name,
index 8ab71220e81be8b397e212b999fa53291b0b63ca..58e37d483c32bf582fd455817cf411dfa02ce481 100644 (file)
@@ -11,7 +11,7 @@ namespace Civi\Api4;
  */
 class SearchDisplay extends Generic\DAOEntity {
 
-  use \Civi\Api4\Generic\Traits\ManagedEntity;
+  use Generic\Traits\ManagedEntity;
 
   /**
    * @param bool $checkPermissions
index 96ecf41db4ec536d985ce7461e2a65e30b4c7be1..01de7d95ae6c2e87642db87387b6c8d24b442bb2 100644 (file)
@@ -42,6 +42,8 @@ class Admin {
       'defaultDisplay' => SearchDisplay::getDefault(FALSE)->setSavedSearch(['id' => NULL])->execute()->first(),
       'afformEnabled' => $extensions->isActiveModule('afform'),
       'afformAdminEnabled' => $extensions->isActiveModule('afform_admin'),
+      // TODO: Add v4 API for Extensions
+      'modules' => array_column(civicrm_api3('Extension', 'get', ['status' => "installed"])['values'], 'label', 'key'),
       'tags' => Tag::get()
         ->addSelect('id', 'name', 'color', 'is_selectable', 'description')
         ->addWhere('used_for', 'CONTAINS', 'civicrm_saved_search')
index 29cf615ec786c0ed38869c92cbef228dd83b1349..61611498c34f76b4c0c5ac2c33da06116e3379b9 100644 (file)
@@ -7,6 +7,6 @@
 <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)">
-  {{:: ts('Delete') }}
+<a href class="btn btn-xs btn-{{ row.data['base_module:label'] ? 'warning' : 'danger' }}" ng-click="$ctrl.deleteOrRevert(row)">
+  {{ row.data['base_module:label'] ? ts('Revert') : ts('Delete') }}
 </a>
index 6509a165694af1908ceced680f17825d7bb65dd2..82d690bea9a837521150629d183e5b4df3490160 100644 (file)
       this.afformEnabled = CRM.crmSearchAdmin.afformEnabled;
       this.afformAdminEnabled = CRM.crmSearchAdmin.afformAdminEnabled;
       this.entitySelect = searchMeta.getPrimaryAndSecondaryEntitySelect();
+      this.modules = _.sortBy(_.transform((CRM.crmSearchAdmin.modules), function(modules, label, key) {
+        modules.push({text: label, id: key});
+      }, []), 'text');
+
+      this.filters = {has_base: false};
+      this.totals = {};
 
       this.apiEntity = 'SavedSearch';
       this.search = {
@@ -33,6 +39,8 @@
             'modified_id.display_name',
             'created_date',
             'modified_date',
+            'has_base',
+            'base_module:label',
             'DATE(created_date) AS date_created',
             'DATE(modified_date) AS date_modified',
             'GROUP_CONCAT(display.name ORDER BY display.id) AS display_name',
       this.$onInit = function() {
         buildDisplaySettings();
         this.initializeDisplay($scope, $());
+        // Keep tab counts up-to-date - put rowCount in current tab if there are no other filters
+        $scope.$watch('$ctrl.rowCount', function(val) {
+          if (typeof val === 'number' && angular.equals({has_base: true}, ctrl.filters)) {
+            ctrl.totals.has_base = val;
+          }
+          else if (typeof val === 'number' && angular.equals({has_base: false}, ctrl.filters)) {
+            ctrl.totals.no_base = val;
+          }
+        });
+        // Initialize count for inactive tab
+        var params = ctrl.getApiParams('row_count');
+        params.filters.has_base = true;
+        crmApi4('SearchDisplay', 'run', params).then(function(result) {
+          ctrl.totals.has_base = result.count;
+        });
+      };
+
+      // Change tabs and clear other filters
+      this.setHasBaseFilter = function(val) {
+        ctrl.filters = {has_base: val};
+        buildDisplaySettings();
       };
 
       this.onPostRun.push(function(result) {
         return encodeURI(angular.toJson(params));
       };
 
-      this.confirmDelete = function(search) {
-        function getConfirmationMsg() {
-          var msg = '<h4>' + _.escape(ts('Permanently delete this saved search?')) + '</h4>' +
+      this.deleteOrRevert = function(row) {
+        var search = row.data,
+          revert = !!search['base_module:label'];
+        function getMessage() {
+          var title = revert ? ts('Revert this search to its packaged settings?') : ts('Permanently delete this saved search?'),
+            msg = '<h4>' + _.escape(title) + '</h4>' +
             '<ul>';
-          if (search.data.display_label && search.data.display_label.length === 1) {
-            msg += '<li>' + _.escape(ts('Includes 1 display which will also be deleted.')) + '</li>';
-          } else if (search.data.display_label && search.data.display_label.length > 1) {
-            msg += '<li>' + _.escape(ts('Includes %1 displays which will also be deleted.', {1: search.data.display_label.length})) + '</li>';
-          }
-          _.each(search.data.groups, function(smartGroup) {
-            msg += '<li class="crm-error"><i class="crm-i fa-exclamation-triangle"></i> ' + _.escape(ts('Smart group "%1" will also be deleted.', {1: smartGroup})) + '</li>';
-          });
-          if (search.afform_count) {
-            _.each(ctrl.afforms[search.data.name], function(afform) {
-              msg += '<li class="crm-error"><i class="crm-i fa-exclamation-triangle"></i> ' + _.escape(ts('Form "%1" will also be deleted because it contains an embedded display from this search.', {1: afform.title})) + '</li>';
+          if (revert) {
+            if (search.display_label && search.display_label.length === 1) {
+              msg += '<li>' + _.escape(ts('Includes 1 display which will also be reverted.')) + '</li>';
+            } else if (search.display_label && search.display_label.length > 1) {
+              msg += '<li>' + _.escape(ts('Includes %1 displays which will also be reverted.', {1: search.display_label.length})) + '</li>';
+            }
+            _.each(search.groups, function(smartGroup) {
+              msg += '<li>' + _.escape(ts('Smart group "%1" will be reset to the packaged search criteria.', {1: smartGroup})) + '</li>';
+            });
+            if (row.afform_count) {
+              _.each(ctrl.afforms[search.name], function(afform) {
+                msg += '<li><i class="crm-i fa-list-alt"></i> ' + _.escape(ts('Form "%1" will be affected because it contains an embedded display from this search.', {1: afform.title})) + '</li>';
+              });
+            }
+          } else {
+            if (search.display_label && search.display_label.length === 1) {
+              msg += '<li>' + _.escape(ts('Includes 1 display which will also be deleted.')) + '</li>';
+            } else if (search.display_label && search.display_label.length > 1) {
+              msg += '<li>' + _.escape(ts('Includes %1 displays which will also be deleted.', {1: search.display_label.length})) + '</li>';
+            }
+            _.each(search.groups, function (smartGroup) {
+              msg += '<li class="crm-error"><i class="crm-i fa-exclamation-triangle"></i> ' + _.escape(ts('Smart group "%1" will also be deleted.', {1: smartGroup})) + '</li>';
             });
+            if (row.afform_count) {
+              _.each(ctrl.afforms[search.name], function (afform) {
+                msg += '<li class="crm-error"><i class="crm-i fa-exclamation-triangle"></i> ' + _.escape(ts('Form "%1" will also be deleted because it contains an embedded display from this search.', {1: afform.title})) + '</li>';
+              });
+            }
           }
           return msg + '</ul>';
         }
 
         var dialog = CRM.confirm({
-          title: ts('Delete %1', {1: search.data.label}),
-          message: getConfirmationMsg(),
+          title: revert ? ts('Revert %1', {1: search.label}) : ts('Delete %1', {1: search.label}),
+          message: getMessage(),
         }).on('crmConfirm:yes', function() {
           $scope.$apply(function() {
-            ctrl.deleteSearch(search);
+            return revert ? ctrl.revertSearch(search) : ctrl.deleteSearch(search);
           });
         }).block();
 
         ctrl.loadAfforms().then(function() {
-          dialog.html(getConfirmationMsg()).unblock();
+          dialog.html(getMessage()).unblock();
         });
       };
 
       this.deleteSearch = function(search) {
         crmStatus({start: ts('Deleting...'), success: ts('Search Deleted')},
-          crmApi4('SavedSearch', 'delete', {where: [['id', '=', search.data.id]]}).then(function() {
+          crmApi4('SavedSearch', 'delete', {where: [['id', '=', search.id]]}).then(function() {
+            ctrl.rowCount = null;
+            ctrl.runSearch();
+          })
+        );
+      };
+
+      this.revertSearch = function(search) {
+        crmStatus({start: ts('Reverting...'), success: ts('Search Reverted')},
+          crmApi4('SavedSearch', 'revert', {
+            where: [['id', '=', search.id]],
+            chain: {
+              revertDisplays: ['SearchDisplay', 'revert', {'where': [['saved_search_id', '=', '$id'], ['has_base', '=', true]]}],
+              deleteDisplays: ['SearchDisplay', 'delete', {'where': [['saved_search_id', '=', '$id'], ['has_base', '=', false]]}]
+            }
+          }).then(function() {
             ctrl.rowCount = null;
             ctrl.runSearch();
           })
                 type: 'include',
                 label: ts('Displays'),
                 path: '~/crmSearchAdmin/searchListing/displays.html'
-              },
-              searchMeta.fieldToColumn('GROUP_CONCAT(DISTINCT group.title) AS groups', {
-                label: ts('Smart Group')
-              }),
-              searchMeta.fieldToColumn('created_date', {
-                label: ts('Created'),
-                title: '[created_date]',
-                rewrite: ts('%1 by %2', {1: '[date_created]', 2: '[created_id.display_name]'})
-              }),
-              searchMeta.fieldToColumn('modified_date', {
-                label: ts('Last Modified'),
-                title: '[modified_date]',
-                rewrite: ts('%1 by %2', {1: '[date_modified]', 2: '[modified_id.display_name]'})
-              }),
-              {
-                type: 'include',
-                alignment: 'text-right',
-                path: '~/crmSearchAdmin/searchListing/buttons.html'
               }
             ]
           }
         };
         if (ctrl.afformEnabled) {
-          ctrl.display.settings.columns.splice(4, 0, {
+          ctrl.display.settings.columns.push({
             type: 'include',
             label: ts('Forms'),
             path: '~/crmSearchAdmin/searchListing/afforms.html'
           });
         }
+        ctrl.display.settings.columns.push(
+          searchMeta.fieldToColumn('GROUP_CONCAT(DISTINCT group.title) AS groups', {
+            label: ts('Smart Group')
+          })
+        );
+        if (ctrl.filters.has_base) {
+          ctrl.display.settings.columns.push(
+            searchMeta.fieldToColumn('base_module:label', {
+              label: ts('Package'),
+              title: '[base_module]',
+              empty_value: ts('Missing'),
+              cssRules: [
+                ['font-italic', 'base_module:label', 'IS EMPTY']
+              ]
+            })
+          );
+        } else {
+          ctrl.display.settings.columns.push(
+            searchMeta.fieldToColumn('created_date', {
+              label: ts('Created'),
+              title: '[created_date]',
+              rewrite: ts('%1 by %2', {1: '[date_created]', 2: '[created_id.display_name]'})
+            })
+          );
+        }
+        ctrl.display.settings.columns.push(
+          searchMeta.fieldToColumn('modified_date', {
+            label: ts('Last Modified'),
+            title: '[modified_date]',
+            rewrite: ts('%1 by %2', {1: '[date_modified]', 2: '[modified_id.display_name]'})
+          })
+        );
+        ctrl.display.settings.columns.push({
+          type: 'include',
+          alignment: 'text-right',
+          path: '~/crmSearchAdmin/searchListing/buttons.html'
+        });
         ctrl.settings = ctrl.display.settings;
       }
 
index 9bab8797707919d5f1a09badd2fec74acdbffbae..84e8358a4fefa55abd22533ac1444043e33a1a6a 100644 (file)
@@ -1,8 +1,29 @@
 <div id="bootstrap-theme" class="crm-search">
   <h1 crm-page-title>{{:: ts('Saved Searches') }}</h1>
+
+  <!-- Tabs based on the has_base filter -->
+  <ul class="nav nav-tabs">
+    <li role="presentation" ng-class="{active: !$ctrl.filters.has_base}">
+      <a href ng-click="$ctrl.setHasBaseFilter(false)"><i class="crm-i fa-search-plus"></i>
+        {{:: ts('Custom Searches') }}
+        <span class="badge">{{ $ctrl.totals.no_base }}</span>
+      </a>
+    </li>
+    <li role="presentation" ng-class="{active: $ctrl.filters.has_base}">
+      <a href ng-click="$ctrl.setHasBaseFilter(true)"><i class="crm-i fa-gift"></i>
+        {{:: ts('Packaged Searches') }}
+        <span class="badge">{{ $ctrl.totals.has_base }}</span>
+      </a>
+    </li>
+  </ul>
+
+  <!-- Other filters -->
   <div class="form-inline">
     <input class="form-control" type="search" ng-model="$ctrl.filters.label" placeholder="{{:: ts('Filter by label...') }}">
-    <input class="form-control" type="search" ng-model="$ctrl.filters['created_id.display_name,modified_id.display_name']" placeholder="{{:: ts('Filter by author...') }}">
+    <input class="form-control" type="search" ng-show="!$ctrl.filters.has_base" ng-model="$ctrl.filters['created_id.display_name,modified_id.display_name']" placeholder="{{:: ts('Filter by author...') }}">
+    <span ng-if="$ctrl.filters.has_base">
+      <input class="form-control" ng-model="$ctrl.filters.base_module" ng-list crm-ui-select="{multiple: true, data: $ctrl.modules, placeholder: ts('Filter by package...')}">
+    </span>
     <input class="form-control collapsible-optgroups" ng-model="$ctrl.filters.api_entity" ng-list crm-ui-select="{multiple: true, data: $ctrl.entitySelect, placeholder: ts('Filter by entity...')}">
     <span ng-if="$ctrl.getTags().results.length">
       <input class="form-control" ng-model="$ctrl.filters.tags" ng-list crm-ui-select="{multiple: true, data: $ctrl.getTags, placeholder: ts('Filter by tags...')}">