Add CONCAT_WS fn to APIv4 and UI support in SearchKit
authorColeman Watts <coleman@civicrm.org>
Sun, 19 Sep 2021 03:32:04 +0000 (23:32 -0400)
committerColeman Watts <coleman@civicrm.org>
Sun, 19 Sep 2021 03:40:02 +0000 (23:40 -0400)
12 files changed:
Civi/Api4/Query/SqlFunctionCOALESCE.php
Civi/Api4/Query/SqlFunctionCONCAT.php
Civi/Api4/Query/SqlFunctionCONCAT_WS.php [new file with mode: 0644]
Civi/Api4/Query/SqlFunctionGREATEST.php
Civi/Api4/Query/SqlFunctionIF.php
Civi/Api4/Query/SqlFunctionLEAST.php
Civi/Api4/Query/SqlFunctionNULLIF.php
Civi/Api4/Query/SqlFunctionREPLACE.php
Civi/Api4/Query/SqlFunctionROUND.php
ext/search_kit/ang/crmSearchAdmin/crmSearchFunction.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchFunction.html
tests/phpunit/api/v4/Action/SqlFunctionTest.php

index fc5765f6dfe17c1336a07e7d0c019c392cb4e2fd..5264e04ef1432beeb220bed94daf4db6860eab86 100644 (file)
@@ -25,6 +25,10 @@ class SqlFunctionCOALESCE extends SqlFunction {
       [
         'max_expr' => 99,
         'optional' => FALSE,
+        'ui_defaults' => [
+          ['type' => 'SqlField', 'placeholder' => ts('If')],
+          ['type' => 'SqlField', 'placeholder' => ts('Else')],
+        ],
       ],
     ];
   }
index 4d6437eb4a697788280fd2d22158da16386aa4aa..3659fa2d137ca625aa3eec13cc020c8448c17a64 100644 (file)
@@ -26,6 +26,9 @@ class SqlFunctionCONCAT extends SqlFunction {
         'max_expr' => 99,
         'optional' => FALSE,
         'must_be' => ['SqlField', 'SqlString'],
+        'ui_defaults' => [
+          ['placeholder' => ts('Plus')],
+        ],
       ],
     ];
   }
@@ -34,14 +37,14 @@ class SqlFunctionCONCAT extends SqlFunction {
    * @return string
    */
   public static function getTitle(): string {
-    return ts('Combine text');
+    return ts('Combine if');
   }
 
   /**
    * @return string
    */
   public static function getDescription(): string {
-    return ts('Multiple values concatenated into a single string.');
+    return ts('Joined text, only if all values are not null.');
   }
 
 }
diff --git a/Civi/Api4/Query/SqlFunctionCONCAT_WS.php b/Civi/Api4/Query/SqlFunctionCONCAT_WS.php
new file mode 100644 (file)
index 0000000..d381baf
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Api4\Query;
+
+/**
+ * Sql function
+ */
+class SqlFunctionCONCAT_WS extends SqlFunction {
+
+  protected static $category = self::CATEGORY_STRING;
+
+  protected static $dataType = 'String';
+
+  protected static function params(): array {
+    return [
+      [
+        'max_expr' => 99,
+        'optional' => FALSE,
+        'must_be' => ['SqlField', 'SqlString'],
+        'ui_defaults' => [
+          ['type' => 'SqlString', 'placeholder' => ts('Separator')],
+          ['type' => 'SqlField', 'placeholder' => ts('Plus')],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @return string
+   */
+  public static function getTitle(): string {
+    return ts('Combine text');
+  }
+
+  /**
+   * @return string
+   */
+  public static function getDescription(): string {
+    return ts('Every non-null value joined by a separator.');
+  }
+
+}
index 6a61daad32d5cb97e2825ba4199d117373c8811c..a76e35b14fa0b0ed2062a3d8f3a8309ff43d9880 100644 (file)
@@ -26,6 +26,10 @@ class SqlFunctionGREATEST extends SqlFunction {
         'max_expr' => 99,
         'min_expr' => 2,
         'optional' => FALSE,
+        'ui_defaults' => [
+          ['type' => 'SqlField', 'placeholder' => ts('If')],
+          ['type' => 'SqlField', 'placeholder' => ts('Else')],
+        ],
       ],
     ];
   }
index 66916a46f7483ed1aab89118d31b1a376ea34fb6..24e2faa65a8b7d1be7e637dd90325b96fe4433a4 100644 (file)
@@ -26,6 +26,11 @@ class SqlFunctionIF extends SqlFunction {
         'min_expr' => 3,
         'max_expr' => 3,
         'optional' => FALSE,
+        'ui_defaults' => [
+          ['type' => 'SqlField', 'placeholder' => ts('If')],
+          ['type' => 'SqlField', 'placeholder' => ts('Then')],
+          ['type' => 'SqlField', 'placeholder' => ts('Else')],
+        ],
       ],
     ];
   }
