SearchKit - Switch results table to use a search display
authorColeman Watts <coleman@civicrm.org>
Sat, 7 Aug 2021 15:42:12 +0000 (11:42 -0400)
committerColeman Watts <coleman@civicrm.org>
Mon, 9 Aug 2021 13:23:44 +0000 (09:23 -0400)
This greatly simplifies the SearchKit admin code by creating a
specialized searchDisplay (copied from crmSearchDisplayTable) which
eliminates all the code for the admin screen to fetch, format and display results.

16 files changed:
ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php
ext/search_kit/ang/crmSearchAdmin.module.js
ext/search_kit/ang/crmSearchAdmin/compose.html [moved from ext/search_kit/ang/crmSearchAdmin/compose/criteria.html with 100% similarity]
ext/search_kit/ang/crmSearchAdmin/compose/controls.html [deleted file]
ext/search_kit/ang/crmSearchAdmin/compose/debug.html [deleted file]
ext/search_kit/ang/crmSearchAdmin/compose/pager.html [deleted file]
ext/search_kit/ang/crmSearchAdmin/compose/results.html [deleted file]
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/resultsTable/crmSearchAdminResultsTable.component.js [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.html [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/resultsTable/debug.html [new file with mode: 0644]
ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js
ext/search_kit/ang/crmSearchTasks/traits/searchDisplayTasksTrait.service.js
ext/search_kit/css/crmSearchAdmin.css

index d5a93d5aacc5b103ec7e4ff3f3ddfc73754b5206..74bf8efae24c43cc8fb2171b62e4279c4d478b9d 100644 (file)
@@ -130,6 +130,7 @@ class Run extends \Civi\Api4\Generic\AbstractAction {
           $page = explode(':', $this->return)[1];
         }
         $limit = !empty($settings['pager']['expose_limit']) && $this->limit ? $this->limit : NULL;
+        $apiParams['debug'] = $this->debug;
         $apiParams['limit'] = $limit ?? $settings['limit'] ?? NULL;
         $apiParams['offset'] = $page ? $apiParams['limit'] * ($page - 1) : 0;
         $apiParams['orderBy'] = $this->getOrderByFromSort();
@@ -142,6 +143,7 @@ class Run extends \Civi\Api4\Generic\AbstractAction {
 
     $result->rowCount = $apiResult->rowCount;
     $result->exchangeArray($apiResult->getArrayCopy());
+    $result->debug = $apiResult->debug;
   }
 
   /**
index 312466d47f75b02a480340f7b4991565cccdd0f0..52bad76c33757e849f50050ff0eb530ab62b9714 100644 (file)
             deferred.resolve($(this).val());
           });
           return deferred.promise;
+        },
+        // Returns name of explicit or implicit join, for links
+        getJoinEntity: function(info) {
+          if (info.field.fk_entity || info.field.name !== info.field.fieldName) {
+            return info.prefix + (info.field.fk_entity ? info.field.name : info.field.name.substr(0, info.field.name.lastIndexOf('.')));
+          } else if (info.prefix) {
+            return info.prefix.replace('.', '');
+          }
+          return '';
         }
       };
     })
diff --git a/ext/search_kit/ang/crmSearchAdmin/compose/controls.html b/ext/search_kit/ang/crmSearchAdmin/compose/controls.html
deleted file mode 100644 (file)
index bd2c52c..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-<hr>
-<div class="form-inline">
-  <div class="btn-group" role="group">
-    <button type="button" class="btn btn-primary{{ $ctrl.autoSearch ? '-outline' : '' }}" ng-click="onClickSearch()" ng-disabled="loading || (!$ctrl.autoSearch && !$ctrl.stale)">
-      <i class="crm-i {{ loading ? 'fa-spin fa-spinner' : 'fa-search' }}"></i>
-      {{:: ts('Search') }}
-    </button>
-    <button type="button" class="btn crm-search-auto-toggle btn-primary{{ $ctrl.autoSearch ? '' : '-outline' }}" ng-click="onClickAuto()">
-      <i class="crm-i fa-toggle-{{ $ctrl.autoSearch ? 'on' : 'off' }}"></i>
-      {{:: ts('Auto') }}
-    </button>
-  </div>
-  <crm-search-tasks entity="$ctrl.savedSearch.api_entity" ids="$ctrl.selectedRows" refresh="$ctrl.refreshPage()"></crm-search-tasks>
-</div>
diff --git a/ext/search_kit/ang/crmSearchAdmin/compose/debug.html b/ext/search_kit/ang/crmSearchAdmin/compose/debug.html
deleted file mode 100644 (file)
index 4bb483d..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-<fieldset class="crm-collapsible collapsed">
-  <legend class="collapsible-title">{{:: ts('Query Info') }}</legend>
-  <div>
-    <pre ng-if="$ctrl.debug.timeIndex">{{ ts('Request took %1 seconds.', {1: $ctrl.debug.timeIndex}) }}</pre>
-    <div><strong>API:</strong></div>
-    <pre>{{ $ctrl.debug.params }}</pre>
-    <div><strong>SQL:</strong></div>
-    <pre ng-repeat="query in $ctrl.debug.sql">{{ query }}</pre>
-  </div>
-</fieldset>
diff --git a/ext/search_kit/ang/crmSearchAdmin/compose/pager.html b/ext/search_kit/ang/crmSearchAdmin/compose/pager.html
deleted file mode 100644 (file)
index 954911b..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<div class="crm-flex-box">
-  <div>
-    <div class="form-inline">
-      <label ng-if="$ctrl.rowCount === false"><i class="crm-i fa-spin fa-spinner"></i></label>
-      <label ng-if="$ctrl.rowCount === 1">
-        {{ $ctrl.selectedRows.length ? ts('%1 selected of 1 result', {1: $ctrl.selectedRows.length}) : ts('1 result') }}
-      </label>
-      <label ng-if="$ctrl.rowCount === 0 || $ctrl.rowCount > 1">
-        {{ $ctrl.selectedRows.length ? ts('%1 selected of %2 results', {1: $ctrl.selectedRows.length, 2: $ctrl.rowCount}) : ts('%1 results', {1: $ctrl.rowCount}) }}
-      </label>
-    </div>
-  </div>
-  <div class="text-center crm-flex-2">
-    <ul uib-pagination ng-if="$ctrl.rowCount && !$ctrl.stale"
-        class="pagination"
-        boundary-links="true"
-        total-items="$ctrl.rowCount"
-        ng-model="$ctrl.page"
-        ng-change="$ctrl.changePage()"
-        items-per-page="$ctrl.limit"
-        max-size="6"
-        force-ellipses="true"
-        previous-text="&lsaquo;"
-        next-text="&rsaquo;"
-        first-text="&laquo;"
-        last-text="&raquo;"
-    ></ul>
-  </div>
-  <div class="form-inline text-right">
-    <label for="crm-search-results-page-size" >
-      {{:: ts('Page Size') }}
-    </label>
-    <input class="form-control" id="crm-search-results-page-size" type="number" ng-model="$ctrl.limit" min="10" step="10" ng-change="onChangeLimit()">
-  </div>
-</div>
diff --git a/ext/search_kit/ang/crmSearchAdmin/compose/results.html b/ext/search_kit/ang/crmSearchAdmin/compose/results.html
deleted file mode 100644 (file)
index 1f7f053..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-<table>
-  <thead>
-    <tr ng-model="$ctrl.savedSearch.api_params.select" ui-sortable="sortableColumnOptions">
-      <th class="crm-search-result-select">
-        <input type="checkbox" ng-checked="$ctrl.allRowsSelected" ng-click="selectAllRows()" ng-disabled="!($ctrl.rowCount && loading === false && !loadingAllRows && $ctrl.results[$ctrl.page] && $ctrl.results[$ctrl.page][0].id)">
-      </th>
-      <th ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-click="setOrderBy(col, $event)" title="{{$index || !$ctrl.groupExists ? ts('Drag to reorder columns, click to sort results (shift-click to sort by multiple).') : ts('Column reserved for smart group.')}}">
-        <i class="crm-i {{ getOrderBy(col) }}"></i>
-        <span ng-class="{'crm-draggable': $index || !$ctrl.groupExists}">{{ $ctrl.getFieldLabel(col) }}</span>
-        <span ng-switch="$index || !$ctrl.groupExists ? 'sortable' : 'locked'">
-          <i ng-switch-when="locked" class="crm-i fa-lock" aria-hidden="true"></i>
-          <a href ng-switch-default class="crm-hover-button" title="{{:: ts('Clear') }}" ng-click="$ctrl.clearParam('select', $index)"><i class="crm-i fa-times" aria-hidden="true"></i></a>
-        </span>
-      </th>
-      <th class="form-inline">
-        <input class="form-control crm-action-menu fa-plus"
-               crm-ui-select="::{data: fieldsForSelect, placeholder: ts('Add'), width: '80px', containerCss: {minWidth: '80px'}, dropdownCss: {width: '300px'}}"
-               on-crm-ui-select="$ctrl.addParam('select', selection)" >
-      </th>
-    </tr>
-  </thead>
-  <tbody>
-    <tr ng-repeat="row in $ctrl.results[$ctrl.page]">
-      <td>
-        <input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(loading === false && !loadingAllRows && row.id)">
-      </td>
-      <td ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-bind-html="formatResult(row, col)"></td>
-      <td></td>
-    </tr>
-  </tbody>
-</table>
-<div class="messages warning no-popup" ng-if="error">
-  <h4>{{:: ts('An error occurred') }}</h4>
-  <p>{{ error }}</p>
-</div>
index 532726c2a25149393899de63ef319e27a17a9a9d..86e9fa816ab49a88654e9f6dd3d8c4027a058962 100644 (file)
 
       this.DEFAULT_AGGREGATE_FN = 'GROUP_CONCAT';
 
-      this.selectedRows = [];
-      this.limit = CRM.crmSearchAdmin.defaultPagerSize;
-      this.page = 1;
       this.displayTypes = _.indexBy(CRM.crmSearchAdmin.displayTypes, 'id');
-      // After a search this.results is an object of result arrays keyed by page,
-      // Initially this.results is an empty string because 1: it's falsey (unlike an empty object) and 2: it doesn't throw an error if you try to access undefined properties (unlike null)
-      this.results = '';
-      this.rowCount = false;
-      this.allRowsSelected = false;
-      // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed?
-      this.stale = true;
 
       $scope.controls = {tab: 'compose', joinType: 'LEFT'};
       $scope.joinTypes = [
 
         $scope.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect);
 
-        $scope.$watch('$ctrl.savedSearch.api_params.where', onChangeFilters, true);
-
         if (this.paramExists('groupBy')) {
           this.savedSearch.api_params.groupBy = this.savedSearch.api_params.groupBy || [];
-          $scope.$watchCollection('$ctrl.savedSearch.api_params.groupBy', onChangeFilters);
         }
 
         if (this.paramExists('join')) {
           this.savedSearch.api_params.join = this.savedSearch.api_params.join || [];
-          $scope.$watch('$ctrl.savedSearch.api_params.join', onChangeFilters, true);
         }
 
         if (this.paramExists('having')) {
           this.savedSearch.api_params.having = this.savedSearch.api_params.having || [];
-          $scope.$watch('$ctrl.savedSearch.api_params.having', onChangeFilters, true);
         }
 
         $scope.$watch('$ctrl.savedSearch', onChangeAnything, true);
         return !errors.length;
       }
 
-      /**
-       * Called when clicking on a column header
-       * @param col
-       * @param $event
-       */
-      $scope.setOrderBy = function(col, $event) {
-        col = _.last(col.split(' AS '));
-        var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC';
-        if (!$event.shiftKey || !ctrl.savedSearch.api_params.orderBy) {
-          ctrl.savedSearch.api_params.orderBy = {};
-        }
-        ctrl.savedSearch.api_params.orderBy[col] = dir;
-        if (ctrl.results) {
-          ctrl.refreshPage();
-        }
-      };
-
-      /**
-       * Returns crm-i icon class for a sortable column
-       * @param col
-       * @returns {string}
-       */
-      $scope.getOrderBy = function(col) {
-        col = _.last(col.split(' AS '));
-        var dir = ctrl.savedSearch.api_params.orderBy && ctrl.savedSearch.api_params.orderBy[col];
-        if (dir) {
-          return 'fa-sort-' + dir.toLowerCase();
-        }
-        return 'fa-sort disabled';
-      };
-
       this.addParam = function(name, value) {
         if (value && !_.contains(ctrl.savedSearch.api_params[name], value)) {
           ctrl.savedSearch.api_params[name].push(value);
         ctrl.savedSearch.api_params[name].splice(idx, 1);
       };
 
-      // Prevent visual jumps in results table height during loading
-      function lockTableHeight() {
-        var $table = $('.crm-search-results', $element);
-        $table.css('height', $table.height());
-      }
-
-      function unlockTableHeight() {
-        $('.crm-search-results', $element).css('height', '');
-      }
-
-      // Debounced callback for loadResults
-      function _loadResultsCallback() {
-        // Multiply limit to read 2 pages at once & save ajax requests
-        var params = _.merge(_.cloneDeep(ctrl.savedSearch.api_params), {debug: true, limit: ctrl.limit * 2});
-        // Select the join field of implicitly joined entities (helps with displaying links)
-        _.each(params.select, function(fieldName) {
-          if (_.includes(fieldName, '.') && !_.includes(fieldName, ' AS ')) {
-            var info = searchMeta.parseExpr(fieldName);
-            if (info.field && !info.suffix && !info.fn && (info.field.name !== info.field.fieldName)) {
-              var idField = fieldName.substr(0, fieldName.lastIndexOf('.'));
-              if (!_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
-                params.select.push(idField);
-              }
-            }
-          }
-        });
-        // Select primary key of explicitly joined entities (helps with displaying links)
-        _.each(params.join, function(join) {
-          var entity = join[0].split(' AS ')[0],
-            alias = join[0].split(' AS ')[1],
-            primaryKeys = searchMeta.getEntity(entity).primary_key,
-            idField = alias + '.' + primaryKeys[0];
-          if (primaryKeys.length && !_.includes(params.select, idField) && !ctrl.canAggregate(idField)) {
-            params.select.push(idField);
-          }
-        });
-        lockTableHeight();
-        $scope.error = false;
-        if (ctrl.stale) {
-          ctrl.page = 1;
-          ctrl.rowCount = false;
-        }
-        params.offset = ctrl.limit * (ctrl.page - 1);
-        crmApi4(ctrl.savedSearch.api_entity, 'get', params).then(function(success) {
-          if (ctrl.stale) {
-            ctrl.results = {};
-            // Get row count for pager
-            if (success.length < params.limit) {
-              ctrl.rowCount = success.count;
-            } else {
-              var countParams = _.cloneDeep(params);
-              // Select is only needed needed by HAVING
-              countParams.select = countParams.having && countParams.having.length ? countParams.select : [];
-              countParams.select.push('row_count');
-              delete countParams.debug;
-              crmApi4(ctrl.savedSearch.api_entity, 'get', countParams).then(function(result) {
-                ctrl.rowCount = result.count;
-              });
-            }
-          }
-          ctrl.debug = success.debug;
-          // populate this page & the next
-          ctrl.results[ctrl.page] = success.slice(0, ctrl.limit);
-          if (success.length > ctrl.limit) {
-            ctrl.results[ctrl.page + 1] = success.slice(ctrl.limit);
-          }
-          $scope.loading = false;
-          ctrl.stale = false;
-          unlockTableHeight();
-        }, function(error) {
-          $scope.loading = false;
-          ctrl.results = {};
-          ctrl.stale = true;
-          ctrl.debug = error.debug;
-          $scope.error = errorMsg(error);
-        })
-          .finally(function() {
-            if (ctrl.debug) {
-              ctrl.debug.params = JSON.stringify(params, null, 2);
-              if (ctrl.debug.timeIndex) {
-                ctrl.debug.timeIndex = Number.parseFloat(ctrl.debug.timeIndex).toPrecision(2);
-              }
-            }
-          });
-      }
-
-      var _loadResults = _.debounce(_loadResultsCallback, 250);
-
-      function loadResults() {
-        $scope.loading = true;
-        _loadResults();
-      }
-
-      // What to tell the user when search returns an error from the server
-      // Todo: parse error codes and give helpful feedback.
-      function errorMsg(error) {
-        return ts('Ensure all search critera are set correctly and try again.');
-      }
-
-      this.changePage = function() {
-        if (ctrl.stale || !ctrl.results[ctrl.page]) {
-          lockTableHeight();
-          loadResults();
-        }
-      };
-
-      this.refreshAll = function() {
-        ctrl.stale = true;
-        clearSelection();
-        loadResults();
-      };
-
-      // Refresh results while staying on current page.
-      this.refreshPage = function() {
-        lockTableHeight();
-        ctrl.results = {};
-        loadResults();
-      };
-
-      $scope.onClickSearch = function() {
-        if (ctrl.autoSearch) {
-          ctrl.autoSearch = false;
-        } else {
-          ctrl.refreshAll();
-        }
-      };
-
-      $scope.onClickAuto = function() {
-        ctrl.autoSearch = !ctrl.autoSearch;
-        if (ctrl.autoSearch && ctrl.stale) {
-          ctrl.refreshAll();
-        }
-        $('.crm-search-auto-toggle').blur();
-      };
-
-      $scope.onChangeLimit = function() {
-        // Refresh only if search has already been run
-        if (ctrl.autoSearch || ctrl.results) {
-          ctrl.refreshAll();
-        }
-      };
-
       function onChangeSelect(newSelect, oldSelect) {
         // When removing a column from SELECT, also remove from ORDER BY & HAVING
         _.each(_.difference(oldSelect, newSelect), function(col) {
             return clauseUsesFields(clause, [col]);
           });
         });
-        // Re-arranging or removing columns doesn't merit a refresh, only adding columns does
-        if (!oldSelect || _.difference(newSelect, oldSelect).length) {
-          if (ctrl.autoSearch) {
-            ctrl.refreshPage();
-          } else {
-            ctrl.stale = true;
-          }
-        }
-      }
-
-      function onChangeFilters() {
-        ctrl.stale = true;
-        clearSelection();
-        if (ctrl.autoSearch) {
-          ctrl.refreshAll();
-        }
-      }
-
-      function clearSelection() {
-        ctrl.allRowsSelected = false;
-        ctrl.selectedRows.length = 0;
       }
 
-      $scope.selectAllRows = function() {
-        // Deselect all
-        if (ctrl.allRowsSelected) {
-          clearSelection();
-          return;
-        }
-        // Select all
-        ctrl.allRowsSelected = true;
-        if (ctrl.page === 1 && ctrl.results[1].length < ctrl.limit) {
-          ctrl.selectedRows = _.pluck(ctrl.results[1], 'id');
-          return;
-        }
-        // If more than one page of results, use ajax to fetch all ids
-        $scope.loadingAllRows = true;
-        var params = _.cloneDeep(ctrl.savedSearch.api_params);
-        // Select is only needed needed by HAVING
-        params.select = params.having && params.having.length ? params.select : [];
-        params.select.push('id');
-        crmApi4(ctrl.savedSearch.api_entity, 'get', params, ['id']).then(function(ids) {
-          $scope.loadingAllRows = false;
-          ctrl.selectedRows = _.toArray(ids);
-        });
-      };
-
-      $scope.selectRow = function(row) {
-        var index = ctrl.selectedRows.indexOf(row.id);
-        if (index < 0) {
-          ctrl.selectedRows.push(row.id);
-          ctrl.allRowsSelected = (ctrl.rowCount === ctrl.selectedRows.length);
-        } else {
-          ctrl.allRowsSelected = false;
-          ctrl.selectedRows.splice(index, 1);
-        }
-      };
-
-      $scope.isRowSelected = function(row) {
-        return ctrl.allRowsSelected || _.includes(ctrl.selectedRows, row.id);
-      };
-
       this.getFieldLabel = searchMeta.getDefaultLabel;
 
       // Is a column eligible to use an aggregate function?
         return ctrl.savedSearch.api_params.groupBy.indexOf(info.prefix + idField) < 0;
       };
 
