SearchKit - Add UI for multiple function arguments
authorColeman Watts <coleman@civicrm.org>
Sat, 18 Sep 2021 14:17:22 +0000 (10:17 -0400)
committerColeman Watts <coleman@civicrm.org>
Sat, 18 Sep 2021 18:15:29 +0000 (14:15 -0400)
Civi/Api4/Query/SqlExpression.php
Civi/Api4/Query/SqlFunction.php
Civi/Api4/Query/SqlFunctionCOUNT.php
Civi/Api4/Query/SqlFunctionGREATEST.php
Civi/Api4/Query/SqlFunctionLEAST.php
ext/search_kit/ang/crmSearchAdmin/compose.html
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchFunction.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchFunction.html
ext/search_kit/ang/crmSearchAdmin/resultsTable/crmSearchAdminResultsTable.component.js

index 8e3d43254d8f6e3f5aea4906bfdabcaa91268dff..338ae5b0575e2ad12530599d38b910b27637711d 100644 (file)
@@ -73,11 +73,10 @@ abstract class SqlExpression {
    * @param string $expression
    * @param bool $parseAlias
    * @param array $mustBe
-   * @param array $cantBe
    * @return SqlExpression
    * @throws \API_Exception
    */
-  public static function convert(string $expression, $parseAlias = FALSE, $mustBe = [], $cantBe = ['SqlWild']) {
+  public static function convert(string $expression, $parseAlias = FALSE, $mustBe = []) {
     $as = $parseAlias ? strrpos($expression, ' AS ') : FALSE;
     $expr = $as ? substr($expression, 0, $as) : $expression;
     $alias = $as ? \CRM_Utils_String::munge(substr($expression, $as + 4), '_', 256) : NULL;
@@ -114,11 +113,6 @@ abstract class SqlExpression {
       throw new \API_Exception('Unable to parse sql expression: ' . $expression);
     }
     $sqlExpression = new $className($expr, $alias);
-    foreach ($cantBe as $cant) {
-      if (is_a($sqlExpression, __NAMESPACE__ . '\\' . $cant)) {
-        throw new \API_Exception('Illegal sql expression.');
-      }
-    }
     if ($mustBe) {
       foreach ($mustBe as $must) {
         if (is_a($sqlExpression, __NAMESPACE__ . '\\' . $must)) {
index 521a67b3bcc853292995105cb3b12780c5bf004e..94bf31b85dba6029d1def51c98af290c6369f223 100644 (file)
@@ -67,7 +67,7 @@ abstract class SqlFunction extends SqlExpression {
         'suffix' => [],
       ];
       if ($param['max_expr'] && (!$param['name'] || $param['name'] === $prefix)) {
-        $exprs = $this->captureExpressions($arg, $param['must_be'], $param['cant_be']);
+        $exprs = $this->captureExpressions($arg, $param['must_be']);
         if (count($exprs) < $param['min_expr'] || count($exprs) > $param['max_expr']) {
           throw new \API_Exception('Incorrect number of arguments for SQL function ' . static::getName());
         }
@@ -116,17 +116,16 @@ abstract class SqlFunction extends SqlExpression {
    *
    * @param string $arg
    * @param array $mustBe
-   * @param array $cantBe
    * @return array
    * @throws \API_Exception
    */
-  private function captureExpressions(&$arg, $mustBe, $cantBe) {
+  private function captureExpressions(&$arg, $mustBe) {
     $captured = [];
     $arg = ltrim($arg);
     while ($arg) {
       $item = $this->captureExpression($arg);
       $arg = ltrim(substr($arg, strlen($item)));
-      $expr = SqlExpression::convert($item, FALSE, $mustBe, $cantBe);
+      $expr = SqlExpression::convert($item, FALSE, $mustBe);
       $this->fields = array_merge($this->fields, $expr->getFields());
       $captured[] = $expr;
       // Keep going if we have a comma indicating another expression follows
@@ -253,8 +252,7 @@ abstract class SqlFunction extends SqlExpression {
         'flag_before' => [],
         'flag_after' => [],
         'optional' => FALSE,
-        'must_be' => [],
-        'cant_be' => ['SqlWild'],
+        'must_be' => ['SqlField', 'SqlFunction', 'SqlString', 'SqlNumber', 'SqlNull'],
         'api_default' => NULL,
       ];
     }
index e45c9ff67d8389bd9deeacaba3d5a78e1706a138..360ce7d8af0a0f9c979185521ae935a3199e5829 100644 (file)
@@ -26,7 +26,6 @@ class SqlFunctionCOUNT extends SqlFunction {
         'flag_before' => ['DISTINCT' => ts('Distinct')],
         'max_expr' => 1,
         'must_be' => ['SqlField', 'SqlWild'],
-        'cant_be' => [],
       ],
     ];
   }
index 3874ce4aecb3de34d812e12adaca41cb4cd34127..6a61daad32d5cb97e2825ba4199d117373c8811c 100644 (file)
@@ -24,6 +24,7 @@ class SqlFunctionGREATEST extends SqlFunction {
     return [
       [
         'max_expr' => 99,
+        'min_expr' => 2,
         'optional' => FALSE,
       ],
     ];
index bbf903819ef6897160585e0df7cf10983061fa32..4b9d4e66e57a8c1ef3efec30fe7b4c9e3a98b924 100644 (file)
@@ -24,6 +24,7 @@ class SqlFunctionLEAST extends SqlFunction {
     return [
       [
         'max_expr' => 99,
+        'min_expr' => 2,
         'optional' => FALSE,
       ],
     ];
index 6bcf443b9148e60baf06b4b84bd4ff792bc354aa..6437a92b5782c63aa050f2db07e8ec6d20b69a01 100644 (file)
                crm-ui-select="{placeholder: ts('Group By'), data: fieldsForGroupBy, dropdownCss: {width: '300px'}}"
                on-crm-ui-select="$ctrl.addParam('groupBy', selection)" >
       </div>
-      <fieldset id="crm-search-build-functions">
-        <legend ng-click="controls.showFunctions = !controls.showFunctions">
-          <i class="crm-i fa-caret-{{ !controls.showFunctions ? 'right' : 'down' }}"></i>
-          {{:: ts('Field Transformations') }}
-        </legend>
-        <div ng-if="!!controls.showFunctions">
-          <fieldset ng-repeat="col in $ctrl.savedSearch.api_params.select" ng-if="!$ctrl.isPseudoField(col)">
-            <crm-search-function expr="$ctrl.savedSearch.api_params.select[$index]"></crm-search-function>
-          </fieldset>
-        </div>
-      </fieldset>
     </fieldset>
   </div>
   <div class="crm-search-criteria-column">
     </fieldset>
   </div>
 </div>
+<fieldset id="crm-search-build-functions">
+  <legend ng-click="controls.showFunctions = !controls.showFunctions">
+    <i class="crm-i fa-caret-{{ !controls.showFunctions ? 'right' : 'down' }}"></i>
+    {{:: ts('Field Transformations') }}
+  </legend>
+  <div ng-if="!!controls.showFunctions">
+    <!-- Must use track by $index with an array of primitives, and manually refresh this loop when indexes change -->
+    <fieldset ng-repeat="col in $ctrl.savedSearch.api_params.select track by $index" ng-if="!$ctrl.isPseudoField(col)">
+      <crm-search-function expr="$ctrl.savedSearch.api_params.select[$index]"></crm-search-function>
+    </fieldset>
+  </div>
+</fieldset>
index ffc1ad78ec6b95d6ecb701bc5989f9fd555f6245..70d08365a14e0ad8e1a610f9adfb4bafc526df3f 100644 (file)
 
       // Deletes an item from an array param
       this.clearParam = function(name, idx) {
+        if (name === 'select') {
+          // Function selectors use `ng-repeat` with `track by $index` so must be refreshed when splicing the array
+          ctrl.hideFuncitons();
+        }
         ctrl.savedSearch.api_params[name].splice(idx, 1);
       };
 
+      this.hideFuncitons = function() {
+        $scope.controls.showFunctions = false;
+      };
+
       function onChangeSelect(newSelect, oldSelect) {
         // When removing a column from SELECT, also remove from ORDER BY & HAVING
         _.each(_.difference(oldSelect, newSelect), function(col) {
index 0aee8488586c2434471be715bcee0c55845a957a..cebe91534ad56fadd41cd038fbc465d13122d277 100644 (file)
         string: ts('Text')
       };
 
-      $scope.$watch('$ctrl.expr', function(expr) {
-        var fieldInfo = searchMeta.parseExpr(expr);
-        ctrl.path = fieldInfo.path + fieldInfo.suffix;
-        ctrl.field = [fieldInfo.field];
-        ctrl.fn = !fieldInfo.fn ? '' : fieldInfo.fn.name;
-        ctrl.modifier = fieldInfo.modifier || null;
+      this.exprTypes = {
+        SqlField: {label: ts('Field'), type: 'field'},
+        SqlString: {label: ts('Text'), type: 'string'},
+        SqlNumber: {label: ts('Number'), type: 'number'},
+      };
+
+      this.$onInit = function() {
+        var info = searchMeta.parseExpr(ctrl.expr);
+        ctrl.args = info.args;
+        ctrl.fn = info.fn;
+        ctrl.fnName = !info.fn ? '' : info.fn.name;
         initFunction();
-      });
+      };
+
+      this.addArg = function(exprType) {
+        exprType = exprType || ctrl.fn.params[0].must_be[0];
+        ctrl.args.push({
+          type: ctrl.exprTypes[exprType].type,
+          value: exprType === 'SqlNumber' ? 0 : ''
+        });
+      };
 
       function initFunction() {
-        ctrl.fnInfo = _.find(CRM.crmSearchAdmin.functions, {name: ctrl.fn});
-        if (ctrl.fnInfo && ctrl.fnInfo.params[0] && !_.isEmpty(ctrl.fnInfo.params[0].flag_before)) {
-          ctrl.modifierName = _.keys(ctrl.fnInfo.params[0].flag_before)[0];
-          ctrl.modifierLabel = ctrl.fnInfo.params[0].flag_before[ctrl.modifierName];
+        if (!ctrl.fn) {
+          return;
+        }
+        if (ctrl.fn && ctrl.fn.params[0] && !_.isEmpty(ctrl.fn.params[0].flag_before)) {
+          ctrl.modifierName = _.keys(ctrl.fn.params[0].flag_before)[0];
+          ctrl.modifierLabel = ctrl.fn.params[0].flag_before[ctrl.modifierName];
         }
         else {
           ctrl.modifierName = null;
           ctrl.modifier = null;
         }
+        while (ctrl.args.length < ctrl.fn.params[0].min_expr) {
+          ctrl.addArg();
+        }
       }
 
       // On-demand options for dropdown function selector
       this.getFunctions = function() {
         var allowedTypes = [], functions = [];
-        if (ctrl.expr && ctrl.field) {
+        if (ctrl.expr && ctrl.args[0] && ctrl.args[0].field) {
           if (ctrl.crmSearchAdmin.canAggregate(ctrl.expr)) {
             allowedTypes.push('aggregate');
           } else {
             allowedTypes.push('comparison', 'string');
-            if (_.includes(['Integer', 'Float', 'Date', 'Timestamp'], ctrl.field[0].data_type)) {
+            if (_.includes(['Integer', 'Float', 'Date', 'Timestamp'], ctrl.args[0].field.data_type)) {
               allowedTypes.push('math');
             }
-            if (_.includes(['Date', 'Timestamp'], ctrl.field[0].data_type)) {
+            if (_.includes(['Date', 'Timestamp'], ctrl.args[0].field.data_type)) {
               allowedTypes.push('date');
             }
           }
             var allowedFunctions = _.filter(CRM.crmSearchAdmin.functions, function(fn) {
               return fn.category === type &&
                 fn.params.length &&
-                // For now, only support functions that take a single field
-                fn.params[0].min_expr === 1 &&
-                fn.params[0].max_expr === 1 &&
-                !_.includes(fn.params[0].cant_be, 'SqlField') &&
-                (!fn.params[0].must_be.length || _.includes(fn.params[0].must_be, 'SqlField'));
+                fn.params[0].min_expr > 0 &&
+                _.includes(fn.params[0].must_be, 'SqlField');
             });
             functions.push({
               text: allTypes[type],
         return {results: functions};
       };
 
+      this.getFields = function() {
+        return {
+          results: ctrl.crmSearchAdmin.getAllFields(':label', ['Field', 'Custom', 'Extra'])
+        };
+      };
+
       this.selectFunction = function() {
+        ctrl.fn = _.find(CRM.crmSearchAdmin.functions, {name: ctrl.fnName});
+        ctrl.args.length = 1;
+        initFunction();
         ctrl.writeExpr();
       };
 
       this.toggleModifier = function() {
-        ctrl.modifier = ctrl.modifier ? null : ctrl. modifierName;
+        ctrl.modifier = ctrl.modifier ? null : ctrl.modifierName;
+        ctrl.writeExpr();
+      };
+
+      this.changeArg = function(index) {
+        var val = ctrl.args[index].value;
+        // Delete empty value
+        if (!val && ctrl.args.length > ctrl.fn.params[0].min_expr) {
+          ctrl.args.splice(index, 1);
+        }
         ctrl.writeExpr();
       };
 
       // Make a sql-friendly alias for this expression
       function makeAlias() {
-        return (ctrl.fn + '_' + ctrl.path).replace(/[.:]/g, '_');
+        var args = _.pluck(_.filter(_.filter(ctrl.args, 'value'), {type: 'field'}), 'value');
+        return (ctrl.fnName + '_' + args.join('_')).replace(/[.:]/g, '_');
       }
 
       this.writeExpr = function() {
-        ctrl.expr = ctrl.fn ? (ctrl.fn + '(' + (ctrl.modifier ? ctrl.modifier + ' ' : '') + ctrl.path + ') AS ' + makeAlias()) : ctrl.path;
+        if (ctrl.fnName) {
+          var args = _.transform(ctrl.args, function(args, arg) {
+            if (arg.value) {
+              args.push(arg.type === 'string' ? JSON.stringify(arg.value) : arg.value);
+            }
+          });
+          ctrl.expr = ctrl.fnName + '(' + (ctrl.modifier ? ctrl.modifier + ' ' : '') + args.join(', ') + ') AS ' + makeAlias();
+        } else {
+          ctrl.expr = ctrl.args[0].value;
+        }
       };
     }
   });
index 258433196acae54d5bce51e20ab26ed211c43222..5ba166840a3a5e618508e5a4dab7af85b73b9fd4 100644 (file)
@@ -1,8 +1,25 @@
 <div class="form-inline">
-  <label>{{ $ctrl.field[0].label }}:</label>
-  <input class="form-control" style="width: 15em;" ng-model="$ctrl.fn" crm-ui-select="{data: $ctrl.getFunctions, placeholder: ts('Select')}" ng-change="$ctrl.selectFunction()">
+  <input class="form-control" style="width: 15em;" ng-model="$ctrl.fnName" crm-ui-select="{data: $ctrl.getFunctions, placeholder: ts('Function')}" ng-change="$ctrl.selectFunction()">
+  <label>{{ $ctrl.args[0].field.label }}</label>
   <label ng-if="$ctrl.modifierName">
     <input type="checkbox" ng-checked="!!$ctrl.modifier" ng-click="$ctrl.toggleModifier()">
     {{ $ctrl.modifierLabel }}
   </label>
+  <div class="form-group" ng-repeat="arg in $ctrl.args" ng-if="$index">
+    <span ng-switch="arg.type">
+      <input ng-switch-when="number" class="form-control" type="number" ng-model="arg.value" ng-change="$ctrl.changeArg($index)" ng-model-options="{updateOn: 'blur'}">
+      <input ng-switch-when="string" class="form-control" ng-model="arg.value" ng-change="$ctrl.changeArg($index)" ng-model-options="{updateOn: 'blur'}">
+      <input ng-switch-default class="form-control" ng-model="arg.value" crm-ui-select="{data: $ctrl.getFields, placeholder: ts('Field')}" ng-change="$ctrl.changeArg($index)">
+    </span>
+  </div>
+  <div class="btn-group" ng-if="$ctrl.args.length < $ctrl.fn.params[0].max_expr">
+    <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <i class="crm-i fa-plus"></i> <span class="caret"></span>
+    </button>
+    <ul class="dropdown-menu">
+      <li ng-repeat="(name, type) in $ctrl.exprTypes" ng-show="$ctrl.fn.params[0].must_be.indexOf(name) >= 0">
+        <a href ng-click="$ctrl.addArg(name)">{{ type.label }}</a>
+      </li>
+    </ul>
+  </div>
 </div>
index 36c751fc2f65e0a800663948bb46133d5ead4e88..f00dc3a831cc69286a061dd1a97c5cbb2d3c148c 100644 (file)
           if (!ui.item.sortable.dropindex && ctrl.crmSearchAdmin.groupExists) {
             ui.item.sortable.cancel();
           }
+          // Function selectors use `ng-repeat` with `track by $index` so must be refreshed when rearranging the array
+          ctrl.crmSearchAdmin.hideFuncitons();
         }
       };