SearchKit - Add basic SearchSegment functionality for numeric range groups
authorColeman Watts <coleman@civicrm.org>
Wed, 30 Mar 2022 01:55:35 +0000 (21:55 -0400)
committerColeman Watts <coleman@civicrm.org>
Mon, 18 Apr 2022 18:34:09 +0000 (14:34 -0400)
ext/search_kit/Civi/Api4/Service/Spec/Provider/SearchSegmentExtraFieldProvider.php [new file with mode: 0644]
ext/search_kit/Civi/Api4/Service/Spec/Provider/SearchSegmentSpecProvider.php [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/crmSearchAdminDisplay.component.js
ext/search_kit/search_kit.php
ext/search_kit/tests/phpunit/api/v4/SearchSegment/SearchSegmentTest.php [new file with mode: 0644]

diff --git a/ext/search_kit/Civi/Api4/Service/Spec/Provider/SearchSegmentExtraFieldProvider.php b/ext/search_kit/Civi/Api4/Service/Spec/Provider/SearchSegmentExtraFieldProvider.php
new file mode 100644 (file)
index 0000000..e2e18fd
--- /dev/null
@@ -0,0 +1,97 @@
+<?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\Service\Spec\Provider;
+
+use Civi\Api4\SearchSegment;
+use Civi\Api4\Service\Spec\FieldSpec;
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class SearchSegmentExtraFieldProvider implements Generic\SpecProviderInterface {
+
+  /**
+   * @inheritDoc
+   */
+  public function modifySpec(RequestSpec $spec) {
+    foreach (self::getSets($spec->getEntity()) as $fullName => $set) {
+      $field = new FieldSpec($fullName, $spec->getEntity());
+      $field->setLabel($set['label']);
+      $field->setColumnName($set['field_name']);
+      $field->setOptions(array_column($set['items'], 'label'));
+      $field->setSuffixes(['label']);
+      $field->setSqlRenderer([__CLASS__, 'renderSql']);
+      $spec->addFieldSpec($field);
+    }
+  }
+
+  /**
+   * @inheritDoc
+   */
+  public function applies($entity, $action) {
+    return $entity !== 'SearchSegment' && $action === 'get';
+  }
+
+  /**
+   * @param string $entity
+   * @return array[]
+   */
+  private static function getSets($entity) {
+    if (!isset(\Civi::$statics['all_search_segments'])) {
+      \Civi::$statics['all_search_segments'] = [];
+      try {
+        $searchSegments = SearchSegment::get(FALSE)->addOrderBy('label')->execute();
+      }
+      // Suppress SearchSegment BAO/table not found error e.g. during upgrade mode
+      catch (\Exception $e) {
+        return [];
+      }
+      foreach ($searchSegments as $set) {
+        \Civi::$statics['all_search_segments'][$set['entity_name']]['segment_' . $set['name']] = $set;
+      }
+    }
+    return \Civi::$statics['all_search_segments'][$entity] ?? [];
+  }
+
+  /**
+   * Generates the sql case statement with a clause for each item.
+   *
+   * @param array $field
+   * @return string
+   */
+  public static function renderSql(array $field): string {
+    $set = self::getSets($field['entity'])[$field['name']];
+    $sqlName = $field['sql_name'];
+    $cases = [];
+    foreach ($set['items'] as $index => $item) {
+      $conditions = [];
+      if (isset($item['min'])) {
+        $conditions[] = $sqlName . ' >= ' . (float) $item['min'];
+      }
+      if (isset($item['max'])) {
+        $conditions[] = $sqlName . ' < ' . (float) $item['max'];
+      }
+      // If no conditions, this is the ELSE clause
+      if (!$conditions) {
+        $elseClause = 'ELSE ' . (int) $index;
+      }
+      else {
+        $cases[] = 'WHEN ' . implode(' AND ', $conditions) . ' THEN ' . (int) $index;
+      }
+    }
+    // Place ELSE clause at the end
+    if (isset($elseClause)) {
+      $cases[] = $elseClause;
+    }
+    return 'CASE ' . implode("\n  ", $cases) . "\nEND";
+  }
+
+}
diff --git a/ext/search_kit/Civi/Api4/Service/Spec/Provider/SearchSegmentSpecProvider.php b/ext/search_kit/Civi/Api4/Service/Spec/Provider/SearchSegmentSpecProvider.php
new file mode 100644 (file)
index 0000000..10fe1bc
--- /dev/null
@@ -0,0 +1,33 @@
+<?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\Service\Spec\Provider;
+
+use Civi\Api4\Service\Spec\RequestSpec;
+
+class SearchSegmentSpecProvider implements Generic\SpecProviderInterface {
+
+  /**
+   * @inheritDoc
+   */
+  public function modifySpec(RequestSpec $spec) {
+    $spec->getFieldByName('name')->setRequired(FALSE);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  public function applies($entity, $action) {
+    return $entity === 'SearchSegment';
+  }
+
+}
index bf6311ebbbc1c3c696e8479777efb4ada33343ef..646f494a3971e6a6831a99d5da866b45688f6f80 100644 (file)
       };
 
       $scope.fieldsForGroupBy = function() {
-        return {results: ctrl.getAllFields('', ['Field', 'Custom'], function(key) {
+        return {results: ctrl.getAllFields('', ['Field', 'Custom', 'Extra'], function(key) {
             return _.contains(ctrl.savedSearch.api_params.groupBy, key);
           })
         };
index 39f15f6325812185f462ab4869a30036a2409b7d..5956e2b3b64800446ac1000635ad3f25ffe4f809 100644 (file)
               text: ts('Columns'),
               children: ctrl.crmSearchAdmin.getSelectFields(disabledIf)
             }
-          ].concat(ctrl.crmSearchAdmin.getAllFields('', ['Field', 'Custom'], disabledIf))
+          ].concat(ctrl.crmSearchAdmin.getAllFields('', ['Field', 'Custom', 'Extra'], disabledIf))
         };
       };
 