-      $scope.formatResult = function(row, col) {
-        var info = searchMeta.parseExpr(col),
-          value = row[info.alias];
-        return formatFieldValue(row, info, value);
-      };
-
-      // Attempts to construct a view url for a given entity
-      function getEntityUrl(row, info, index) {
-        var entity = searchMeta.getEntity(info.field.entity),
-          path = _.result(_.findWhere(entity.paths, {action: 'view'}), 'path');
-        // Only proceed if the path metadata exists for this entity
-        if (path) {
-          // Replace tokens in the path (e.g. [id])
-          var tokens = path.match(/\[\w*]/g) || [],
-            prefix = info.prefix;
-          var replacements = _.transform(tokens, function(replacements, token) {
-            var fieldName = token.slice(1, token.length - 1);
-            // For implicit join fields
-            if (fieldName === 'id' && info.field.name !== info.field.fieldName) {
-              fieldName = info.field.name.substr(0, info.field.name.lastIndexOf('.'));
-            }
-            var replacement = row[prefix + fieldName];
-            if (replacement) {
-              replacements.push(_.isArray(replacement) ? replacement[index] : replacement);
-            }
-          });
-          // Only proceed if the row contains all the necessary data to resolve tokens
-          if (tokens.length === replacements.length) {
-            _.each(tokens, function(token, key) {
-              path = path.replace(token, replacements[key]);
-            });
-            return {url: CRM.url(path), title: path.title};
-          }
-        }
-      }
-
-      function formatFieldValue(row, info, value, index) {
-        var type = (info.fn && info.fn.dataType) || info.field.data_type,
-          result = value,
-          link;
-        if (_.isArray(value)) {
-          return _.map(value, function(val, idx) {
-            return formatFieldValue(row, info, val, idx);
-          }).join(', ');
-        }
-        if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) {
-          result = CRM.utils.formatDate(value, null, type === 'Timestamp');
-        }
-        else if (type === 'Boolean' && typeof value === 'boolean') {
-          result = value ? ts('Yes') : ts('No');
-        }
-        else if (type === 'Money' && typeof value === 'number') {
-          result = CRM.formatMoney(value);
-        }
-        // Output user-facing name/label fields as a link, if possible
-        if (info.field.fieldName === searchMeta.getEntity(info.field.entity).label_field && !info.fn) {
-          link = getEntityUrl(row, info, index || 0);
-        }
-        if (link) {
-          return '<a href="' + _.escape(link.url) + '" title="' + _.escape(link.title) + '">' + _.escape(result) + '</a>';
-        }
-        return _.escape(result);
-      }
-
       $scope.fieldsForGroupBy = function() {
         return {results: ctrl.getAllFields('', ['Field', 'Custom'], function(key) {
             return _.contains(ctrl.savedSearch.api_params.groupBy, key);
         };
       };
 
-      $scope.fieldsForSelect = function() {
-        return {results: ctrl.getAllFields(':label', ['Field', 'Custom', 'Extra'], function(key) {
-            return _.contains(ctrl.savedSearch.api_params.select, key);
-          })
-        };
-      };
-
       function getFieldsForJoin(joinEntity) {
         return {results: ctrl.getAllFields(':name', ['Field', 'Custom'], null, joinEntity)};
       }
         return {results: ctrl.getSelectFields()};
       };
 
-      $scope.sortableColumnOptions = {
-        axis: 'x',
-        handle: '.crm-draggable',
-        update: function(e, ui) {
-          // Don't allow items to be moved to position 0 if locked
-          if (!ui.item.sortable.dropindex && ctrl.groupExists) {
-            ui.item.sortable.cancel();
-          }
-        }
-      };
-
       // Sets the default select clause based on commonly-named fields
       function getDefaultSelect() {
         var entity = searchMeta.getEntity(ctrl.savedSearch.api_entity);
         }
       }
 