index 4b9d4e66e57a8c1ef3efec30fe7b4c9e3a98b924..a79ee4a8a29b19945d92f4f8cd50fbc7fe90a820 100644 (file)
@@ -26,6 +26,10 @@ class SqlFunctionLEAST extends SqlFunction {
         'max_expr' => 99,
         'min_expr' => 2,
         'optional' => FALSE,
+        'ui_defaults' => [
+          ['type' => 'SqlField', 'placeholder' => ts('If')],
+          ['type' => 'SqlField', 'placeholder' => ts('Else')],
+        ],
       ],
     ];
   }
index 6cb6b2fb78155d457da1af75c0463edfb2b66c12..bda6a8e2e828237e81be9f20fb0c6d63e6357f8e 100644 (file)
@@ -26,6 +26,10 @@ class SqlFunctionNULLIF extends SqlFunction {
         'min_expr' => 2,
         'max_expr' => 2,
         'optional' => FALSE,
+        'ui_defaults' => [
+          ['type' => 'SqlField', 'placeholder' => ts('Preferred')],
+          ['type' => 'SqlField', 'placeholder' => ts('Alternate')],
+        ],
       ],
     ];
   }
index 916678db0fa6736859ca587666e359f0f59c51cd..f5a1cfbbea09c00b86b8f0cb541e54f83188b85a 100644 (file)
@@ -25,6 +25,11 @@ class SqlFunctionREPLACE extends SqlFunction {
         'max_expr' => 3,
         'optional' => FALSE,
         'must_be' => ['SqlString', 'SqlField'],
+        'ui_defaults' => [
+          ['type' => 'SqlField', 'placeholder' => ts('Source')],
+          ['type' => 'SqlString', 'placeholder' => ts('Find')],
+          ['type' => 'SqlString', 'placeholder' => ts('Replace')],
+        ],
       ],
     ];
   }
index 285f43c621007348a96c8da66ed2ae636ed342df..6e09945ecd232994ded88cdff99e782ac687034b 100644 (file)
@@ -25,6 +25,10 @@ class SqlFunctionROUND extends SqlFunction {
         'min_expr' => 1,
         'max_expr' => 2,
         'must_be' => ['SqlNumber', 'SqlField'],
+        'ui_defaults' => [
+          ['type' => 'SqlField', 'placeholder' => ts('Number')],
+          ['type' => 'SqlNumber', 'placeholder' => ts('Decimals')],
+        ],
       ],
     ];
   }
index cebe91534ad56fadd41cd038fbc465d13122d277..25e7528269d8990db942ab21844170bf86514b56 100644 (file)
@@ -13,6 +13,8 @@
       var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
         ctrl = this;
 
