Afform - SearchKit support for calculated fields
authorColeman Watts <coleman@civicrm.org>
Tue, 16 Feb 2021 20:59:35 +0000 (15:59 -0500)
committerColeman Watts <coleman@civicrm.org>
Wed, 17 Feb 2021 02:26:32 +0000 (21:26 -0500)
This allows the aggregated columns from a savedSearch to be used as filters on an afform with embedded search display

Civi/Api4/Query/Api4SelectQuery.php
ext/afform/admin/Civi/Api4/Action/Afform/LoadAdminData.php
ext/afform/admin/ang/afGuiEditor/afGuiSearch.component.js
ext/afform/admin/ang/afGuiEditor/afGuiSearch.html
ext/afform/admin/ang/afGuiEditor/elements/afGuiField.component.js
ext/search/Civi/Api4/Action/SearchDisplay/Run.php

index e604e99bea50d44c7b238c782b1a2d9ab84e72e5..f15db6f1d597dfd796598f770cf9c1298848983e 100644 (file)
@@ -103,6 +103,9 @@ class Api4SelectQuery {
     // Add ACLs first to avoid redundant subclauses
     $baoName = CoreUtil::getBAOFromApiName($this->getEntity());
     $this->query->where($this->getAclClause(self::MAIN_TABLE_ALIAS, $baoName));
+
+    // Add explicit joins. Other joins implied by dot notation may be added later
+    $this->addExplicitJoins();
   }
 
   /**
@@ -113,8 +116,6 @@ class Api4SelectQuery {
    * @throws \CRM_Core_Exception
    */
   public function getSql() {
-    // Add explicit joins. Other joins implied by dot notation may be added later
-    $this->addExplicitJoins();
     $this->buildSelectClause();
     $this->buildWhereClause();
     $this->buildOrderBy();
@@ -152,7 +153,6 @@ class Api4SelectQuery {
    * @throws \API_Exception
    */
   public function getCount() {
-    $this->addExplicitJoins();
     $this->buildWhereClause();
     // If no having or groupBy, we only need to select count
     if (!$this->getHaving() && !$this->getGroupBy()) {
index 929a91acab36c677bb7ec558278d439fa39a6d4d..f8bd4cfafc3f90af599e7ee7a1698d0f9ab1a24f 100644 (file)
@@ -4,6 +4,8 @@ namespace Civi\Api4\Action\Afform;
 
 use Civi\AfformAdmin\AfformAdminMeta;
 use Civi\Api4\Afform;
+use Civi\Api4\Entity;
+use Civi\Api4\Query\SqlExpression;
 
 /**
  * This action is used by the Afform Admin extension to load metadata for the Admin GUI.
@@ -176,6 +178,7 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
           ->addWhere('saved_search.name', '=', $displayTag['search-name'])
           ->addSelect('*', 'type:name', 'type:icon', 'saved_search.name', 'saved_search.api_entity', 'saved_search.api_params')
           ->execute()->first();
+        $display['calc_fields'] = $this->getCalcFields($display['saved_search.api_entity'], $display['saved_search.api_params']);
         $info['search_displays'][] = $display;
         if ($newForm) {
           $info['definition']['layout'][0]['#children'][] = $displayTag + ['#tag' => $display['type:name']];
@@ -240,6 +243,51 @@ class LoadAdminData extends \Civi\Api4\Generic\AbstractAction {
     }
   }
 
+  /**
+   * @param string $apiEntity
+   * @param array $apiParams
+   * @return array
+   */
+  private function getCalcFields($apiEntity, $apiParams) {
+    $calcFields = [];
+    $api = \Civi\API\Request::create($apiEntity, 'get', $apiParams);
+    $selectQuery = new \Civi\Api4\Query\Api4SelectQuery($api);
+    $joinMap = $joinCount = [];
+    foreach ($apiParams['join'] ?? [] as $join) {
+      [$entityName, $alias] = explode(' AS ', $join[0]);
+      $num = '';
+      if (!empty($joinCount[$entityName])) {
+        $num = ' ' . (++$joinCount[$entityName]);
+      }
+      else {
+        $joinCount[$entityName] = 1;
+      }
+      $label = Entity::get(FALSE)
+        ->addWhere('name', '=', $entityName)
+        ->addSelect('title')
+        ->execute()->first()['title'];
+      $joinMap[$alias] = $label . $num;
+    }
+
+    foreach ($apiParams['select'] ?? [] as $select) {
+      if (strstr($select, ' AS ')) {
+        $expr = SqlExpression::convert($select, TRUE);
+        $field = $expr->getFields() ? $selectQuery->getField($expr->getFields()[0]) : NULL;
+        $joinName = explode('.', $expr->getFields()[0] ?? '')[0];
+        $label = $expr::getTitle() . ': ' . (isset($joinMap[$joinName]) ? $joinMap[$joinName] . ' ' : '') . $field['title'];
+        $calcFields[] = [
+          '#tag' => 'af-field',
+          'name' => $expr->getAlias(),
+          'defn' => [
+            'label' => $label,
+            'input_type' => 'Text',
+          ],
+        ];
+      }
+    }
+    return $calcFields;
+  }
+
   public function fields() {
     return [
       [
index c195a5e321f546a478231e552d3cca52c083e1e6..f8c282e5a9196fc08b03079d364c1bb2b1a21a14 100644 (file)
@@ -13,6 +13,7 @@
       var ctrl = this;
       $scope.controls = {};
       $scope.fieldList = [];
+      $scope.calcFieldList = [];
       $scope.blockList = [];
       $scope.blockTitles = [];
       $scope.elementList = [];
 
       this.buildPaletteLists = function() {
         var search = $scope.controls.fieldSearch ? $scope.controls.fieldSearch.toLowerCase() : null;
+        buildCalcFieldList(search);
         buildFieldList(search);
         buildBlockList(search);
         buildElementList(search);
       };
 
+      function buildCalcFieldList(search) {
+        $scope.calcFieldList.length = 0;
+        _.each(_.cloneDeep(ctrl.display.calc_fields), function(field) {
+          if (!search || _.contains(field.defn.label.toLowerCase(), search)) {
+            $scope.calcFieldList.push(field);
+          }
+        });
+      }
+
       function buildBlockList(search) {
         $scope.blockList.length = 0;
         $scope.blockTitles.length = 0;
index 1d9def8349cac6fa92e9a8e271955d2945164154..9115234d9a71099ef9409017e0b84d80c825a553 100644 (file)
           </div>
         </div>
       </div>
+      <div ng-if="calcFieldList.length">
+        <label>{{:: ts('Calculated Fields') }}</label>
+        <div ui-sortable="{update: buildPaletteLists, items: '&gt; div:not(.disabled)', connectWith: '[ui-sortable]', placeholder: 'af-gui-dropzone'}" ui-sortable-update="$ctrl.editor.onDrop" ng-model="calcFieldList">
+          <div ng-repeat="field in calcFieldList" ng-class="{disabled: fieldInUse(field.name)}">
+            {{:: field.defn.label }}
+          </div>
+        </div>
+      </div>
       <div ng-repeat="fieldGroup in fieldList">
         <div ng-if="fieldGroup.fields.length">
           <label>{{:: fieldGroup.label }}</label>
index 2c76c72bc2dac654b1e87fa4b92186e556595ad6..1534dafb29dd0540b18527087268fa1a62b8c342 100644 (file)
         $scope.meta = afGui.meta;
       };
 
-      // $scope.getEntity = function() {
-      //   return ctrl.editor ? ctrl.editor.getEntity(ctrl.container.getEntityName()) : {};
-      // };
-
       // Returns the original field definition from metadata
       this.getDefn = function() {
-        return ctrl.editor ? afGui.getField(ctrl.container.getFieldEntityType(ctrl.node.name), ctrl.node.name) : {};
+        var defn = afGui.getField(ctrl.container.getFieldEntityType(ctrl.node.name), ctrl.node.name);
+        return defn ||  {
+          label: ts('Untitled'),
+          requred: false,
+          input_attrs: []
+        };
       };
 
       $scope.getOriginalLabel = function() {
index 1fd8d26c08bda5828b4a613a763e01f393e3e775..8028fc4d2edef8108fcfec83120bf8610bb69d2b 100644 (file)
@@ -122,8 +122,26 @@ class Run extends \Civi\Api4\Generic\AbstractAction {
     foreach ($this->filters as $fieldName => $value) {
       if ($value) {
         $field = $this->getField($fieldName) ?? [];
-        $dataType = $field['data_type'] ?? NULL;
 
+        // If the field doesn't exist, it could be an aggregated column
+        if (!$field) {
+          // Not a real field but in the SELECT clause. It must be an aggregated column. Add to HAVING clause.
+          if (in_array($fieldName, $this->getSelectAliases())) {
+            if ($prefixWithWildcard) {
+              $this->savedSearch['api_params']['having'][] = [$fieldName, 'CONTAINS', $value];
+            }
+            else {
+              $this->savedSearch['api_params']['having'][] = [$fieldName, 'LIKE', $value . '%'];
+            }
+          }
+          // Error - field doesn't exist and isn't a column alias
+          else {
+            // Maybe throw an exception? Or just log a warning?
+          }
+          continue;
+        }
+
+        $dataType = $field['data_type'];
         if (!empty($field['serialize'])) {
           $this->savedSearch['api_params']['where'][] = [$fieldName, 'CONTAINS', $value];
         }