+      // Build a list of all possible links to main entity & join entities
+      this.buildLinks = function() {
+        function addTitle(link, entityName) {
+          switch (link.action) {
+            case 'view':
+              link.title = ts('View %1', {1: entityName});
+              break;
+
+            case 'update':
+              link.title = ts('Edit %1', {1: entityName});
+              break;
+
+            case 'delete':
+              link.title = ts('Delete %1', {1: entityName});
+              break;
+          }
+        }
+
+        // Links to main entity
+        var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
+          links = _.cloneDeep(mainEntity.paths || []);
+        _.each(links, function(link) {
+          link.join = '';
+          addTitle(link, mainEntity.title);
+        });
+        // Links to explicitly joined entities
+        _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
+          var join = searchMeta.getJoin(joinClause[0]),
+            joinEntity = searchMeta.getEntity(join.entity),
+            bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
+          _.each(joinEntity.paths, function(path) {
+            var link = _.cloneDeep(path);
+            link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
+            link.join = join.alias;
+            addTitle(link, join.label);
+            links.push(link);
+          });
+          _.each(bridgeEntity && bridgeEntity.paths, function(path) {
+            var link = _.cloneDeep(path);
+            link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
+            link.join = join.alias;
+            addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
+            links.push(link);
+          });
+        });
+        // Links to implicit joins
+        _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
+          if (!_.includes(fieldName, ' AS ')) {
+            var info = searchMeta.parseExpr(fieldName);
+            if (info.field && !info.suffix && !info.fn && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
+              var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
+                idField = searchMeta.parseExpr(idFieldName).field;
+              if (!ctrl.canAggregate(idFieldName)) {
+                var joinEntity = searchMeta.getEntity(idField.fk_entity),
+                  label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
+                _.each((joinEntity || {}).paths, function(path) {
+                  var link = _.cloneDeep(path);
+                  link.path = link.path.replace(/\[id/g, '[' + idFieldName);
+                  link.join = idFieldName;
+                  addTitle(link, label);
+                  links.push(link);
+                });
+              }
+            }
+          }
+        });
+        return _.uniq(links, 'path');
+      };
+
     }
   });
 