+      var defaultUiDefaults = {type: 'SqlField', placeholder: ts('Select')};
+
       var allTypes = {
         aggregate: ts('Aggregate'),
         comparison: ts('Comparison'),
@@ -29,6 +31,7 @@
 
       this.$onInit = function() {
         var info = searchMeta.parseExpr(ctrl.expr);
+        ctrl.fieldArg = _.findWhere(info.args, {type: 'field'});
         ctrl.args = info.args;
         ctrl.fn = info.fn;
         ctrl.fnName = !info.fn ? '' : info.fn.name;
@@ -36,7 +39,7 @@
       };
 
       this.addArg = function(exprType) {
-        exprType = exprType || ctrl.fn.params[0].must_be[0];
+        exprType = exprType || ctrl.getUiDefault(ctrl.args.length).type;
         ctrl.args.push({
           type: ctrl.exprTypes[exprType].type,
           value: exprType === 'SqlNumber' ? 0 : ''
           ctrl.modifierName = null;
           ctrl.modifier = null;
         }
+        // Push args to reach the minimum
         while (ctrl.args.length < ctrl.fn.params[0].min_expr) {
           ctrl.addArg();
         }
       }
 
+      this.getUiDefault = function(index) {
+        if (ctrl.fn.params[0].ui_defaults) {
+          return ctrl.fn.params[0].ui_defaults[index] || _.last(ctrl.fn.params[0].ui_defaults);
+        }
+        defaultUiDefaults.type = ctrl.fn.params[0].must_be[0];
+        return defaultUiDefaults;
+      };
+
       // On-demand options for dropdown function selector
       this.getFunctions = function() {
         var allowedTypes = [], functions = [];
-        if (ctrl.expr && ctrl.args[0] && ctrl.args[0].field) {
+        if (ctrl.expr && ctrl.fieldArg) {
           if (ctrl.crmSearchAdmin.canAggregate(ctrl.expr)) {
             allowedTypes.push('aggregate');
           } else {
             allowedTypes.push('comparison', 'string');
-            if (_.includes(['Integer', 'Float', 'Date', 'Timestamp'], ctrl.args[0].field.data_type)) {
+            if (_.includes(['Integer', 'Float', 'Date', 'Timestamp'], ctrl.fieldArg.field.data_type)) {
               allowedTypes.push('math');
             }
-            if (_.includes(['Date', 'Timestamp'], ctrl.args[0].field.data_type)) {
+            if (_.includes(['Date', 'Timestamp'], ctrl.fieldArg.field.data_type)) {
               allowedTypes.push('date');
             }
           }
 
       this.selectFunction = function() {
         ctrl.fn = _.find(CRM.crmSearchAdmin.functions, {name: ctrl.fnName});
-        ctrl.args.length = 1;
-        initFunction();
+        ctrl.args = [ctrl.fieldArg];
+        if (ctrl.fn) {
+          var exprType, pos = 0,
+            uiDefaults = ctrl.fn.params[0].ui_defaults || [];
+          // Add non-field args to the beginning if needed
+          while (uiDefaults[pos] && uiDefaults[pos].type && uiDefaults[pos].type !== 'SqlField') {
+            exprType = uiDefaults[pos].type;
+            ctrl.args.splice(pos, 0, {
+              type: ctrl.exprTypes[exprType].type,
+              value: exprType === 'SqlNumber' ? 0 : ''
+            });
+            ++pos;
+          }
+          initFunction();
+        }
         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) {
+        if (index && !val && ctrl.args.length > ctrl.fn.params[0].min_expr) {
           ctrl.args.splice(index, 1);
         }
         ctrl.writeExpr();
index 5ba166840a3a5e618508e5a4dab7af85b73b9fd4..67c9b6c980c2e5e216bc2519fa7e7755757f60db 100644 (file)
@@ -1,15 +1,15 @@
 <div class="form-inline">
   <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>{{ $ctrl.fieldArg.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">
+  <div class="form-group" ng-repeat="arg in $ctrl.args" ng-if="arg !== $ctrl.fieldArg">
     <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)">
+      <input ng-switch-when="number" class="form-control" type="number" ng-model="arg.value" placeholder="{{ $ctrl.getUiDefault($index).placeholder }}" ng-change="$ctrl.changeArg($index)" ng-model-options="{updateOn: 'blur'}">
+      <input ng-switch-when="string" class="form-control" ng-model="arg.value" placeholder="{{ $ctrl.getUiDefault($index).placeholder }}" ng-change="$ctrl.changeArg($index)" ng-trim="false" ng-model-options="{updateOn: 'blur'}">
+      <input ng-switch-default class="form-control" ng-model="arg.value" crm-ui-select="{data: $ctrl.getFields, placeholder: $ctrl.getUiDefault($index).placeholder}" ng-change="$ctrl.changeArg($index)">
     </span>
   </div>
   <div class="btn-group" ng-if="$ctrl.args.length < $ctrl.fn.params[0].max_expr">
index ebac8d5d30cb7a81772039e6552763c335d2ea8b..9101fad3e42a33a120acf5b1e9e3a2ca2f1612f9 100644 (file)
@@ -191,6 +191,22 @@ class SqlFunctionTest extends UnitTestCase {
     $this->assertEquals(FALSE, $result[$aids[2]]['duration_isnull']);
   }
 
+  public function testStringFunctions() {
+    $sampleData = [
+      ['first_name' => 'abc', 'middle_name' => 'q', 'last_name' => 'tester1', 'source' => '123'],
+    ];
+    $cid = Contact::save(FALSE)
+      ->setRecords($sampleData)
+      ->execute()->first()['id'];
+
+    $result = Contact::get(FALSE)
+      ->addWhere('id', '=', $cid)
+      ->addSelect('CONCAT_WS("|", first_name, middle_name, last_name) AS concat_ws')
+      ->execute()->first();
+
+    $this->assertEquals('abc|q|tester1', $result['concat_ws']);
+  }
+
   public function testIncorrectNumberOfArguments() {
     try {
       Activity::get(FALSE)