SearchKit - Add "Totals" footer row for sums, averages, and other aggregate statisics
authorColeman Watts <coleman@civicrm.org>
Fri, 13 May 2022 02:55:18 +0000 (22:55 -0400)
committerColeman Watts <coleman@civicrm.org>
Wed, 18 May 2022 21:18:33 +0000 (17:18 -0400)
Fixes dev/core#3186

12 files changed:
ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.component.js
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayTable.html
ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayBaseTrait.service.js
ext/search_kit/ang/crmSearchDisplayTable.ang.php
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTable.component.js
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTable.html
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTableBody.html
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTableLoading.html
ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTally.html [new file with mode: 0644]
ext/search_kit/css/crmSearchDisplayTable.css [new file with mode: 0644]
ext/search_kit/css/crmSearchTasks.css

index fad6bc95a5ef833e7d11c7d1298bf86f94a18193..3d631321e620b144e4611224f0266086a7e272c7 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace Civi\Api4\Action\SearchDisplay;
 
+use Civi\API\Request;
+use Civi\Api4\Query\Api4SelectQuery;
 use Civi\Api4\Utils\CoreUtil;
 
 /**
@@ -53,6 +55,34 @@ class Run extends AbstractRunAction {
         unset($apiParams['orderBy'], $apiParams['limit']);
         break;
 
+      case 'tally':
+        $this->applyFilters();
+        unset($apiParams['orderBy'], $apiParams['limit']);
+        $api = Request::create($entityName, 'get', $apiParams);
+        $query = new Api4SelectQuery($api);
+        $query->forceSelectId = FALSE;
+        $sql = $query->getSql();
+        $select = [];
+        foreach ($settings['columns'] as $col) {
+          if (!empty($col['tally']['fn']) && !empty($col['key'])) {
+            $fn = \CRM_Core_DAO::escapeString($col['tally']['fn']);
+            $key = \CRM_Core_DAO::escapeString($col['key']);
+            $select[] = $fn . '(`' . $key . '`) `' . $key . '`';
+          }
+        }
+        $query = 'SELECT ' . implode(', ', $select) . ' FROM (' . $sql . ') `api_query`';
+        $dao = \CRM_Core_DAO::executeQuery($query);
+        $dao->fetch();
+        $tally = [];
+        foreach ($settings['columns'] as $col) {
+          if (!empty($col['tally']['fn']) && !empty($col['key'])) {
+            $alias = str_replace('.', '_', $col['key']);
+            $tally[$col['key']] = $dao->$alias ?? NULL;
+          }
+        }
+        $result[] = $tally;
+        return;
+
       default:
         if (($settings['pager'] ?? FALSE) !== FALSE && preg_match('/^page:\d+$/', $key)) {
           $page = explode(':', $key)[1];
index 11dfb6522fdf0d8eec4d1d4dc19c7e95580f39e8..d2e09fcd4960287e355981680a4e9c809ab4fe86 100644 (file)
@@ -11,7 +11,7 @@
       parent: '^crmSearchAdminDisplay'
     },
     templateUrl: '~/crmSearchAdmin/displays/searchAdminDisplayTable.html',
-    controller: function($scope, searchMeta) {
+    controller: function($scope, searchMeta, formatForSelect2) {
       var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
         ctrl = this;
 
         ctrl.parent.initColumns({label: true, sortable: true});
       };
 
+      this.toggleTally = function() {
+        if (ctrl.display.settings.tally) {
+          delete ctrl.display.settings.tally;
+          _.each(ctrl.display.settings.columns, function(col) {
+            delete col.tally;
+          });
+        } else {
+          ctrl.display.settings.tally = {label: ts('Total')};
+          _.each(ctrl.display.settings.columns, function(col) {
+            if (col.type === 'field') {
+              col.tally = {
+                fn: searchMeta.getDefaultAggregateFn(searchMeta.parseExpr(col.key)).fnName
+              };
+            }
+          });
+        }
+      };
+
+      this.getTallyFunctions = function() {
+        var allowedFunctions = _.filter(CRM.crmSearchAdmin.functions, function(fn) {
+          return fn.category === 'aggregate' && fn.params.length;
+        });
+        return {results: formatForSelect2(allowedFunctions, 'name', 'title', ['description'])};
+      };
+
     }
   });
 
index 3a8a214ff86b19e1a389341c698fb5bdb94ff0eb..99971ebbb03ac2e2da67db61128aa264e3ef836e 100644 (file)
     <label for="crm-search-admin-display-no-results-text">{{:: ts('No Results Text') }}</label>
     <input class="form-control crm-flex-1" id="crm-search-admin-display-no-results-text" ng-model="$ctrl.display.settings.noResultsText" placeholder="{{:: ts('None found.') }}">
   </div>
+  <div class="form-inline">
+    <div class="checkbox-inline form-control" title="{{:: ts('Shows grand totals or other statistics, configured per-column.') }}">
+      <label>
+        <input type="checkbox" ng-click="$ctrl.toggleTally()" ng-checked="!!$ctrl.display.settings.tally">
+        <span>{{:: ts('Show Totals in Footer') }}</span>
+      </label>
+    </div>
+    <div class="form-group" ng-if="$ctrl.display.settings.tally">
+      <label for="crm-search-admin-table-tally-title">{{:: ts('Label') }}</label>
+      <input id="crm-search-admin-table-tally-title" ng-model="$ctrl.display.settings.tally.label" class="form-control">
+    </div>
+  </div>
 </fieldset>
 <fieldset class="crm-search-admin-edit-columns-wrapper">
   <legend>
         </label>
       </div>
       <div ng-include="'~/crmSearchAdmin/displays/colType/' + col.type + '.html'"></div>
+      <div class="form-inline" ng-if="col.type === 'field' && $ctrl.display.settings.tally">
+        <label>{{:: ts('Footer Label') }}</label>
+        <input class="form-control" ng-model="col.tally.label" placeholder="{{:: ts('None') }}">
+        <label>{{:: ts('Footer Aggregate') }}</label>
+        <input class="form-control" ng-model="col.tally.fn" crm-ui-select="{data: $ctrl.getTallyFunctions, placeholder: ts('None'), allowClear: true}">
+      </div>
     </fieldset>
   </div>
 </fieldset>
index bffde300df8d3f807c2a05719acfd810c4aa6445..21be429058e402e2f0ae48083f70816f31ecac72 100644 (file)
         $scope.$watch('$ctrl.filters', onChangeFilters, true);
       },
 
+      hasExtraFirstColumn: function() {
+        return this.settings.actions || this.settings.draggable || (this.settings.tally && this.settings.tally.label);
+      },
+
       getAfformFilters: function() {
         return _.pick(this.afFieldset ? this.afFieldset.getFieldData() : {}, function(val) {
           return val !== null && (_.includes(['boolean', 'number'], typeof val) || val.length);
index c52b7f3e173c15e13555ebc2f793b53313e9209e..dbbcca459514534ad6c875d0318e403bed628c44 100644 (file)
@@ -8,6 +8,9 @@ return [
   'partials' => [
     'ang/crmSearchDisplayTable',
   ],
+  'css' => [
+    'css/crmSearchDisplayTable.css',
+  ],
   'basePages' => ['civicrm/search', 'civicrm/admin/search'],
   'requires' => ['crmSearchDisplay', 'crmUi', 'crmSearchTasks', 'ui.bootstrap', 'ui.sortable'],
   'bundles' => ['bootstrap3'],
index 76e500910ea140f6b2dc1a5e0a4792ffffb5b780..b356bfffa84917b5ea8cee521e85628da38a8005 100644 (file)
         ctrl = angular.extend(this, searchDisplayBaseTrait, searchDisplayTasksTrait, searchDisplaySortableTrait);
 
       this.$onInit = function() {
+        var tallyParams;
+
+        if (ctrl.settings.tally) {
+          ctrl.onPreRun.push(function (apiParams) {
+            ctrl.tally = null;
+            tallyParams = _.cloneDeep(apiParams);
+          });
+
+          ctrl.onPostRun.push(function (results, status) {
+            ctrl.tally = null;
+            if (status === 'success' && tallyParams) {
+              tallyParams.return = 'tally';
+              crmApi4('SearchDisplay', 'run', tallyParams).then(function (result) {
+                ctrl.tally = result[0];
+              });
+            }
+          });
+        }
+
         this.initializeDisplay($scope, $element);
 
         if (ctrl.settings.draggable) {
index 0293a9294f479ccc47d8963f68847ab0f12c9159..82e9e03cc4c0e803d62ebed085915ad57061c6fe 100644 (file)
@@ -6,7 +6,7 @@
   <table class="{{:: $ctrl.settings.classes.join(' ') }}">
     <thead>
       <tr>
-        <th ng-class="{'crm-search-result-select': $ctrl.settings.actions}" ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTaskHeader.html'" ng-if=":: $ctrl.settings.actions || $ctrl.settings.draggable">
+        <th ng-class="{'crm-search-result-select': $ctrl.settings.actions}" ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTaskHeader.html'" ng-if=":: $ctrl.hasExtraFirstColumn()">
         </th>
         <th ng-repeat="col in $ctrl.settings.columns" ng-click="$ctrl.setSort(col, $event)" class="{{:: $ctrl.isSortable(col) ? 'crm-sortable-col' : ''}}" title="{{:: $ctrl.isSortable(col) ? ts('Click to sort results (shift-click to sort by multiple).') : '' }}">
           <i ng-if=":: $ctrl.isSortable(col)" class="crm-i {{ $ctrl.getSort(col) }}"></i>
@@ -17,6 +17,7 @@
     <tbody ng-if="$ctrl.loading" ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTableLoading.html'"></tbody>
     <tbody ng-if="!$ctrl.loading && !$ctrl.settings.draggable" ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTableBody.html'"></tbody>
     <tbody ng-if="!$ctrl.loading && $ctrl.settings.draggable" ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTableBody.html'" ui-sortable="$ctrl.draggableOptions" ng-model="$ctrl.results"></tbody>
+    <tfoot ng-if="!$ctrl.loading && $ctrl.settings.tally" ng-include="'~/crmSearchDisplayTable/crmSearchDisplayTally.html'"></tfoot>
   </table>
   <div ng-include="'~/crmSearchDisplay/Pager.html'"></div>
 </div>
index 36aba9cd4e310b88e0d587dc08a0f70c14f0bc45..617dc299beb1b440571aee1dd2f1bed08ad57160 100644 (file)
@@ -1,5 +1,5 @@
 <tr ng-repeat="(rowIndex, row) in $ctrl.results">
-  <td ng-if=":: $ctrl.settings.actions || $ctrl.settings.draggable" class="{{:: row.cssClass }}">
+  <td ng-if=":: $ctrl.hasExtraFirstColumn()" class="{{:: row.cssClass }}">
     <span ng-if=":: $ctrl.settings.draggable" class="crm-draggable" title="{{:: ts('Drag to reposition') }}">
       <i class="crm-i fa-arrows-v"></i>
     </span>
index 35738b7edd6b4adae646a79839fc0fa105785d82..8054df17d46bb5cbc59386af79f09ca7e6356960 100644 (file)
@@ -1,6 +1,6 @@
 <!-- Placeholder table rows shown during ajax loading -->
 <tr ng-repeat="num in [1,2,3,4,5] track by $index">
-  <td ng-if=":: $ctrl.settings.actions || $ctrl.settings.draggable">
+  <td ng-if=":: $ctrl.hasExtraFirstColumn()">
     <input ng-if=":: $ctrl.settings.actions" type="checkbox" disabled>
   </td>
   <td ng-repeat="col in $ctrl.settings.columns">
diff --git a/ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTally.html b/ext/search_kit/ang/crmSearchDisplayTable/crmSearchDisplayTally.html
new file mode 100644 (file)
index 0000000..bd6809f
--- /dev/null
@@ -0,0 +1,13 @@
+<!-- Placeholder table rows shown during ajax loading -->
+<tr>
+  <td ng-if=":: $ctrl.hasExtraFirstColumn()">
+    {{:: $ctrl.settings.tally.label }}
+  </td>
+  <td ng-repeat="col in $ctrl.settings.columns">
+    <div ng-if="!$ctrl.tally" class="crm-search-loading-placeholder"></div>
+    <div ng-if="$ctrl.tally">
+      {{:: col.tally.label }}
+      {{ $ctrl.tally[col.key] }}
+    </div>
+  </td>
+</tr>
diff --git a/ext/search_kit/css/crmSearchDisplayTable.css b/ext/search_kit/css/crmSearchDisplayTable.css
new file mode 100644 (file)
index 0000000..d454137
--- /dev/null
@@ -0,0 +1,14 @@
+/* search kit table display styling */
+
+#bootstrap-theme .crm-search-display-table > table.table > thead > tr > th.crm-search-result-select {
+  padding-left: 0;
+  padding-right: 0;
+  text-transform: none;
+  color: initial;
+  /* Don't allow button to be split on 2 lines */
+  min-width: 86px;
+}
+
+#bootstrap-theme .crm-search-display.crm-search-display-table tfoot > tr > td {
+  font-weight: bold;
+}
index 8a96d12a5f2f868e770f04d75a95acdbab139a55..bdb38fbc2d269a4f620c2cd2fc71417dede0778d 100644 (file)
@@ -4,15 +4,6 @@
   border: 1px solid lightgrey;
 }
 
-#bootstrap-theme .crm-search-display-table > table.table > thead > tr > th.crm-search-result-select {
-  padding-left: 0;
-  padding-right: 0;
-  text-transform: none;
-  color: initial;
-  /* Don't allow button to be split on 2 lines */
-  min-width: 86px;
-}
-
 .crm-search-display.crm-search-display-table td > crm-search-display-editable,
 .crm-search-display.crm-search-display-table td > .crm-editable-enabled {
   display: block !important;