index db7c24aa5035ec37aa1f38cb7aa3cb498fbb30dc..a4867088092fad252131a8a39801278246813440 100644 (file)
       <ul class="nav nav-pills nav-stacked" ng-include="'~/crmSearchAdmin/tabs.html'"></ul>
       <div class="crm-flex-4" ng-switch="controls.tab">
         <div ng-switch-when="compose">
-          <div ng-include="'~/crmSearchAdmin/compose/criteria.html'"></div>
-          <div ng-include="'~/crmSearchAdmin/compose/controls.html'"></div>
-          <div ng-include="'~/crmSearchAdmin/compose/debug.html'" ng-if="$ctrl.debug"></div>
-          <div ng-include="'~/crmSearchAdmin/compose/results.html'" class="crm-search-results"></div>
-          <div ng-include="'~/crmSearchAdmin/compose/pager.html'" ng-if="$ctrl.results"></div>
+          <div ng-include="'~/crmSearchAdmin/compose.html'"></div>
+          <crm-search-admin-results-table search="$ctrl.savedSearch"></crm-search-admin-results-table>
         </div>
         <div ng-switch-when="group">
           <fieldset ng-include="'~/crmSearchAdmin/group.html'"></fieldset>
index 21b6469b10074bd3b4c073f5d4244f7d95c704a3..7ea85b38884adb5dee48af95ce3424f5fb85c8c1 100644 (file)
 
       this.getLinks = function(columnKey) {
         if (!ctrl.links) {
-          ctrl.links = {'*': buildLinks()};
+          ctrl.links = {'*': ctrl.crmSearchAdmin.buildLinks()};
         }
         if (!columnKey) {
           return ctrl.links['*'];
         }
         var expr = ctrl.getExprFromSelect(columnKey),
           info = searchMeta.parseExpr(expr),
-          joinEntity = '';
-        if (info.field.fk_entity || info.field.name !== info.field.fieldName) {
-          joinEntity = info.prefix + (info.field.fk_entity ? info.field.name : info.field.name.substr(0, info.field.name.lastIndexOf('.')));
-        } else if (info.prefix) {
-          joinEntity = info.prefix.replace('.', '');
-        }
+          joinEntity = searchMeta.getJoinEntity(info);
         if (!ctrl.links[joinEntity]) {
-          ctrl.links[joinEntity] = _.filter(ctrl.links['*'], function(link) {
-            return joinEntity === (link.join || '');
-          });
+          ctrl.links[joinEntity] = _.filter(ctrl.links['*'], {join: joinEntity});
         }
         return ctrl.links[joinEntity];
       };
 