index b3163c327adae39e1922c958e98f299f7c8ecd94..bf742583e3c294ec23ab2ccfd1f13f8dbbe3b74e 100644 (file)
@@ -92,3 +92,14 @@ function search_kit_civicrm_pre($op, $entity, $id, &$params) {
       ->execute();
   }
 }
+
+/**
+ * Implements hook_civicrm_post().
+ */
+function search_kit_civicrm_post($op, $entity, $id, $object) {
+  // Flush fieldSpec cache when saving a SearchSegment
+  if ($entity === 'SearchSegment') {
+    \Civi::$statics['all_search_segments'] = NULL;
+    \Civi::cache('metadata')->clear();
+  }
+}
diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchSegment/SearchSegmentTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchSegment/SearchSegmentTest.php
new file mode 100644 (file)
index 0000000..dfc4523
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+namespace api\v4\SearchDisplay;
+
+use Civi\Api4\Contact;
+use Civi\Api4\Contribution;
+use Civi\Api4\SearchSegment;
+use Civi\Test\HeadlessInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * @group headless
+ */
+class SearchSegmentTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, TransactionalInterface {
+
+  public function setUpHeadless() {
+    // Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
+    // See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
+    return \Civi\Test::headless()
+      ->installMe(__DIR__)
+      ->apply();
+  }
+
+  /**
+   * Test running a searchDisplay with a numeric range segment.
+   */
+  public function testRangeSearchSegment() {
+    $cid = Contact::create(FALSE)->execute()->single()['id'];
+
+    $sampleData = [
+      ['total_amount' => 1.5],
+      ['total_amount' => 10],
+      ['total_amount' => 20],
+      ['total_amount' => 25],
+      ['total_amount' => 32],
+      ['total_amount' => 33],
+      ['total_amount' => 56],
+    ];
+    Contribution::save(FALSE)
+      ->addDefault('contact_id', $cid)
+      ->addDefault('financial_type_id:name', 'Donation')
+      ->addDefault('receive_date', 'now')
+      ->setRecords($sampleData)->execute();
+
+    SearchSegment::create(FALSE)
+      ->addValue('label', 'Giving Tier')
+      ->addValue('entity_name', 'Contribution')
+      ->addValue('field_name', 'total_amount')
+      ->addValue('description', 'Tiers by donation amount')
+      ->addValue('items', [
+        // Only a max means no minimum
+        [
+          'label' => 'Low ball',
+          'max' => 10,
+        ],
+        [
+          'label' => 'Minor league',
+          'min' => 10,
+          'max' => 25,
+        ],
+        [
+          'label' => 'Major league',
+          'min' => 25,
+          'max' => 40,
+        ],
+        // No conditions makes this the ELSE clause
+        [
+          'label' => 'Heavy hitter',
+        ],
+      ])
+      ->execute();
+
+    $getField = Contribution::getFields(FALSE)
+      ->addWhere('name', '=', 'segment_Giving_Tier')
+      ->setLoadOptions(TRUE)
+      ->execute()->single();
+
+    $this->assertEquals('Giving Tier', $getField['label']);
+    $this->assertEquals(['Low ball', 'Minor league', 'Major league', 'Heavy hitter'], $getField['options']);
+
+    $params = [
+      'checkPermissions' => FALSE,
+      'return' => 'page:1',
+      'savedSearch' => [
+        'api_entity' => 'Contribution',
+        'api_params' => [
+          'version' => 4,
+          'select' => [
+            'segment_Giving_Tier:label',
+            'AVG(total_amount) AS AVG_total_amount',
+            'COUNT(total_amount) AS COUNT_total_amount',
+          ],
+          'where' => [['contact_id', '=', $cid]],
+          'groupBy' => [
+            'segment_Giving_Tier',
+          ],
+          'join' => [],
+          'having' => [],
+        ],
+      ],
+    ];
+
+    $result = civicrm_api4('SearchDisplay', 'run', $params);
+    $this->assertCount(4, $result);
+
+    $this->assertEquals('Low ball', $result[0]['columns'][0]['val']);
+    $this->assertEquals(1.5, $result[0]['data']['AVG_total_amount']);
+    $this->assertEquals(1, $result[0]['data']['COUNT_total_amount']);
+
+    $this->assertEquals('Minor league', $result[1]['columns'][0]['val']);
+    $this->assertEquals(15.0, $result[1]['data']['AVG_total_amount']);
+    $this->assertEquals(2, $result[1]['data']['COUNT_total_amount']);
+
+    $this->assertEquals('Major league', $result[2]['columns'][0]['val']);
+    $this->assertEquals(30.0, $result[2]['data']['AVG_total_amount']);
+    $this->assertEquals(3, $result[2]['data']['COUNT_total_amount']);
+
+    $this->assertEquals('Heavy hitter', $result[3]['columns'][0]['val']);
+    $this->assertEquals(56.0, $result[3]['data']['AVG_total_amount']);
+    $this->assertEquals(1, $result[3]['data']['COUNT_total_amount']);
+  }
+
+}