-      // Build a list of all possible links to main entity or join entities
-      function buildLinks() {
-        function addTitle(link, entityName) {
-          switch (link.action) {
-            case 'view':
-              link.title = ts('View %1', {1: entityName});
-              break;
-
-            case 'update':
-              link.title = ts('Edit %1', {1: entityName});
-              break;
-
-            case 'delete':
-              link.title = ts('Delete %1', {1: entityName});
-              break;
-          }
-        }
-
-        // Links to main entity
-        var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
-          links = _.cloneDeep(mainEntity.paths || []);
-        _.each(links, function(link) {
-          addTitle(link, mainEntity.title);
-        });
-        // Links to explicitly joined entities
-        _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
-          var join = searchMeta.getJoin(joinClause[0]),
-            joinEntity = searchMeta.getEntity(join.entity),
-            bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
-          _.each(joinEntity.paths, function(path) {
-            var link = _.cloneDeep(path);
-            link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
-            link.join = join.alias;
-            addTitle(link, join.label);
-            links.push(link);
-          });
-          _.each(bridgeEntity && bridgeEntity.paths, function(path) {
-            var link = _.cloneDeep(path);
-            link.path = link.path.replace(/\[/g, '[' + join.alias + '.');
-            link.join = join.alias;
-            addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
-            links.push(link);
-          });
-        });
-        // Links to implicit joins
-        _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
-          if (!_.includes(fieldName, ' AS ')) {
-            var info = searchMeta.parseExpr(fieldName);
-            if (info.field && !info.suffix && !info.fn && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
-              var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
-                idField = searchMeta.parseExpr(idFieldName).field;
-              if (!ctrl.crmSearchAdmin.canAggregate(idFieldName)) {
-                var joinEntity = searchMeta.getEntity(idField.fk_entity),
-                  label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
-                _.each((joinEntity || {}).paths, function(path) {
-                  var link = _.cloneDeep(path);
-                  link.path = link.path.replace(/\[id/g, '[' + idFieldName);
-                  link.join = idFieldName;
-                  addTitle(link, label);
-                  links.push(link);
-                });
-              }
-            }
-          }
-        });
-        return _.uniq(links, 'path');
-      }
-
       this.pickIcon = function(model, key) {
         searchMeta.pickIcon().then(function(icon) {
           model[key] = icon;
diff --git a/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js b/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js
new file mode 100644 (file)
index 0000000..2bc0659
--- /dev/null
@@ -0,0 +1,108 @@
+(function(angular, $, _) {
+  "use strict";
+
+  // Specialized searchDisplay, only used by Admins
+  angular.module('crmSearchAdmin').component('crmSearchAdminResultsTable', {
+    bindings: {
+      search: '<'
+    },
+    require: {
+      crmSearchAdmin: '^crmSearchAdmin'
+    },
+    templateUrl: '~/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.html',
+    controller: function($scope, searchMeta, searchDisplayBaseTrait, searchDisplayTasksTrait, searchDisplaySortableTrait) {
+      var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
+        // Mix in traits to this controller
+        ctrl = angular.extend(this, searchDisplayBaseTrait, searchDisplayTasksTrait, searchDisplaySortableTrait);
+
+      // Output user-facing name/label fields as a link, if possible
+      function getViewLink(fieldExpr, links) {
+        var info = searchMeta.parseExpr(fieldExpr),
+          entity = searchMeta.getEntity(info.field.entity);
+        if (!info.fn && entity && info.field.fieldName === entity.label_field) {
+          var joinEntity = searchMeta.getJoinEntity(info);
+          return _.find(links, {join: joinEntity, action: 'view'});
+        }
+      }
+
+      function buildSettings() {
+        var links = ctrl.crmSearchAdmin.buildLinks();
+        ctrl.apiEntity = ctrl.search.api_entity;
+        ctrl.display = {
+          type: 'table',
+          settings: {
+            limit: CRM.crmSearchAdmin.defaultPagerSize,
+            pager: {show_count: true, expose_limit: true},
+            actions: true,
+            button: ts('Search'),
+            columns: _.transform(ctrl.search.api_params.select, function(columns, fieldExpr) {
+              var column = {label: true},
+                link = getViewLink(fieldExpr, links);
+              if (link) {
+                column.title = link.title;
+                column.link = {
+                  path: link.path,
+                  target: '_blank'
+                };
+              }
+              columns.push(searchMeta.fieldToColumn(fieldExpr, column));
+            })
+          }
+        };
+        ctrl.debug = {
+          apiParams: JSON.stringify(ctrl.search.api_params, null, 2)
+        };
+        ctrl.settings = ctrl.display.settings;
+      }
+
+      this.$onInit = function() {
+        buildSettings();
+        this.initializeDisplay($scope, $());
+        $scope.$watch('$ctrl.search.api_entity', buildSettings);
+        $scope.$watch('$ctrl.search.api_params', buildSettings, true);
+      };
+
+      // Refresh current page
+      this.refresh = function(row) {
+        ctrl.runSearch();
+      };
+
+      // Add callbacks for pre & post run
+      this.onPreRun.push(function(apiParams) {
+        apiParams.debug = true;
+      });
+
+      this.onPostRun.push(function(result) {
+        ctrl.debug = _.extend(_.pick(ctrl.debug, 'apiParams'), result.debug);
+      });
+
+      $scope.sortableColumnOptions = {
+        axis: 'x',
+        handle: '.crm-draggable',
+        update: function(e, ui) {
+          // Don't allow items to be moved to position 0 if locked
+          if (!ui.item.sortable.dropindex && ctrl.crmSearchAdmin.groupExists) {
+            ui.item.sortable.cancel();
+          }
+        }
+      };
+
+      $scope.fieldsForSelect = function() {
+        return {results: ctrl.crmSearchAdmin.getAllFields(':label', ['Field', 'Custom', 'Extra'], function(key) {
+            return _.contains(ctrl.search.api_params.select, key);
+          })
+        };
+      };
+
+      $scope.addColumn = function(col) {
+        ctrl.crmSearchAdmin.addParam('select', col);
+      };
+
+      $scope.removeColumn = function(index) {
+        ctrl.crmSearchAdmin.clearParam('select', index);
+      };
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.html b/ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.html
new file mode 100644 (file)
index 0000000..67ac27f
--- /dev/null
@@ -0,0 +1,31 @@
+<div class="crm-search-display crm-search-display-table">
+  <div ng-include="'~/crmSearchAdmin/resultsTable/debug.html'"></div>
+  <div class="form-inline">
+    <div class="btn-group" ng-include="'~/crmSearchDisplay/SearchButton.html'"></div>
+    <crm-search-tasks entity="$ctrl.apiEntity" ids="$ctrl.selectedRows" refresh="$ctrl.getResults()"></crm-search-tasks>
+  </div>
+  <table>
+    <thead>
+      <tr ng-model="$ctrl.search.api_params.select" ui-sortable="sortableColumnOptions">
+        <th class="crm-search-result-select" ng-if=":: $ctrl.settings.actions">
+          <input type="checkbox" ng-disabled="$ctrl.loading || !$ctrl.results.length" ng-checked="$ctrl.allRowsSelected" ng-click="$ctrl.selectAllRows()" >
+        </th>
+        <th ng-repeat="item in $ctrl.search.api_params.select" ng-click="$ctrl.setSort($ctrl.settings.columns[$index], $event)" title="{{$index || !$ctrl.crmSearchAdmin.groupExists ? ts('Drag to reorder columns, click to sort results (shift-click to sort by multiple).') : ts('Column reserved for smart group.')}}">
+          <i class="crm-i {{ $ctrl.getSort($ctrl.settings.columns[$index]) }}"></i>
+          <span ng-class="{'crm-draggable': $index || !$ctrl.crmSearchAdmin.groupExists}">{{ $ctrl.settings.columns[$index].label }}</span>
+          <span ng-switch="$index || !$ctrl.crmSearchAdmin.groupExists ? 'sortable' : 'locked'">
+            <i ng-switch-when="locked" class="crm-i fa-lock" aria-hidden="true"></i>
+            <a href ng-switch-default class="crm-hover-button" title="{{:: ts('Clear') }}" ng-click="removeColumn($index); $event.stopPropagation();"><i class="crm-i fa-times" aria-hidden="true"></i></a>
+          </span>
+        </th>
+        <th class="form-inline">
+          <input class="form-control crm-action-menu fa-plus"
+                 crm-ui-select="::{data: fieldsForSelect, placeholder: ts('Add'), width: '80px', containerCss: {minWidth: '80px'}, dropdownCss: {width: '300px'}}"
+                 on-crm-ui-select="addColumn(selection)" >
+        </th>
+      </tr>
+    </thead>
+    <tbody ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTableBody.html'"></tbody>
+  </table>
+  <div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
+</div>
diff --git a/ext/search_kit/ang/crmSearchAdmin/resultsTable/debug.html b/ext/search_kit/ang/crmSearchAdmin/resultsTable/debug.html
new file mode 100644 (file)
index 0000000..d435fae
--- /dev/null
@@ -0,0 +1,17 @@
+<fieldset id="crm-search-admin-debug">
+  <legend ng-click="$ctrl.showDebug = !$ctrl.showDebug">
+    <i class="crm-i fa-caret-{{ !$ctrl.showDebug ? 'right' : 'down' }}"></i>
+    {{:: ts('Query Info') }}
+  </legend>
+  <div ng-if="$ctrl.showDebug">
+    <pre ng-if="$ctrl.debug.timeIndex">{{ ts('Request took %1 seconds.', {1: $ctrl.debug.timeIndex}) }}</pre>
+    <div>
+      <strong>API:</strong>
+    </div>
+    <pre>{{ $ctrl.debug.apiParams }}</pre>
+    <div ng-if="$ctrl.debug.sql">
+      <strong>SQL:</strong>
+    </div>
+    <pre ng-repeat="query in $ctrl.debug.sql">{{ query }}</pre>
+  </div>
+</fieldset>
index 62f8cd0d90bd1ebce53a006209921eedfb9cc9bf..86900be0db7f409ed4d5bb5239f49893262f3722 100644 (file)
       page: 1,
       rowCount: null,
       getUrl: getUrl,
+      // Arrays may contain callback functions for various events
+      onChangeFilters: [],
+      onPreRun: [],
+      onPostRun: [],
 
       // Called by the controller's $onInit function
       initializeDisplay: function($scope, $element) {
         function onChangeFilters() {
           ctrl.page = 1;
           ctrl.rowCount = null;
-          if (ctrl.onChangeFilters) {
-            ctrl.onChangeFilters();
-          }
+          _.each(ctrl.onChangeFilters, function(callback) {
+            callback.call(ctrl);
+          });
           if (!ctrl.settings.button) {
             ctrl.getResults();
           }
         var ctrl = this,
           apiParams = this.getApiParams();
         this.loading = true;
+        _.each(ctrl.onPreRun, function(callback) {
+          callback.call(ctrl, apiParams);
+        });
         return crmApi4('SearchDisplay', 'run', apiParams).then(function(results) {
           ctrl.results = results;
           ctrl.editing = ctrl.loading = false;
               });
             }
           }
+          _.each(ctrl.onPostRun, function(callback) {
+            callback.call(ctrl, results, 'success');
+          });
         }, function(error) {
           ctrl.results = [];
           ctrl.editing = ctrl.loading = false;
+          _.each(ctrl.onPostRun, function(callback) {
+            callback.call(ctrl, error, 'error');
+          });
         });
       },
       replaceTokens: function(value, row) {
index 1644052869edfc601f1afbd35370b9b8d7424728..27475eac63046356427308b5bbffe71ef2b338c2 100644 (file)
         return this.allRowsSelected || _.includes(this.selectedRows, row.id);
       },
 
-      // Reset selection when filters are changed
-      onChangeFilters: function() {
+      // Overwrite empty onChangeFilters array from searchDisplayBaseTrait
+      onChangeFilters: [function() {
+        // Reset selection when filters are changed
         this.selectedRows.length = 0;
         this.allRowsSelected = false;
-      }
+      }]
 
     };
   });
index eceab7fcf6a73b24199bb5cfda4f2b0f1f171df7..cf3b0743c9c1122aa9f5716f3c6b59f705b90332 100644 (file)
   min-width: 500px;
 }
 
-#bootstrap-theme #crm-search-results-page-size {
-  width: 5em;
-}
-#bootstrap-theme .crm-search-results {
-  min-height: 200px;
-}
-
 #bootstrap-theme.crm-search .nav-stacked {
   margin-left: 0;
   margin-right: 20px;