SearchKit - Add display of type `entity`
authorColeman Watts <coleman@civicrm.org>
Sun, 19 Mar 2023 22:33:18 +0000 (18:33 -0400)
committercolemanw <coleman@civicrm.org>
Thu, 25 May 2023 19:09:09 +0000 (15:09 -0400)
An entity display does not produce user-facing output, instead it writes to a SQL table
which can then be queried from SearchKit, the API, or other SQL-based tools like Drupal Views.

The new table is static; this includes a scheduled job to refresh it (disabled by default).

17 files changed:
Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
Civi/Schema/Traits/OptionsSpecTrait.php
ext/search_kit/Civi/Api4/Action/SKEntity/GetRefreshDate.php [new file with mode: 0644]
ext/search_kit/Civi/Api4/Action/SKEntity/Refresh.php [new file with mode: 0644]
ext/search_kit/Civi/Api4/Event/Subscriber/SKEntitySubscriber.php [new file with mode: 0644]
ext/search_kit/Civi/Api4/SKEntity.php [new file with mode: 0644]
ext/search_kit/Civi/Api4/Service/Spec/Provider/SKEntitySpecProvider.php [new file with mode: 0644]
ext/search_kit/Civi/BAO/SK_Entity.php [new file with mode: 0644]
ext/search_kit/Civi/Search/Admin.php
ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js
ext/search_kit/ang/crmSearchAdmin/displays/crmSearchDisplayEntity.component.js [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.component.js [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.decorator.js [new file with mode: 0644]
ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.html [new file with mode: 0644]
ext/search_kit/managed/SearchDisplayType.mgd.php
ext/search_kit/search_kit.php
ext/search_kit/tests/phpunit/api/v4/SearchDisplay/EntityDisplayTest.php [new file with mode: 0644]

index 1e2b87ce66eb7af805d78e66a0ca8a6624cd51af..ce6cbb325b46569ed420a551cf2699aac3fa4caa 100644 (file)
@@ -46,13 +46,14 @@ trait SavedSearchInspectorTrait {
 
   /**
    * If SavedSearch is supplied as a string, this will load it as an array
+   * @param int|null $id
+   * @throws UnauthorizedException
    * @throws \CRM_Core_Exception
-   * @throws \Civi\API\Exception\UnauthorizedException
    */
-  protected function loadSavedSearch() {
-    if (is_string($this->savedSearch)) {
+  protected function loadSavedSearch(int $id = NULL) {
+    if ($id || is_string($this->savedSearch)) {
       $this->savedSearch = SavedSearch::get(FALSE)
-        ->addWhere('name', '=', $this->savedSearch)
+        ->addWhere($id ? 'id' : 'name', '=', $id ?: $this->savedSearch)
         ->execute()->single();
     }
     if (is_array($this->savedSearch)) {
@@ -64,6 +65,8 @@ trait SavedSearchInspectorTrait {
       ];
       $this->savedSearch['api_params'] += ['version' => 4, 'select' => [], 'where' => []];
     }
+    // Reset internal cached metadata
+    $this->_selectQuery = $this->_selectClause = $this->_searchEntityFields = NULL;
     $this->_apiParams = ($this->savedSearch['api_params'] ?? []) + ['select' => [], 'where' => []];
   }
 
index aebe2694bfdad7c52131a1c089c7575c7fa14754..22df97adb842bc921502c8064f1dbc1e823ad087 100644 (file)
@@ -35,6 +35,11 @@ trait OptionsSpecTrait {
    */
   private $optionsCallback;
 
+  /**
+   * @var array
+   */
+  private $optionsCallbackParams = [];
+
   /**
    * @param array $values
    * @param array|bool $return
@@ -45,7 +50,7 @@ trait OptionsSpecTrait {
   public function getOptions($values = [], $return = TRUE, $checkPermissions = TRUE) {
     if (!isset($this->options)) {
       if ($this->optionsCallback) {
-        $this->options = ($this->optionsCallback)($this, $values, $return, $checkPermissions);
+        $this->options = ($this->optionsCallback)($this, $values, $return, $checkPermissions, $this->optionsCallbackParams);
       }
       else {
         $this->options = FALSE;
@@ -76,11 +81,15 @@ trait OptionsSpecTrait {
 
   /**
    * @param callable $callback
-   *
+   *   Function to be called, will receive the following arguments:
+   *   ($this, $values, $returnFormat, $checkPermissions, $params)
+   * @param array $params
+   *   Array of optional extra data; sent as 5th argument to the callback
    * @return $this
    */
-  public function setOptionsCallback($callback) {
+  public function setOptionsCallback($callback, array $params = []) {
     $this->optionsCallback = $callback;
+    $this->optionsCallbackParams = $params;
     return $this;
   }
 
diff --git a/ext/search_kit/Civi/Api4/Action/SKEntity/GetRefreshDate.php b/ext/search_kit/Civi/Api4/Action/SKEntity/GetRefreshDate.php
new file mode 100644 (file)
index 0000000..c6da7a4
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace Civi\Api4\Action\SKEntity;
+
+use Civi\Api4\Generic\AbstractAction;
+use Civi\Api4\Generic\Result;
+
+/**
+ * Get the date the stored data was last refreshed for $ENTITY
+ *
+ * @package Civi\Api4\Action\SKEntity
+ */
+class GetRefreshDate extends AbstractAction {
+
+  /**
+   * @param \Civi\Api4\Generic\Result $result
+   * @throws \CRM_Core_Exception
+   */
+  public function _run(Result $result) {
+    [, $displayName] = explode('_', $this->getEntityName(), 2);
+    $tableName = _getSearchKitDisplayTableName($displayName);
+    $dbPath = explode('/', parse_url(CIVICRM_DSN, PHP_URL_PATH));
+    $dbName = end($dbPath);
+
+    $result[] = [
+      'refresh_date' => \CRM_Core_DAO::singleValueQuery("
+        SELECT UPDATE_TIME
+        FROM information_schema.tables
+        WHERE TABLE_SCHEMA = '$dbName'
+        AND TABLE_NAME = '$tableName'"),
+    ];
+  }
+
+}
diff --git a/ext/search_kit/Civi/Api4/Action/SKEntity/Refresh.php b/ext/search_kit/Civi/Api4/Action/SKEntity/Refresh.php
new file mode 100644 (file)
index 0000000..1e5a732
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+namespace Civi\Api4\Action\SKEntity;
+
+use Civi\API\Request;
+use Civi\Api4\Generic\AbstractAction;
+use Civi\Api4\Generic\Result;
+use Civi\Api4\Query\Api4SelectQuery;
+
+/**
+ * Store the results of a SearchDisplay as a SQL table.
+ *
+ * For displays of type `entity` which save to a DB table
+ * rather than outputting anything to the user.
+ *
+ * @package Civi\Api4\Action\SKEntity
+ */
+class Refresh extends AbstractAction {
+
+  /**
+   * @param \Civi\Api4\Generic\Result $result
+   * @throws \CRM_Core_Exception
+   */
+  public function _run(Result $result) {
+    [, $displayName] = explode('_', $this->getEntityName(), 2);
+    $display = \Civi\Api4\SearchDisplay::get(FALSE)
+      ->setSelect(['settings', 'saved_search_id.api_entity', 'saved_search_id.api_params'])
+      ->addWhere('type', '=', 'entity')
+      ->addWhere('name', '=', $displayName)
+      ->execute()->single();
+
+    $apiParams = $display['saved_search_id.api_params'];
+    foreach ($display['settings']['sort'] ?? [] as $item) {
+      $apiParams['orderBy'][$item[0]] = $item[1];
+    }
+    $api = Request::create($display['saved_search_id.api_entity'], 'get', $apiParams);
+    $query = new Api4SelectQuery($api);
+    $query->forceSelectId = FALSE;
+    $select = $query->getSql();
+    $tableName = _getSearchKitDisplayTableName($displayName);
+    $columnSpecs = array_column($display['settings']['columns'], 'spec');
+    $columns = implode(', ', array_column($columnSpecs, 'name'));
+    \CRM_Core_DAO::executeQuery("TRUNCATE TABLE `$tableName`");
+    \CRM_Core_DAO::executeQuery("INSERT INTO `$tableName` ($columns) $select");
+    $result[] = [
+      'refresh_date' => \CRM_Core_DAO::singleValueQuery("SELECT NOW()"),
+    ];
+  }
+
+}
diff --git a/ext/search_kit/Civi/Api4/Event/Subscriber/SKEntitySubscriber.php b/ext/search_kit/Civi/Api4/Event/Subscriber/SKEntitySubscriber.php
new file mode 100644 (file)
index 0000000..66952b2
--- /dev/null
@@ -0,0 +1,230 @@
+<?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\Event\Subscriber;
+
+use Civi\Api4\Generic\Traits\SavedSearchInspectorTrait;
+use Civi\Api4\Job;
+use Civi\Api4\SKEntity;
+use Civi\Api4\Utils\CoreUtil;
+use Civi\Core\Event\GenericHookEvent;
+use Civi\Core\Event\PostEvent;
+use Civi\Core\Event\PreEvent;
+use Civi\Core\Service\AutoService;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Manages tables and API entities created from search displays of type "entity"
+ * @service
+ * @internal
+ */
+class SKEntitySubscriber extends AutoService implements EventSubscriberInterface {
+
+  use SavedSearchInspectorTrait;
+
+  /**
+   * @return array
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      'civi.api4.entityTypes' => 'on_civi_api4_entityTypes',
+      'hook_civicrm_pre' => 'onPreSaveDisplay',
+      'hook_civicrm_post' => 'onPostSaveDisplay',
+    ];
+  }
+
+  /**
+   * Register SearchDisplays of type 'entity'
+   *
+   * @param \Civi\Core\Event\GenericHookEvent $event
+   */
+  public static function on_civi_api4_entityTypes(GenericHookEvent $event): void {
+    // Can't use the API to fetch search displays because this hook is called when the API boots
+    foreach (_getSearchKitEntityDisplays() as $display) {
+      $event->entities[$display['entityName']] = [
+        'name' => $display['entityName'],
+        'title' => $display['label'],
+        'title_plural' => $display['label'],
+        'description' => $display['settings']['description'] ?? NULL,
+        'primary_key' => ['_row'],
+        'type' => ['SavedSearch'],
+        'table_name' => $display['tableName'],
+        'class_args' => [$display['name']],
+        'label_field' => NULL,
+        'searchable' => 'secondary',
+        'class' => SKEntity::class,
+        'icon' => 'fa-search-plus',
+      ];
+    }
+  }
+
+  /**
+   * @param \Civi\Core\Event\PreEvent $event
+   */
+  public function onPreSaveDisplay(PreEvent $event): void {
+    if (!$this->applies($event)) {
+      return;
+    }
+    $oldName = $event->id ? \CRM_Core_DAO::getFieldValue('CRM_Search_DAO_SearchDisplay', $event->id) : NULL;
+    $newName = $event->params['name'] ?? $oldName;
+    $newSettings = $event->params['settings'] ?? NULL;
+    // No changes made, nothing to do
+    if (!$newSettings && $oldName === $newName && $event->action !== 'delete') {
+      return;
+    }
+    // Drop the old table if it exists
+    if ($oldName) {
+      \CRM_Core_BAO_SchemaHandler::dropTable(_getSearchKitDisplayTableName($oldName));
+    }
+    if ($event->action === 'delete') {
+      // Delete scheduled jobs when deleting entity
+      Job::delete(FALSE)
+        ->addWhere('api_entity', '=', 'SK_' . $oldName)
+        ->execute();
+      return;
+    }
+    // Build the new table
+    $savedSearchID = $event->params['saved_search_id'] ?? \CRM_Core_DAO::getFieldValue('CRM_Search_DAO_SearchDisplay', $event->id, 'saved_search_id');
+    $this->loadSavedSearch($savedSearchID);
+    $table = [
+      'name' => _getSearchKitDisplayTableName($newName),
+      'is_multiple' => FALSE,
+      'attributes' => 'ENGINE=InnoDB',
+      'fields' => [],
+    ];
+    // Primary key field
+    $table['fields'][] = [
+      'name' => '_row',
+      'type' => 'int unsigned',
+      'primary' => TRUE,
+      'required' => TRUE,
+      'attributes' => 'AUTO_INCREMENT',
+      'comment' => 'Row number',
+    ];
+    foreach ($newSettings['columns'] as &$column) {
+      $expr = $this->getSelectExpression($column['key']);
+      if (!$expr) {
+        continue;
+      }
+      $column['spec'] = $this->formatFieldSpec($column, $expr);
+      $table['fields'][] = $this->formatSQLSpec($column, $expr);
+    }
+    // Store new settings with added column spec
+    $event->params['settings'] = $newSettings;
+    $sql = \CRM_Core_BAO_SchemaHandler::buildTableSQL($table);
+    // do not i18n-rewrite
+    \CRM_Core_DAO::executeQuery($sql, [], TRUE, NULL, FALSE, FALSE);
+  }
+
+  /**
+   * @param array $column
+   * @param array{fields: array, expr: SqlExpression, dataType: string} $expr
+   * @return array
+   */
+  private function formatFieldSpec(array $column, array $expr): array {
+    // Strip the pseuoconstant suffix
+    [$name, $suffix] = array_pad(explode(':', $column['key']), 2, NULL);
+    // Sanitize the name
+    $name = \CRM_Utils_String::munge($name, '_', 255);
+    $spec = [
+      'name' => $name,
+      'data_type' => $expr['dataType'],
+      'suffixes' => $suffix ? ['id', $suffix] : NULL,
+      'options' => FALSE,
+    ];
+    if ($expr['expr']->getType() === 'SqlField') {
+      $field = \CRM_Utils_Array::first($expr['fields']);
+      $spec['fk_entity'] = $field['fk_entity'] ?? NULL;
+      $spec['original_field_name'] = $field['name'];
+      $spec['original_field_entity'] = $field['entity'];
+      if ($suffix) {
+        // Options will be looked up by SKEntitySpecProvider::getOptionsForSKEntityField
+        $spec['options'] = TRUE;
+      }
+    }
+    elseif ($expr['expr']->getType() === 'SqlFunction') {
+      if ($suffix) {
+        $spec['options'] = CoreUtil::formatOptionList($expr['expr']::getOptions(), $spec['suffixes']);
+      }
+    }
+    return $spec;
+  }
+
+  /**
+   * @param array $column
+   * @param array{fields: array, expr: SqlExpression, dataType: string} $expr
+   * @return array
+   */
+  private function formatSQLSpec(array $column, array $expr): array {
+    // Try to use the exact sql column type as the original field
+    $field = \CRM_Utils_Array::first($expr['fields']);
+    if (!empty($field['column_name']) && !empty($field['table_name'])) {
+      $columns = \CRM_Core_DAO::executeQuery("DESCRIBE `{$field['table_name']}`")
+        ->fetchMap('Field', 'Type');
+      $type = $columns[$field['column_name']] ?? NULL;
+    }
+    // If we can't get the exact data type from the column, take an educated guess
+    if (empty($type) ||
+      ($expr['expr']->getType() !== 'SqlField' && $field['data_type'] !== $expr['dataType'])
+    ) {
+      $map = [
+        'Array' => 'text',
+        'Boolean' => 'tinyint',
+        'Date' => 'date',
+        'Float' => 'double',
+        'Integer' => 'int',
+        'String' => 'text',
+        'Text' => 'text',
+        'Timestamp' => 'datetime',
+      ];
+      $type = $map[$expr['dataType']] ?? $type;
+    }
+    $defn = [
+      'name' => $column['spec']['name'],
+      'type' => $type,
+      // Adds an index to non-fk fields
+      'searchable' => TRUE,
+    ];
+    // Add FK indexes
+    if ($expr['expr']->getType() === 'SqlField' && !empty($field['fk_entity'])) {
+      $defn['fk_table_name'] = CoreUtil::getTableName($field['fk_entity']);
+      // FIXME look up fk_field_name from schema, don't assume it's always "id"
+      $defn['fk_field_name'] = 'id';
+      $defn['fk_attributes'] = ' ON DELETE SET NULL';
+    }
+    return $defn;
+  }
+
+  /**
+   * @param \Civi\Core\Event\PostEvent $event
+   */
+  public function onPostSaveDisplay(PostEvent $event): void {
+    if ($this->applies($event)) {
+      \CRM_Core_DAO_AllCoreTables::flush();
+      \Civi::cache('metadata')->clear();
+    }
+  }
+
+  /**
+   * Check if pre/post hook applies to a SearchDisplay type 'entity'
+   *
+   * @param \Civi\Core\Event\PreEvent|\Civi\Core\Event\PostEvent $event
+   * @return bool
+   */
+  private function applies(GenericHookEvent $event): bool {
+    if ($event->entity !== 'SearchDisplay') {
+      return FALSE;
+    }
+    $type = $event->params['type'] ?? $event->object->type ?? \CRM_Core_DAO::getFieldValue('CRM_Search_DAO_SearchDisplay', $event->id, 'type');
+    return $type === 'entity';
+  }
+
+}
diff --git a/ext/search_kit/Civi/Api4/SKEntity.php b/ext/search_kit/Civi/Api4/SKEntity.php
new file mode 100644 (file)
index 0000000..1a80f55
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+namespace Civi\Api4;
+
+/**
+ * Virtual API entities provided by SearchDisplays of type "entity"
+ * @package Civi\Api4
+ */
+class SKEntity {
+
+  /**
+   * @param string $displayEntity
+   * @param bool $checkPermissions
+   *
+   * @return \Civi\Api4\Generic\DAOGetFieldsAction
+   */
+  public static function getFields(string $displayEntity, bool $checkPermissions = TRUE): Generic\DAOGetFieldsAction {
+    return (new Generic\DAOGetFieldsAction('SK_' . $displayEntity, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @param string $displayEntity
+   * @param bool $checkPermissions
+   * @return \Civi\Api4\Generic\DAOGetAction
+   * @throws \CRM_Core_Exception
+   */
+  public static function get(string $displayEntity, bool $checkPermissions = TRUE): Generic\DAOGetAction {
+    return (new Generic\DAOGetAction('SK_' . $displayEntity, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @param string $displayEntity
+   * @param bool $checkPermissions
+   * @return \Civi\Api4\Action\SKEntity\Refresh
+   * @throws \CRM_Core_Exception
+   */
+  public static function refresh(string $displayEntity, bool $checkPermissions = TRUE): Action\SKEntity\Refresh {
+    return (new Action\SKEntity\Refresh('SK_' . $displayEntity, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @param string $displayEntity
+   * @param bool $checkPermissions
+   * @return \Civi\Api4\Action\SKEntity\GetRefreshDate
+   * @throws \CRM_Core_Exception
+   */
+  public static function getRefreshDate(string $displayEntity, bool $checkPermissions = TRUE): Action\SKEntity\GetRefreshDate {
+    return (new Action\SKEntity\GetRefreshDate('SK_' . $displayEntity, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @param string $displayEntity
+   * @param bool $checkPermissions
+   * @return \Civi\Api4\Action\GetActions
+   */
+  public static function getActions(string $displayEntity, bool $checkPermissions = TRUE): Action\GetActions {
+    return (new Action\GetActions('SK_' . $displayEntity, __FUNCTION__))
+      ->setCheckPermissions($checkPermissions);
+  }
+
+  /**
+   * @param string $displayEntity
+   * @return \Civi\Api4\Generic\CheckAccessAction
+   * @throws \CRM_Core_Exception
+   */
+  public static function checkAccess(string $displayEntity): Generic\CheckAccessAction {
+    return new Generic\CheckAccessAction('SK_' . $displayEntity, __FUNCTION__);
+  }
+
+  /**
+   * @return array
+   */
+  public static function permissions(): array {
+    return [
+      'meta' => ['access CiviCRM'],
+      'refresh' => [['administer CiviCRM data', 'administer search_kit']],
+      'getRefreshDate' => [['administer CiviCRM data', 'administer search_kit']],
+    ];
+  }
+
+}
diff --git a/ext/search_kit/Civi/Api4/Service/Spec/Provider/SKEntitySpecProvider.php b/ext/search_kit/Civi/Api4/Service/Spec/Provider/SKEntitySpecProvider.php
new file mode 100644 (file)
index 0000000..7b101e8
--- /dev/null
@@ -0,0 +1,92 @@
+<?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\FieldSpec;
+use Civi\Api4\Service\Spec\Provider\Generic\SpecProviderInterface;
+use Civi\Api4\Service\Spec\RequestSpec;
+use Civi\Core\Service\AutoService;
+use CRM_Search_ExtensionUtil as E;
+
+/**
+ * @service
+ * @internal
+ */
+class SKEntitySpecProvider extends AutoService implements SpecProviderInterface {
+
+  /**
+   * @inheritDoc
+   * @throws \CRM_Core_Exception
+   */
+  public function modifySpec(RequestSpec $spec): void {
+    $entityName = $spec->getEntity();
+    foreach (_getSearchKitEntityDisplays() as $entityDisplay) {
+      if ($entityDisplay['entityName'] !== $entityName) {
+        continue;
+      }
+      // Primary key field
+      $field = new FieldSpec('_row', $entityName, 'Int');
+      $field->setTitle(E::ts('Row'));
+      $field->setLabel(E::ts('Row'));
+      $field->setType('Field');
+      $field->setDescription('Search result row number');
+      $field->setColumnName('_row');
+      $spec->addFieldSpec($field);
+
+      foreach ($entityDisplay['settings']['columns'] as $column) {
+        $field = new FieldSpec($column['spec']['name'], $entityName, $column['spec']['data_type']);
+        $field->setTitle($column['label']);
+        $field->setLabel($column['label']);
+        $field->setType('Field');
+        $field->setFkEntity($column['spec']['fk_entity']);
+        $field->setColumnName($column['spec']['name']);
+        $field->setSuffixes($column['spec']['suffixes']);
+        if (!empty($column['spec']['options'])) {
+          if (is_array($column['spec']['options'])) {
+            $field->setOptions($column['spec']['options']);
+          }
+          else {
+            $field->setOptionsCallback([__CLASS__, 'getOptionsForSKEntityField'], $column['spec']);
+          }
+        }
+        $spec->addFieldSpec($field);
+      }
+    }
+  }
+
+  /**
+   * @inheritDoc
+   */
+  public function applies($entity, $action): bool {
+    return strpos($entity, 'SK_') === 0;
+  }
+
+  /**
+   * Callback function retrieve options from original field.
+   *
+   * @param \Civi\Api4\Service\Spec\FieldSpec $spec
+   * @param array $values
+   * @param bool|array $returnFormat
+   * @param bool $checkPermissions
+   * @param array $params
+   * @return array|false
+   */
+  public static function getOptionsForSKEntityField($spec, $values, $returnFormat, $checkPermissions, $params) {
+    return civicrm_api4($params['original_field_entity'], 'getFields', [
+      'where' => [['name', '=', $params['original_field_name']]],
+      'loadOptions' => $returnFormat,
+      'checkPermissions' => FALSE,
+    ])->first()['options'];
+  }
+
+}
diff --git a/ext/search_kit/Civi/BAO/SK_Entity.php b/ext/search_kit/Civi/BAO/SK_Entity.php
new file mode 100644 (file)
index 0000000..787ac96
--- /dev/null
@@ -0,0 +1,73 @@
+<?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\BAO;
+
+use CRM_Core_DAO;
+
+/**
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+class SK_Entity extends CRM_Core_DAO {
+
+  /**
+   * This is the primary key - it has an underscore to prevent possible conflicts with other columns.
+   *
+   * @var int
+   */
+  protected $_row;
+
+  /**
+   * Primary key field.
+   *
+   * @var string[]
+   */
+  public static $_primaryKey = ['_row'];
+
+  /**
+   * Over-ride the parent to prevent a NULL return.
+   *
+   * @return array
+   */
+  public static function &fields(): array {
+    $result = [];
+    return $result;
+  }
+
+  /**
+   * @return bool
+   */
+  public static function tableHasBeenAdded(): bool {
+    return TRUE;
+  }
+
+  /**
+   * Defines the primary key(s).
+   *
+   * @return array
+   */
+  public function keys() {
+    return ['_row'];
+  }
+
+  /**
+   * Tells DB_DataObject which keys use autoincrement.
+   * Overrides the default 'id'.
+   *
+   * @return array
+   */
+  public function sequenceKey() {
+    return ['_row', TRUE];
+  }
+
+}
index 4bd91142941b9857387504c49e882a3e9c0862a1..a99dddfc475f33111fea31fb8c47f1c213d959df 100644 (file)
@@ -51,6 +51,10 @@ class Admin {
       'modules' => $extensions,
       'defaultContactType' => \CRM_Contact_BAO_ContactType::basicTypeInfo()['Individual']['name'] ?? NULL,
       'defaultDistanceUnit' => \CRM_Utils_Address::getDefaultDistanceUnit(),
+      'jobFrequency' => \Civi\Api4\Job::getFields()
+        ->addWhere('name', '=', 'run_frequency')
+        ->setLoadOptions(['id', 'label'])
+        ->execute()->first()['options'],
       'tags' => Tag::get()
         ->addSelect('id', 'name', 'color', 'is_selectable', 'description')
         ->addWhere('used_for', 'CONTAINS', 'civicrm_saved_search')
index d5dde6b1e3ab4b3bac1bca4fa3fada491099f92d..51f3bc0145811626b16068720080bd700a59214a 100644 (file)
 (function(angular, $, _) {
   "use strict";
 
-  angular.module('crmSearchAdmin').component('crmSearchAdmin', {
-    bindings: {
-      savedSearch: '<'
-    },
-    templateUrl: '~/crmSearchAdmin/crmSearchAdmin.html',
-    controller: function($scope, $element, $location, $timeout, crmApi4, dialogService, searchMeta, crmUiHelp) {
-      var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
-        ctrl = this,
-        afformLoad,
-        fieldsForJoinGetters = {};
-      $scope.hs = crmUiHelp({file: 'CRM/Search/Help/Compose'});
-
-      this.afformEnabled = 'org.civicrm.afform' in CRM.crmSearchAdmin.modules;
-      this.afformAdminEnabled = (CRM.checkPerm('administer CiviCRM') || CRM.checkPerm('administer afform')) &&
-        'org.civicrm.afform_admin' in CRM.crmSearchAdmin.modules;
-      this.displayTypes = _.indexBy(CRM.crmSearchAdmin.displayTypes, 'id');
-      this.searchDisplayPath = CRM.url('civicrm/search');
-      this.afformPath = CRM.url('civicrm/admin/afform');
-
-      $scope.controls = {tab: 'compose', joinType: 'LEFT'};
-      $scope.joinTypes = [
-        {k: 'LEFT', v: ts('With (optional)')},
-        {k: 'INNER', v: ts('With (required)')},
-        {k: 'EXCLUDE', v: ts('Without')},
-      ];
-      $scope.getEntity = searchMeta.getEntity;
-      $scope.getField = searchMeta.getField;
-      this.perm = {
-        editGroups: CRM.checkPerm('edit groups')
-      };
-
-      this.$onInit = function() {
-        this.entityTitle = searchMeta.getEntity(this.savedSearch.api_entity).title_plural;
-
-        this.savedSearch.displays = this.savedSearch.displays || [];
-        this.savedSearch.groups = this.savedSearch.groups || [];
-        this.savedSearch.tag_id = this.savedSearch.tag_id || [];
-        this.groupExists = !!this.savedSearch.groups.length;
-
-        if (!this.savedSearch.id) {
-          var defaults = {
-            version: 4,
-            select: getDefaultSelect(),
-            orderBy: {},
-            where: [],
-          };
-          _.each(['groupBy', 'join', 'having'], function(param) {
-            if (ctrl.paramExists(param)) {
-              defaults[param] = [];
-            }
-          });
-          // Default to Individuals
-          if (this.savedSearch.api_entity === 'Contact' && CRM.crmSearchAdmin.defaultContactType) {
-            defaults.where.push(['contact_type:name', '=', CRM.crmSearchAdmin.defaultContactType]);
+  // Hooks allow code outside this component to modify behaviors.
+  // Register a hook by decorating "crmSearchAdminDirective". Ex:
+  //   angular.module('myModule').decorator('crmSearchAdminDirective', function($delegate) {
+  //     $delegate[0].controller.hook.postSaveDisplay.push(function(display) {
+  //       console.log(display);
+  //     });
+  //     return $delegate;
+  //   });
+  var hook = {
+    preSaveDisplay: [],
+    postSaveDisplay: []
+  };
+
+  // Controller function for main crmSearchAdmin component
+  var ctrl = function($scope, $element, $location, $timeout, crmApi4, dialogService, searchMeta, crmUiHelp) {
+    var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
+      ctrl = this,
+      afformLoad,
+      fieldsForJoinGetters = {};
+    $scope.hs = crmUiHelp({file: 'CRM/Search/Help/Compose'});
+
+    this.afformEnabled = 'org.civicrm.afform' in CRM.crmSearchAdmin.modules;
+    this.afformAdminEnabled = (CRM.checkPerm('administer CiviCRM') || CRM.checkPerm('administer afform')) &&
+      'org.civicrm.afform_admin' in CRM.crmSearchAdmin.modules;
+    this.displayTypes = _.indexBy(CRM.crmSearchAdmin.displayTypes, 'id');
+    this.searchDisplayPath = CRM.url('civicrm/search');
+    this.afformPath = CRM.url('civicrm/admin/afform');
+
+    $scope.controls = {tab: 'compose', joinType: 'LEFT'};
+    $scope.joinTypes = [
+      {k: 'LEFT', v: ts('With (optional)')},
+      {k: 'INNER', v: ts('With (required)')},
+      {k: 'EXCLUDE', v: ts('Without')},
+    ];
+    $scope.getEntity = searchMeta.getEntity;
+    $scope.getField = searchMeta.getField;
+    this.perm = {
+      editGroups: CRM.checkPerm('edit groups')
+    };
+
+    this.$onInit = function() {
+      this.entityTitle = searchMeta.getEntity(this.savedSearch.api_entity).title_plural;
+
+      this.savedSearch.displays = this.savedSearch.displays || [];
+      this.savedSearch.groups = this.savedSearch.groups || [];
+      this.savedSearch.tag_id = this.savedSearch.tag_id || [];
+      this.groupExists = !!this.savedSearch.groups.length;
+
+      if (!this.savedSearch.id) {
+        var defaults = {
+          version: 4,
+          select: getDefaultSelect(),
+          orderBy: {},
+          where: [],
+        };
+        _.each(['groupBy', 'join', 'having'], function(param) {
+          if (ctrl.paramExists(param)) {
+            defaults[param] = [];
           }
+        });
+        // Default to Individuals
+        if (this.savedSearch.api_entity === 'Contact' && CRM.crmSearchAdmin.defaultContactType) {
+          defaults.where.push(['contact_type:name', '=', CRM.crmSearchAdmin.defaultContactType]);
+        }
 
-          $scope.$bindToRoute({
-            param: 'params',
-            expr: '$ctrl.savedSearch.api_params',
-            deep: true,
-            default: defaults
-          });
+        $scope.$bindToRoute({
+          param: 'params',
+          expr: '$ctrl.savedSearch.api_params',
+          deep: true,
+          default: defaults
+        });
 
-          $scope.$bindToRoute({
-            param: 'label',
-            expr: '$ctrl.savedSearch.label',
-            format: 'raw',
-            default: ''
-          });
-        }
+        $scope.$bindToRoute({
+          param: 'label',
+          expr: '$ctrl.savedSearch.label',
+          format: 'raw',
+          default: ''
+        });
+      }
 
-        $scope.mainEntitySelect = searchMeta.getPrimaryAndSecondaryEntitySelect();
+      $scope.mainEntitySelect = searchMeta.getPrimaryAndSecondaryEntitySelect();
 
-        $scope.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect);
+      $scope.$watchCollection('$ctrl.savedSearch.api_params.select', onChangeSelect);
 
-        $scope.$watch('$ctrl.savedSearch', onChangeAnything, true);
+      $scope.$watch('$ctrl.savedSearch', onChangeAnything, true);
 
-        // After watcher runs for the first time and messes up the status, set it correctly
-        $timeout(function() {
-          $scope.status = ctrl.savedSearch && ctrl.savedSearch.id ? 'saved' : 'unsaved';
-        });
+      // After watcher runs for the first time and messes up the status, set it correctly
+      $timeout(function() {
+        $scope.status = ctrl.savedSearch && ctrl.savedSearch.id ? 'saved' : 'unsaved';
+      });
 
-        loadFieldOptions();
-        loadAfforms();
-      };
+      loadFieldOptions();
+      loadAfforms();
+    };
 
-      function onChangeAnything() {
-        $scope.status = 'unsaved';
-      }
+    function onChangeAnything() {
+      $scope.status = 'unsaved';
+    }
 
-      this.save = function() {
-        if (!validate()) {
-          return;
+    this.save = function() {
+      if (!validate()) {
+        return;
+      }
+      $scope.status = 'saving';
+      var params = _.cloneDeep(ctrl.savedSearch),
+        apiCalls = {},
+        chain = {};
+      if (ctrl.groupExists) {
+        chain.groups = ['Group', 'save', {defaults: {saved_search_id: '$id'}, records: params.groups}];
+        delete params.groups;
+      } else if (params.id) {
+        apiCalls.deleteGroup = ['Group', 'delete', {where: [['saved_search_id', '=', params.id]]}];
+      }
+      _.remove(params.displays, {trashed: true});
+      if (params.displays && params.displays.length) {
+        // Call preSaveDisplay hook
+        if (hook.preSaveDisplay.length) {
+          params.displays.forEach(function(display) {
+            hook.preSaveDisplay.forEach(function(callback) {
+              callback(display, apiCalls);
+            });
+          });
         }
-        $scope.status = 'saving';
-        var params = _.cloneDeep(ctrl.savedSearch),
-          apiCalls = {},
-          chain = {};
-        if (ctrl.groupExists) {
-          chain.groups = ['Group', 'save', {defaults: {saved_search_id: '$id'}, records: params.groups}];
-          delete params.groups;
-        } else if (params.id) {
-          apiCalls.deleteGroup = ['Group', 'delete', {where: [['saved_search_id', '=', params.id]]}];
+        chain.displays = ['SearchDisplay', 'replace', {where: [['saved_search_id', '=', '$id']], records: params.displays}];
+      } else if (params.id) {
+        apiCalls.deleteDisplays = ['SearchDisplay', 'delete', {where: [['saved_search_id', '=', params.id]]}];
+      }
+      delete params.displays;
+      if (params.tag_id && params.tag_id.length) {
+        chain.tag_id = ['EntityTag', 'replace', {
+          where: [['entity_id', '=', '$id'], ['entity_table', '=', 'civicrm_saved_search']],
+          match: ['entity_id', 'entity_table', 'tag_id'],
+          records: _.transform(params.tag_id, function(records, id) {records.push({tag_id: id});})
+        }];
+      } else if (params.id) {
+        chain.tag_id = ['EntityTag', 'delete', {
+          where: [['entity_id', '=', '$id'], ['entity_table', '=', 'civicrm_saved_search']]
+        }];
+      }
+      delete params.tag_id;
+      apiCalls.saved = ['SavedSearch', 'save', {records: [params], chain: chain}, 0];
+      crmApi4(apiCalls).then(function(results) {
+        // Call postSaveDisplay hook
+        if (chain.displays && hook.postSaveDisplay.length) {
+          results.saved.displays.forEach(function(display) {
+            hook.postSaveDisplay.forEach(function(callback) {
+              callback(display, results);
+            });
+          });
         }
-        _.remove(params.displays, {trashed: true});
-        if (params.displays && params.displays.length) {
-          chain.displays = ['SearchDisplay', 'replace', {where: [['saved_search_id', '=', '$id']], records: params.displays}];
-        } else if (params.id) {
-          apiCalls.deleteDisplays = ['SearchDisplay', 'delete', {where: [['saved_search_id', '=', params.id]]}];
+        // After saving a new search, redirect to the edit url
+        if (!ctrl.savedSearch.id) {
+          $location.url('edit/' + results.saved.id);
         }
-        delete params.displays;
-        if (params.tag_id && params.tag_id.length) {
-          chain.tag_id = ['EntityTag', 'replace', {
-            where: [['entity_id', '=', '$id'], ['entity_table', '=', 'civicrm_saved_search']],
-            match: ['entity_id', 'entity_table', 'tag_id'],
-            records: _.transform(params.tag_id, function(records, id) {records.push({tag_id: id});})
-          }];
-        } else if (params.id) {
-          chain.tag_id = ['EntityTag', 'delete', {
-            where: [['entity_id', '=', '$id'], ['entity_table', '=', 'civicrm_saved_search']]
-          }];
+        // Set new status to saved unless the user changed something in the interim
+        var newStatus = $scope.status === 'unsaved' ? 'unsaved' : 'saved';
+        if (results.saved.groups && results.saved.groups.length) {
+          ctrl.savedSearch.groups[0].id = results.saved.groups[0].id;
         }
-        delete params.tag_id;
-        apiCalls.saved = ['SavedSearch', 'save', {records: [params], chain: chain}, 0];
-        crmApi4(apiCalls).then(function(results) {
-          // After saving a new search, redirect to the edit url
-          if (!ctrl.savedSearch.id) {
-            $location.url('edit/' + results.saved.id);
-          }
-          // Set new status to saved unless the user changed something in the interim
-          var newStatus = $scope.status === 'unsaved' ? 'unsaved' : 'saved';
-          if (results.saved.groups && results.saved.groups.length) {
-            ctrl.savedSearch.groups[0].id = results.saved.groups[0].id;
-          }
-          ctrl.savedSearch.displays = results.saved.displays || [];
-          // Wait until after onChangeAnything to update status
-          $timeout(function() {
-            $scope.status = newStatus;
-          });
-        });
-      };
-
-      this.paramExists = function(param) {
-        return _.includes(searchMeta.getEntity(ctrl.savedSearch.api_entity).params, param);
-      };
-
-      this.hasFunction = function(expr) {
-        return expr.indexOf('(') > -1;
-      };
-
-      this.addDisplay = function(type) {
-        var count = _.filter(ctrl.savedSearch.displays, {type: type}).length,
-          searchLabel = ctrl.savedSearch.label || searchMeta.getEntity(ctrl.savedSearch.api_entity).title_plural;
-        ctrl.savedSearch.displays.push({
-          type: type,
-          label: searchLabel + ' ' + ctrl.displayTypes[type].label + ' ' + (count + 1),
+        ctrl.savedSearch.displays = results.saved.displays || [];
+        // Wait until after onChangeAnything to update status
+        $timeout(function() {
+          $scope.status = newStatus;
         });
-        $scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
-      };
-
-      this.removeDisplay = function(index) {
-        var display = ctrl.savedSearch.displays[index];
-        if (display.id) {
-          display.trashed = !display.trashed;
-          if ($scope.controls.tab === ('display_' + index) && display.trashed) {
-            $scope.selectTab('compose');
-          } else if (!display.trashed) {
-            $scope.selectTab('display_' + index);
-          }
-          if (display.trashed && afformLoad) {
-            afformLoad.then(function() {
-              var displayForms = _.filter(ctrl.afforms, function(form) {
-                return _.includes(form.displays, ctrl.savedSearch.name + '.' + display.name);
-              });
-              if (displayForms.length) {
-                var msg = displayForms.length === 1 ?
-                  ts('Form "%1" will be deleted if the embedded display "%2" is deleted.', {1: displayForms[0].title, 2: display.label}) :
-                  ts('%1 forms will be deleted if the embedded display "%2" is deleted.', {1: displayForms.length, 2: display.label});
-                CRM.alert(msg, ts('Display embedded'), 'alert');
-              }
-            });
-          }
-        } else {
+      });
+    };
+
+    this.paramExists = function(param) {
+      return _.includes(searchMeta.getEntity(ctrl.savedSearch.api_entity).params, param);
+    };
+
+    this.hasFunction = function(expr) {
+      return expr.indexOf('(') > -1;
+    };
+
+    this.addDisplay = function(type) {
+      var count = _.filter(ctrl.savedSearch.displays, {type: type}).length,
+        searchLabel = ctrl.savedSearch.label || searchMeta.getEntity(ctrl.savedSearch.api_entity).title_plural;
+      ctrl.savedSearch.displays.push({
+        type: type,
+        label: searchLabel + ' ' + ctrl.displayTypes[type].label + ' ' + (count + 1),
+      });
+      $scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
+    };
+
+    this.removeDisplay = function(index) {
+      var display = ctrl.savedSearch.displays[index];
+      if (display.id) {
+        display.trashed = !display.trashed;
+        if ($scope.controls.tab === ('display_' + index) && display.trashed) {
           $scope.selectTab('compose');
-          ctrl.savedSearch.displays.splice(index, 1);
+        } else if (!display.trashed) {
+          $scope.selectTab('display_' + index);
         }
-      };
-
-      this.cloneDisplay = function(display) {
-        var newDisplay = angular.copy(display);
-        delete newDisplay.name;
-        delete newDisplay.id;
-        newDisplay.label += ts(' (copy)');
-        ctrl.savedSearch.displays.push(newDisplay);
-        $scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
-      };
-
-      this.addGroup = function() {
-        ctrl.savedSearch.groups.push({
-          title: '',
-          description: '',
-          visibility: 'User and User Admin Only',
-          group_type: []
-        });
-        ctrl.groupExists = true;
-        $scope.selectTab('group');
-      };
-
-      $scope.selectTab = function(tab) {
-        if (tab === 'group') {
-          loadFieldOptions('Group');
-          $scope.smartGroupColumns = searchMeta.getSmartGroupColumns(ctrl.savedSearch.api_entity, ctrl.savedSearch.api_params);
-          var smartGroupColumns = _.map($scope.smartGroupColumns, 'id');
-          if (smartGroupColumns.length && !_.includes(smartGroupColumns, ctrl.savedSearch.api_params.select[0])) {
-            ctrl.savedSearch.api_params.select.unshift(smartGroupColumns[0]);
-          }
-        }
-        ctrl.savedSearch.api_params.select = _.uniq(ctrl.savedSearch.api_params.select);
-        $scope.controls.tab = tab;
-      };
-
-      this.removeGroup = function() {
-        ctrl.groupExists = !ctrl.groupExists;
-        $scope.status = 'unsaved';
-        if (!ctrl.groupExists && (!ctrl.savedSearch.groups.length || !ctrl.savedSearch.groups[0].id)) {
-          ctrl.savedSearch.groups.length = 0;
+        if (display.trashed && afformLoad) {
+          afformLoad.then(function() {
+            var displayForms = _.filter(ctrl.afforms, function(form) {
+              return _.includes(form.displays, ctrl.savedSearch.name + '.' + display.name);
+            });
+            if (displayForms.length) {
+              var msg = displayForms.length === 1 ?
+                ts('Form "%1" will be deleted if the embedded display "%2" is deleted.', {1: displayForms[0].title, 2: display.label}) :
+                ts('%1 forms will be deleted if the embedded display "%2" is deleted.', {1: displayForms.length, 2: display.label});
+              CRM.alert(msg, ts('Display embedded'), 'alert');
+            }
+          });
         }
-        if ($scope.controls.tab === 'group') {
-          $scope.selectTab('compose');
+      } else {
+        $scope.selectTab('compose');
+        ctrl.savedSearch.displays.splice(index, 1);
+      }
+    };
+
+    this.cloneDisplay = function(display) {
+      var newDisplay = angular.copy(display);
+      delete newDisplay.name;
+      delete newDisplay.id;
+      newDisplay.label += ts(' (copy)');
+      ctrl.savedSearch.displays.push(newDisplay);
+      $scope.selectTab('display_' + (ctrl.savedSearch.displays.length - 1));
+    };
+
+    this.addGroup = function() {
+      ctrl.savedSearch.groups.push({
+        title: '',
+        description: '',
+        visibility: 'User and User Admin Only',
+        group_type: []
+      });
+      ctrl.groupExists = true;
+      $scope.selectTab('group');
+    };
+
+    $scope.selectTab = function(tab) {
+      if (tab === 'group') {
+        loadFieldOptions('Group');
+        $scope.smartGroupColumns = searchMeta.getSmartGroupColumns(ctrl.savedSearch.api_entity, ctrl.savedSearch.api_params);
+        var smartGroupColumns = _.map($scope.smartGroupColumns, 'id');
+        if (smartGroupColumns.length && !_.includes(smartGroupColumns, ctrl.savedSearch.api_params.select[0])) {
+          ctrl.savedSearch.api_params.select.unshift(smartGroupColumns[0]);
         }
-      };
-
-      function addNum(name, num) {
-        return name + (num < 10 ? '_0' : '_') + num;
       }
-
-      function getExistingJoins() {
-        return _.transform(ctrl.savedSearch.api_params.join || [], function(joins, join) {
-          joins[join[0].split(' AS ')[1]] = searchMeta.getJoin(join[0]);
-        }, {});
+      ctrl.savedSearch.api_params.select = _.uniq(ctrl.savedSearch.api_params.select);
+      $scope.controls.tab = tab;
+    };
+
+    this.removeGroup = function() {
+      ctrl.groupExists = !ctrl.groupExists;
+      $scope.status = 'unsaved';
+      if (!ctrl.groupExists && (!ctrl.savedSearch.groups.length || !ctrl.savedSearch.groups[0].id)) {
+        ctrl.savedSearch.groups.length = 0;
       }
+      if ($scope.controls.tab === 'group') {
+        $scope.selectTab('compose');
+      }
+    };
 
-      $scope.getJoin = searchMeta.getJoin;
-
-      $scope.getJoinEntities = function() {
-        var existingJoins = getExistingJoins();
+    function addNum(name, num) {
+      return name + (num < 10 ? '_0' : '_') + num;
+    }
 
-        function addEntityJoins(entity, stack, baseEntity) {
-          return _.transform(CRM.crmSearchAdmin.joins[entity], function(joinEntities, join) {
-            var num = 0;
-            if (
-              // Exclude joins that singly point back to the original entity
-              !(baseEntity === join.entity && !join.multi) &&
-              // Exclude joins to bridge tables
-              !searchMeta.getEntity(join.entity).bridge
-            ) {
-              do {
-                appendJoin(joinEntities, join, ++num, stack, entity);
-              } while (addNum((stack ? stack + '_' : '') + join.alias, num) in existingJoins);
-            }
-          }, []);
-        }
+    function getExistingJoins() {
+      return _.transform(ctrl.savedSearch.api_params.join || [], function(joins, join) {
+        joins[join[0].split(' AS ')[1]] = searchMeta.getJoin(join[0]);
+      }, {});
+    }
 
-        function appendJoin(collection, join, num, stack, baseEntity) {
-          var alias = addNum((stack ? stack + '_' : '') + join.alias, num),
-            opt = {
-              id: join.entity + ' AS ' + alias,
-              description: join.description,
-              text: join.label + (num > 1 ? ' ' + num : ''),
-              icon: searchMeta.getEntity(join.entity).icon,
-              disabled: alias in existingJoins
-            };
-          if (alias in existingJoins) {
-            opt.children = addEntityJoins(join.entity, alias, baseEntity);
+    $scope.getJoin = searchMeta.getJoin;
+
+    $scope.getJoinEntities = function() {
+      var existingJoins = getExistingJoins();
+
+      function addEntityJoins(entity, stack, baseEntity) {
+        return _.transform(CRM.crmSearchAdmin.joins[entity], function(joinEntities, join) {
+          var num = 0;
+          if (
+            // Exclude joins that singly point back to the original entity
+            !(baseEntity === join.entity && !join.multi) &&
+            // Exclude joins to bridge tables
+            !searchMeta.getEntity(join.entity).bridge
+          ) {
+            do {
+              appendJoin(joinEntities, join, ++num, stack, entity);
+            } while (addNum((stack ? stack + '_' : '') + join.alias, num) in existingJoins);
           }
-          collection.push(opt);
-        }
-
-        return {results: addEntityJoins(ctrl.savedSearch.api_entity)};
-      };
+        }, []);
+      }
 
-      this.addJoin = function(value) {
-        if (value) {
-          ctrl.savedSearch.api_params.join = ctrl.savedSearch.api_params.join || [];
-          var join = searchMeta.getJoin(value),
-            entity = searchMeta.getEntity(join.entity),
-            params = [value, $scope.controls.joinType || 'LEFT'];
-          _.each(_.cloneDeep(join.conditions), function(condition) {
-            params.push(condition);
-          });
-          _.each(_.cloneDeep(join.defaults), function(condition) {
-            params.push(condition);
-          });
-          ctrl.savedSearch.api_params.join.push(params);
-          if (entity.label_field && $scope.controls.joinType !== 'EXCLUDE') {
-            ctrl.savedSearch.api_params.select.push(join.alias + '.' + entity.label_field);
-          }
-          loadFieldOptions();
+      function appendJoin(collection, join, num, stack, baseEntity) {
+        var alias = addNum((stack ? stack + '_' : '') + join.alias, num),
+          opt = {
+            id: join.entity + ' AS ' + alias,
+            description: join.description,
+            text: join.label + (num > 1 ? ' ' + num : ''),
+            icon: searchMeta.getEntity(join.entity).icon,
+            disabled: alias in existingJoins
+          };
+        if (alias in existingJoins) {
+          opt.children = addEntityJoins(join.entity, alias, baseEntity);
         }
-      };
-
-      // Remove an explicit join + all SELECT, WHERE & other JOINs that use it
-      this.removeJoin = function(index) {
-        var alias = searchMeta.getJoin(ctrl.savedSearch.api_params.join[index][0]).alias;
-        ctrl.clearParam('join', index);
-        removeJoinStuff(alias);
-      };
+        collection.push(opt);
+      }
 
-      function removeJoinStuff(alias) {
-        _.remove(ctrl.savedSearch.api_params.select, function(item) {
-          var pattern = new RegExp('\\b' + alias + '\\.');
-          return pattern.test(item.split(' AS ')[0]);
-        });
-        _.remove(ctrl.savedSearch.api_params.where, function(clause) {
-          return clauseUsesJoin(clause, alias);
+      return {results: addEntityJoins(ctrl.savedSearch.api_entity)};
+    };
+
+    this.addJoin = function(value) {
+      if (value) {
+        ctrl.savedSearch.api_params.join = ctrl.savedSearch.api_params.join || [];
+        var join = searchMeta.getJoin(value),
+          entity = searchMeta.getEntity(join.entity),
+          params = [value, $scope.controls.joinType || 'LEFT'];
+        _.each(_.cloneDeep(join.conditions), function(condition) {
+          params.push(condition);
         });
-        _.eachRight(ctrl.savedSearch.api_params.join, function(item, i) {
-          var joinAlias = searchMeta.getJoin(item[0]).alias;
-          if (joinAlias !== alias && joinAlias.indexOf(alias) === 0) {
-            ctrl.removeJoin(i);
-          }
+        _.each(_.cloneDeep(join.defaults), function(condition) {
+          params.push(condition);
         });
+        ctrl.savedSearch.api_params.join.push(params);
+        if (entity.label_field && $scope.controls.joinType !== 'EXCLUDE') {
+          ctrl.savedSearch.api_params.select.push(join.alias + '.' + entity.label_field);
+        }
+        loadFieldOptions();
       }
+    };
+
+    // Remove an explicit join + all SELECT, WHERE & other JOINs that use it
+    this.removeJoin = function(index) {
+      var alias = searchMeta.getJoin(ctrl.savedSearch.api_params.join[index][0]).alias;
+      ctrl.clearParam('join', index);
+      removeJoinStuff(alias);
+    };
+
+    function removeJoinStuff(alias) {
+      _.remove(ctrl.savedSearch.api_params.select, function(item) {
+        var pattern = new RegExp('\\b' + alias + '\\.');
+        return pattern.test(item.split(' AS ')[0]);
+      });
+      _.remove(ctrl.savedSearch.api_params.where, function(clause) {
+        return clauseUsesJoin(clause, alias);
+      });
+      _.eachRight(ctrl.savedSearch.api_params.join, function(item, i) {
+        var joinAlias = searchMeta.getJoin(item[0]).alias;
+        if (joinAlias !== alias && joinAlias.indexOf(alias) === 0) {
+          ctrl.removeJoin(i);
+        }
+      });
+    }
 
-      this.changeJoinType = function(join) {
-        if (join[1] === 'EXCLUDE') {
-          removeJoinStuff(searchMeta.getJoin(join[0]).alias);
-        }
-      };
+    this.changeJoinType = function(join) {
+      if (join[1] === 'EXCLUDE') {
+        removeJoinStuff(searchMeta.getJoin(join[0]).alias);
+      }
+    };
 
-      $scope.changeGroupBy = function(idx) {
-        // When clearing a selection
-        if (!ctrl.savedSearch.api_params.groupBy[idx]) {
-          ctrl.clearParam('groupBy', idx);
+    $scope.changeGroupBy = function(idx) {
+      // When clearing a selection
+      if (!ctrl.savedSearch.api_params.groupBy[idx]) {
+        ctrl.clearParam('groupBy', idx);
+      }
+      reconcileAggregateColumns();
+    };
+
+    function reconcileAggregateColumns() {
+      _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
+        var info = searchMeta.parseExpr(col),
+          fieldExpr = (_.findWhere(info.args, {type: 'field'}) || {}).value;
+        if (ctrl.canAggregate(col)) {
+          // Ensure all non-grouped columns are aggregated if using GROUP BY
+          if (!info.fn || info.fn.category !== 'aggregate') {
+            var dflFn = searchMeta.getDefaultAggregateFn(info) || 'GROUP_CONCAT',
+              flagBefore = dflFn === 'GROUP_CONCAT' ? 'DISTINCT ' : '';
+            ctrl.savedSearch.api_params.select[pos] = dflFn + '(' + flagBefore + fieldExpr + ') AS ' + dflFn + '_' + fieldExpr.replace(/[.:]/g, '_');
+          }
+        } else {
+          // Remove aggregate functions when no grouping
+          if (info.fn && info.fn.category === 'aggregate') {
+            ctrl.savedSearch.api_params.select[pos] = fieldExpr;
+          }
         }
-        reconcileAggregateColumns();
-      };
+      });
+    }
 
-      function reconcileAggregateColumns() {
-        _.each(ctrl.savedSearch.api_params.select, function(col, pos) {
-          var info = searchMeta.parseExpr(col),
-            fieldExpr = (_.findWhere(info.args, {type: 'field'}) || {}).value;
-          if (ctrl.canAggregate(col)) {
-            // Ensure all non-grouped columns are aggregated if using GROUP BY
-            if (!info.fn || info.fn.category !== 'aggregate') {
-              var dflFn = searchMeta.getDefaultAggregateFn(info) || 'GROUP_CONCAT',
-                flagBefore = dflFn === 'GROUP_CONCAT' ? 'DISTINCT ' : '';
-              ctrl.savedSearch.api_params.select[pos] = dflFn + '(' + flagBefore + fieldExpr + ') AS ' + dflFn + '_' + fieldExpr.replace(/[.:]/g, '_');
-            }
-          } else {
-            // Remove aggregate functions when no grouping
-            if (info.fn && info.fn.category === 'aggregate') {
-              ctrl.savedSearch.api_params.select[pos] = fieldExpr;
-            }
-          }
+    function clauseUsesJoin(clause, alias) {
+      if (clause[0].indexOf(alias + '.') === 0) {
+        return true;
+      }
+      if (_.isArray(clause[1])) {
+        return clause[1].some(function(subClause) {
+          return clauseUsesJoin(subClause, alias);
         });
       }
+      return false;
+    }
 
-      function clauseUsesJoin(clause, alias) {
-        if (clause[0].indexOf(alias + '.') === 0) {
-          return true;
-        }
-        if (_.isArray(clause[1])) {
-          return clause[1].some(function(subClause) {
-            return clauseUsesJoin(subClause, alias);
-          });
-        }
+    // Returns true if a clause contains one of the
+    function clauseUsesFields(clause, fields) {
+      if (!fields || !fields.length) {
         return false;
       }
-
-      // Returns true if a clause contains one of the
-      function clauseUsesFields(clause, fields) {
-        if (!fields || !fields.length) {
-          return false;
-        }
-        if (_.includes(fields, clause[0])) {
-          return true;
-        }
-        if (_.isArray(clause[1])) {
-          return clause[1].some(function(subClause) {
-            return clauseUsesField(subClause, fields);
-          });
-        }
-        return false;
+      if (_.includes(fields, clause[0])) {
+        return true;
       }
-
-      function validate() {
-        var errors = [],
-          errorEl,
-          label,
-          tab;
-        if (!ctrl.savedSearch.label) {
-          errorEl = '#crm-saved-search-label';
-          label = ts('Search Label');
-          errors.push(ts('%1 is a required field.', {1: label}));
-        }
-        if (ctrl.groupExists && !ctrl.savedSearch.groups[0].title) {
-          errorEl = '#crm-search-admin-group-title';
-          label = ts('Group Title');
-          errors.push(ts('%1 is a required field.', {1: label}));
-          tab = 'group';
-        }
-        _.each(ctrl.savedSearch.displays, function(display, index) {
-          if (!display.trashed && !display.label) {
-            errorEl = '#crm-search-admin-display-label';
-            label = ts('Display Label');
-            errors.push(ts('%1 is a required field.', {1: label}));
-            tab = 'display_' + index;
-          }
+      if (_.isArray(clause[1])) {
+        return clause[1].some(function(subClause) {
+          return clauseUsesField(subClause, fields);
         });
-        if (errors.length) {
-          if (tab) {
-            $scope.selectTab(tab);
-          }
-          $(errorEl).crmError(errors.join('<br>'), ts('Error Saving'), {expires: 5000});
-        }
-        return !errors.length;
       }
+      return false;
+    }
 
-      this.addParam = function(name, value) {
-        if (value && !_.contains(ctrl.savedSearch.api_params[name], value)) {
-          ctrl.savedSearch.api_params[name].push(value);
-          // This needs to be called when adding a field as well as changing groupBy
-          reconcileAggregateColumns();
-        }
-      };
-
-      // 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) {
-          col = _.last(col.split(' AS '));
-          delete ctrl.savedSearch.api_params.orderBy[col];
-          _.remove(ctrl.savedSearch.api_params.having, function(clause) {
-            return clauseUsesFields(clause, [col]);
-          });
-        });
+    function validate() {
+      var errors = [],
+        errorEl,
+        label,
+        tab;
+      if (!ctrl.savedSearch.label) {
+        errorEl = '#crm-saved-search-label';
+        label = ts('Search Label');
+        errors.push(ts('%1 is a required field.', {1: label}));
       }
-
-      this.getFieldLabel = searchMeta.getDefaultLabel;
-
-      // Is a column eligible to use an aggregate function?
-      this.canAggregate = function(col) {
-        // If the query does not use grouping, never
-        if (!ctrl.savedSearch.api_params.groupBy || !ctrl.savedSearch.api_params.groupBy.length) {
-          return false;
-        }
-        var arg = _.findWhere(searchMeta.parseExpr(col).args, {type: 'field'}) || {};
-        // If the column is not a database field, no
-        if (!arg.field || !arg.field.entity || !_.includes(['Field', 'Custom', 'Extra'], arg.field.type)) {
-          return false;
+      if (ctrl.groupExists && !ctrl.savedSearch.groups[0].title) {
+        errorEl = '#crm-search-admin-group-title';
+        label = ts('Group Title');
+        errors.push(ts('%1 is a required field.', {1: label}));
+        tab = 'group';
+      }
+      _.each(ctrl.savedSearch.displays, function(display, index) {
+        if (!display.trashed && !display.label) {
+          errorEl = '#crm-search-admin-display-label';
+          label = ts('Display Label');
+          errors.push(ts('%1 is a required field.', {1: label}));
+          tab = 'display_' + index;
         }
-        // If the column is used for a groupBy, no
-        if (ctrl.savedSearch.api_params.groupBy.indexOf(arg.path) > -1) {
-          return false;
+      });
+      if (errors.length) {
+        if (tab) {
+          $scope.selectTab(tab);
         }
-        // If the entity this column belongs to is being grouped by primary key, then also no
-        var idField = searchMeta.getEntity(arg.field.entity).primary_key[0];
-        return ctrl.savedSearch.api_params.groupBy.indexOf(arg.prefix + idField) < 0;
-      };
+        $(errorEl).crmError(errors.join('<br>'), ts('Error Saving'), {expires: 5000});
+      }
+      return !errors.length;
+    }
 
-      $scope.fieldsForGroupBy = function() {
-        return {results: ctrl.getAllFields('', ['Field', 'Custom', 'Extra'], function(key) {
-            return _.contains(ctrl.savedSearch.api_params.groupBy, key);
-          })
-        };
-      };
+    this.addParam = function(name, value) {
+      if (value && !_.contains(ctrl.savedSearch.api_params[name], value)) {
+        ctrl.savedSearch.api_params[name].push(value);
+        // This needs to be called when adding a field as well as changing groupBy
+        reconcileAggregateColumns();
+      }
+    };
 
-      function getFieldsForJoin(joinEntity) {
-        return {results: ctrl.getAllFields(':name', ['Field', 'Extra'], null, joinEntity)};
+    // 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) {
+        col = _.last(col.split(' AS '));
+        delete ctrl.savedSearch.api_params.orderBy[col];
+        _.remove(ctrl.savedSearch.api_params.having, function(clause) {
+          return clauseUsesFields(clause, [col]);
+        });
+      });
+    }
 
-      // @return {function}
-      $scope.fieldsForJoin = function(joinEntity) {
-        if (!fieldsForJoinGetters[joinEntity]) {
-          fieldsForJoinGetters[joinEntity] = _.wrap(joinEntity, getFieldsForJoin);
-        }
-        return fieldsForJoinGetters[joinEntity];
-      };
+    this.getFieldLabel = searchMeta.getDefaultLabel;
 
-      $scope.fieldsForWhere = function() {
-        return {results: ctrl.getAllFields(':name')};
+    // Is a column eligible to use an aggregate function?
+    this.canAggregate = function(col) {
+      // If the query does not use grouping, never
+      if (!ctrl.savedSearch.api_params.groupBy || !ctrl.savedSearch.api_params.groupBy.length) {
+        return false;
+      }
+      var arg = _.findWhere(searchMeta.parseExpr(col).args, {type: 'field'}) || {};
+      // If the column is not a database field, no
+      if (!arg.field || !arg.field.entity || !_.includes(['Field', 'Custom', 'Extra'], arg.field.type)) {
+        return false;
+      }
+      // If the column is used for a groupBy, no
+      if (ctrl.savedSearch.api_params.groupBy.indexOf(arg.path) > -1) {
+        return false;
+      }
+      // If the entity this column belongs to is being grouped by primary key, then also no
+      var idField = searchMeta.getEntity(arg.field.entity).primary_key[0];
+      return ctrl.savedSearch.api_params.groupBy.indexOf(arg.prefix + idField) < 0;
+    };
+
+    $scope.fieldsForGroupBy = function() {
+      return {results: ctrl.getAllFields('', ['Field', 'Custom', 'Extra'], function(key) {
+          return _.contains(ctrl.savedSearch.api_params.groupBy, key);
+        })
       };
+    };
 
-      $scope.fieldsForHaving = function() {
-        return {results: ctrl.getSelectFields()};
-      };
+    function getFieldsForJoin(joinEntity) {
+      return {results: ctrl.getAllFields(':name', ['Field', 'Extra'], null, joinEntity)};
+    }
 
-      // Sets the default select clause based on commonly-named fields
-      function getDefaultSelect() {
-        var entity = searchMeta.getEntity(ctrl.savedSearch.api_entity);
-        return _.transform(entity.fields, function(defaultSelect, field) {
-          if (field.name === 'id' || field.name === entity.label_field) {
-            defaultSelect.push(field.name);
-          }
-        });
+    // @return {function}
+    $scope.fieldsForJoin = function(joinEntity) {
+      if (!fieldsForJoinGetters[joinEntity]) {
+        fieldsForJoinGetters[joinEntity] = _.wrap(joinEntity, getFieldsForJoin);
       }
+      return fieldsForJoinGetters[joinEntity];
+    };
 
-      this.getAllFields = function(suffix, allowedTypes, disabledIf, topJoin) {
-        disabledIf = disabledIf || _.noop;
-        allowedTypes = allowedTypes || ['Field', 'Custom', 'Extra', 'Filter'];
+    $scope.fieldsForWhere = function() {
+      return {results: ctrl.getAllFields(':name')};
+    };
 
-        function formatEntityFields(entityName, join) {
-          var prefix = join ? join.alias + '.' : '',
-            result = [];
+    $scope.fieldsForHaving = function() {
+      return {results: ctrl.getSelectFields()};
+    };
 
-          // Add extra searchable fields from bridge entity
-          if (join && join.bridge) {
-            formatFields(_.filter(searchMeta.getEntity(join.bridge).fields, function(field) {
-              return (field.name !== 'id' && field.name !== 'entity_id' && field.name !== 'entity_table' && field.fk_entity !== entityName);
-            }), result, prefix);
-          }
-
-          formatFields(searchMeta.getEntity(entityName).fields, result, prefix);
-          return result;
+    // Sets the default select clause based on commonly-named fields
+    function getDefaultSelect() {
+      var entity = searchMeta.getEntity(ctrl.savedSearch.api_entity);
+      return _.transform(entity.fields, function(defaultSelect, field) {
+        if (field.name === 'id' || field.name === entity.label_field) {
+          defaultSelect.push(field.name);
         }
+      });
+    }
 
-        function formatFields(fields, result, prefix) {
-          prefix = typeof prefix === 'undefined' ? '' : prefix;
-          _.each(fields, function(field) {
-            var item = {
-              // Use options suffix if available.
-              id: prefix + field.name + (_.includes(field.suffixes || [], suffix.replace(':', '')) ? suffix : ''),
-              text: field.label,
-              description: field.description
-            };
-            if (disabledIf(item.id)) {
-              item.disabled = true;
-            }
-            if (_.includes(allowedTypes, field.type)) {
-              result.push(item);
-            }
-          });
-          return result;
-        }
+    this.getAllFields = function(suffix, allowedTypes, disabledIf, topJoin) {
+      disabledIf = disabledIf || _.noop;
+      allowedTypes = allowedTypes || ['Field', 'Custom', 'Extra', 'Filter'];
 
-        var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
-          joinEntities = _.map(ctrl.savedSearch.api_params.join, 0),
+      function formatEntityFields(entityName, join) {
+        var prefix = join ? join.alias + '.' : '',
           result = [];
 
-        function addJoin(join) {
-          var joinInfo = searchMeta.getJoin(join),
-            joinEntity = searchMeta.getEntity(joinInfo.entity);
-          result.push({
-            text: joinInfo.label,
-            description: joinInfo.description,
-            icon: joinEntity.icon,
-            children: formatEntityFields(joinEntity.name, joinInfo)
-          });
-        }
-
-        // Place specified join at top of list
-        if (topJoin) {
-          addJoin(topJoin);
-          _.pull(joinEntities, topJoin);
+        // Add extra searchable fields from bridge entity
+        if (join && join.bridge) {
+          formatFields(_.filter(searchMeta.getEntity(join.bridge).fields, function(field) {
+            return (field.name !== 'id' && field.name !== 'entity_id' && field.name !== 'entity_table' && field.fk_entity !== entityName);
+          }), result, prefix);
         }
 
-        result.push({
-          text: mainEntity.title_plural,
-          icon: mainEntity.icon,
-          children: formatEntityFields(ctrl.savedSearch.api_entity)
-        });
-
-        // Include SearchKit's pseudo-fields if specifically requested
-        if (_.includes(allowedTypes, 'Pseudo')) {
-          result.push({
-            text: ts('Extra'),
-            icon: 'fa-gear',
-            children: formatFields(CRM.crmSearchAdmin.pseudoFields, [])
-          });
-        }
-
-        _.each(joinEntities, addJoin);
+        formatFields(searchMeta.getEntity(entityName).fields, result, prefix);
         return result;
-      };
+      }
 
-      this.getSelectFields = function(disabledIf) {
-        disabledIf = disabledIf || _.noop;
-        return _.transform(ctrl.savedSearch.api_params.select, function(fields, name) {
-          var info = searchMeta.parseExpr(name);
+      function formatFields(fields, result, prefix) {
+        prefix = typeof prefix === 'undefined' ? '' : prefix;
+        _.each(fields, function(field) {
           var item = {
-            id: info.alias,
-            text: ctrl.getFieldLabel(name),
-            description: info.fn ? info.fn.description : info.args[0].field && info.args[0].field.description
+            // Use options suffix if available.
+            id: prefix + field.name + (_.includes(field.suffixes || [], suffix.replace(':', '')) ? suffix : ''),
+            text: field.label,
+            description: field.description
           };
           if (disabledIf(item.id)) {
             item.disabled = true;
           }
-          fields.push(item);
+          if (_.includes(allowedTypes, field.type)) {
+            result.push(item);
+          }
         });
-      };
+        return result;
+      }
 
-      this.isPseudoField = function(name) {
-        return _.findIndex(CRM.crmSearchAdmin.pseudoFields, {name: name}) >= 0;
-      };
+      var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
+        joinEntities = _.map(ctrl.savedSearch.api_params.join, 0),
+        result = [];
 
-      // Ensure options are loaded for main entity + joined entities
-      // And an optional additional entity
-      function loadFieldOptions(entity) {
-        // Main entity
-        var entitiesToLoad = [ctrl.savedSearch.api_entity];
-
-        // Join entities + bridge entities
-        _.each(ctrl.savedSearch.api_params.join, function(join) {
-          var joinInfo = searchMeta.getJoin(join[0]);
-          entitiesToLoad.push(joinInfo.entity);
-          if (joinInfo.bridge) {
-            entitiesToLoad.push(joinInfo.bridge);
-          }
+      function addJoin(join) {
+        var joinInfo = searchMeta.getJoin(join),
+          joinEntity = searchMeta.getEntity(joinInfo.entity);
+        result.push({
+          text: joinInfo.label,
+          description: joinInfo.description,
+          icon: joinEntity.icon,
+          children: formatEntityFields(joinEntity.name, joinInfo)
         });
+      }
 
-        // Optional additional entity
-        if (entity) {
-          entitiesToLoad.push(entity);
-        }
+      // Place specified join at top of list
+      if (topJoin) {
+        addJoin(topJoin);
+        _.pull(joinEntities, topJoin);
+      }
 
-        searchMeta.loadFieldOptions(entitiesToLoad);
+      result.push({
+        text: mainEntity.title_plural,
+        icon: mainEntity.icon,
+        children: formatEntityFields(ctrl.savedSearch.api_entity)
+      });
+
+      // Include SearchKit's pseudo-fields if specifically requested
+      if (_.includes(allowedTypes, 'Pseudo')) {
+        result.push({
+          text: ts('Extra'),
+          icon: 'fa-gear',
+          children: formatFields(CRM.crmSearchAdmin.pseudoFields, [])
+        });
       }
 
-      // Build a list of all possible links to main entity & join entities
-      // @return {Array}
-      this.buildLinks = function() {
-        function addTitle(link, entityName) {
-          link.text = link.text.replace('%1', entityName);
-        }
+      _.each(joinEntities, addJoin);
+      return result;
+    };
+
+    this.getSelectFields = function(disabledIf) {
+      disabledIf = disabledIf || _.noop;
+      return _.transform(ctrl.savedSearch.api_params.select, function(fields, name) {
+        var info = searchMeta.parseExpr(name);
+        var item = {
+          id: info.alias,
+          text: ctrl.getFieldLabel(name),
+          description: info.fn ? info.fn.description : info.args[0].field && info.args[0].field.description
+        };
+        if (disabledIf(item.id)) {
+          item.disabled = true;
+        }
+        fields.push(item);
+      });
+    };
+
+    this.isPseudoField = function(name) {
+      return _.findIndex(CRM.crmSearchAdmin.pseudoFields, {name: name}) >= 0;
+    };
+
+    // Ensure options are loaded for main entity + joined entities
+    // And an optional additional entity
+    function loadFieldOptions(entity) {
+      // Main entity
+      var entitiesToLoad = [ctrl.savedSearch.api_entity];
+
+      // Join entities + bridge entities
+      _.each(ctrl.savedSearch.api_params.join, function(join) {
+        var joinInfo = searchMeta.getJoin(join[0]);
+        entitiesToLoad.push(joinInfo.entity);
+        if (joinInfo.bridge) {
+          entitiesToLoad.push(joinInfo.bridge);
+        }
+      });
+
+      // Optional additional entity
+      if (entity) {
+        entitiesToLoad.push(entity);
+      }
+
+      searchMeta.loadFieldOptions(entitiesToLoad);
+    }
+
+    // Build a list of all possible links to main entity & join entities
+    // @return {Array}
+    this.buildLinks = function() {
+      function addTitle(link, entityName) {
+        link.text = link.text.replace('%1', entityName);
+      }
 
-        // Links to main entity
-        var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
-          links = _.cloneDeep(mainEntity.links || []);
-        _.each(links, function(link) {
-          link.join = '';
-          addTitle(link, mainEntity.title);
+      // Links to main entity
+      var mainEntity = searchMeta.getEntity(ctrl.savedSearch.api_entity),
+        links = _.cloneDeep(mainEntity.links || []);
+      _.each(links, function(link) {
+        link.join = '';
+        addTitle(link, mainEntity.title);
+      });
+      // Links to explicitly joined entities
+      _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
+        var join = searchMeta.getJoin(joinClause[0]),
+          joinEntity = searchMeta.getEntity(join.entity),
+          bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
+        _.each(_.cloneDeep(joinEntity.links), function(link) {
+          link.join = join.alias;
+          addTitle(link, join.label);
+          links.push(link);
         });
-        // Links to explicitly joined entities
-        _.each(ctrl.savedSearch.api_params.join, function(joinClause) {
-          var join = searchMeta.getJoin(joinClause[0]),
-            joinEntity = searchMeta.getEntity(join.entity),
-            bridgeEntity = _.isString(joinClause[2]) ? searchMeta.getEntity(joinClause[2]) : null;
-          _.each(_.cloneDeep(joinEntity.links), function(link) {
-            link.join = join.alias;
-            addTitle(link, join.label);
-            links.push(link);
-          });
-          _.each(_.cloneDeep(bridgeEntity && bridgeEntity.links), function(link) {
-            link.join = join.alias;
-            addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
-            links.push(link);
-          });
+        _.each(_.cloneDeep(bridgeEntity && bridgeEntity.links), function(link) {
+          link.join = join.alias;
+          addTitle(link, join.label + (bridgeEntity.bridge_title ? ' ' + bridgeEntity.bridge_title : ''));
+          links.push(link);
         });
-        // Links to implicit joins
-        _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
-          if (!_.includes(fieldName, ' AS ')) {
-            var info = searchMeta.parseExpr(fieldName).args[0];
-            if (info.field && !info.suffix && !info.fn && info.field.type === 'Field' && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
-              var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
-                idField = searchMeta.parseExpr(idFieldName).args[0].field;
-              if (!ctrl.canAggregate(idFieldName)) {
-                var joinEntity = searchMeta.getEntity(idField.fk_entity),
-                  label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
-                _.each(_.cloneDeep(joinEntity && joinEntity.links), function(link) {
-                  link.join = idFieldName;
-                  addTitle(link, label);
-                  links.push(link);
-                });
-              }
+      });
+      // Links to implicit joins
+      _.each(ctrl.savedSearch.api_params.select, function(fieldName) {
+        if (!_.includes(fieldName, ' AS ')) {
+          var info = searchMeta.parseExpr(fieldName).args[0];
+          if (info.field && !info.suffix && !info.fn && info.field.type === 'Field' && (info.field.fk_entity || info.field.name !== info.field.fieldName)) {
+            var idFieldName = info.field.fk_entity ? fieldName : fieldName.substr(0, fieldName.lastIndexOf('.')),
+              idField = searchMeta.parseExpr(idFieldName).args[0].field;
+            if (!ctrl.canAggregate(idFieldName)) {
+              var joinEntity = searchMeta.getEntity(idField.fk_entity),
+                label = (idField.join ? idField.join.label + ': ' : '') + (idField.input_attrs && idField.input_attrs.label || idField.label);
+              _.each(_.cloneDeep(joinEntity && joinEntity.links), function(link) {
+                link.join = idFieldName;
+                addTitle(link, label);
+                links.push(link);
+              });
             }
           }
-        });
-        return links;
-      };
+        }
+      });
+      return links;
+    };
 
-      function loadAfforms() {
-        ctrl.afforms = null;
-        if (ctrl.afformEnabled && ctrl.savedSearch.id) {
-          var findDisplays = _.transform(ctrl.savedSearch.displays, function(findDisplays, display) {
-            if (display.id && display.name) {
-              findDisplays.push(['search_displays', 'CONTAINS', ctrl.savedSearch.name + '.' + display.name]);
-            }
-          }, [['search_displays', 'CONTAINS', ctrl.savedSearch.name]]);
-          afformLoad = crmApi4('Afform', 'get', {
-            select: ['name', 'title', 'search_displays'],
-            where: [['OR', findDisplays]]
-          }).then(function(afforms) {
-            ctrl.afforms = [];
-            _.each(afforms, function(afform) {
-              ctrl.afforms.push({
-                title: afform.title,
-                displays: afform.search_displays,
-                link: ctrl.afformAdminEnabled ? CRM.url('civicrm/admin/afform#/edit/' + afform.name) : '',
-              });
+    function loadAfforms() {
+      ctrl.afforms = null;
+      if (ctrl.afformEnabled && ctrl.savedSearch.id) {
+        var findDisplays = _.transform(ctrl.savedSearch.displays, function(findDisplays, display) {
+          if (display.id && display.name) {
+            findDisplays.push(['search_displays', 'CONTAINS', ctrl.savedSearch.name + '.' + display.name]);
+          }
+        }, [['search_displays', 'CONTAINS', ctrl.savedSearch.name]]);
+        afformLoad = crmApi4('Afform', 'get', {
+          select: ['name', 'title', 'search_displays'],
+          where: [['OR', findDisplays]]
+        }).then(function(afforms) {
+          ctrl.afforms = [];
+          _.each(afforms, function(afform) {
+            ctrl.afforms.push({
+              title: afform.title,
+              displays: afform.search_displays,
+              link: ctrl.afformAdminEnabled ? CRM.url('civicrm/admin/afform#/edit/' + afform.name) : '',
             });
-            ctrl.afformCount = ctrl.afforms.length;
           });
-        }
+          ctrl.afformCount = ctrl.afforms.length;
+        });
       }
+    }
 
-      // Creating an Afform opens a new tab, so when switching back after > 10 sec, re-check for Afforms
-      $(window).on('focus', _.debounce(function() {
-        $scope.$apply(loadAfforms);
-      }, 10000, {leading: true, trailing: false}));
+    // Creating an Afform opens a new tab, so when switching back after > 10 sec, re-check for Afforms
+    $(window).on('focus', _.debounce(function() {
+      $scope.$apply(loadAfforms);
+    }, 10000, {leading: true, trailing: false}));
 
-    }
+  };
+
+  ctrl.hook = hook;
+
+  angular.module('crmSearchAdmin').component('crmSearchAdmin', {
+    bindings: {
+      savedSearch: '<'
+    },
+    templateUrl: '~/crmSearchAdmin/crmSearchAdmin.html',
+    controller: ctrl
   });
 
 })(angular, CRM.$, CRM._);
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/crmSearchDisplayEntity.component.js b/ext/search_kit/ang/crmSearchAdmin/displays/crmSearchDisplayEntity.component.js
new file mode 100644 (file)
index 0000000..5ee7e63
--- /dev/null
@@ -0,0 +1,31 @@
+(function(angular, $, _) {
+  "use strict";
+
+  // This isn't a real display type, it's only used for preview purposes on the Admin screen
+  angular.module('crmSearchAdmin').component('crmSearchDisplayEntity', {
+    bindings: {
+      apiEntity: '@',
+      search: '<',
+      display: '<',
+      settings: '<',
+    },
+
+    templateUrl: '~/crmSearchDisplayTable/crmSearchDisplayTable.html',
+    controller: function($scope, $element, searchDisplayBaseTrait) {
+      var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
+        // Mix in traits to this controller
+        ctrl = angular.extend(this, searchDisplayBaseTrait);
+
+      this.$onInit = function() {
+        // Adding this stuff for the sake of preview, but pollutes the display settings
+        // so it gets removed by preSaveDisplay hook
+        this.settings.limit = 50;
+        this.settings.pager = {expose_limit: true};
+        this.settings.classes = ['table', 'table-striped'];
+        this.initializeDisplay($scope, $element);
+      };
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.component.js b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.component.js
new file mode 100644 (file)
index 0000000..bf1ffc9
--- /dev/null
@@ -0,0 +1,69 @@
+(function(angular, $, _) {
+  "use strict";
+
+  angular.module('crmSearchAdmin').component('searchAdminDisplayEntity', {
+    bindings: {
+      display: '<',
+      apiEntity: '<',
+      apiParams: '<'
+    },
+    require: {
+      parent: '^crmSearchAdminDisplay'
+    },
+    templateUrl: '~/crmSearchAdmin/displays/searchAdminDisplayEntity.html',
+    controller: function($scope, crmApi4) {
+      var ts = $scope.ts = CRM.ts('org.civicrm.search_kit'),
+        ctrl = this,
+        colTypes = [];
+
+      this.getColTypes = function() {
+        return colTypes;
+      };
+
+      this.$onInit = function () {
+        ctrl.jobFrequency = CRM.crmSearchAdmin.jobFrequency;
+        if (!ctrl.display.settings) {
+          ctrl.display.settings = {
+            sort: ctrl.parent.getDefaultSort()
+          };
+        }
+        if (ctrl.display.id && !ctrl.display._job) {
+          crmApi4({
+            ref: ['SK_' + ctrl.display.name, 'getRefreshDate', {}, 0],
+            job: ['Job', 'get', {where: [['api_entity', '=', 'SK_' + ctrl.display.name,], ['api_action', '=', 'refresh']]}, 0],
+          }).then(function(result) {
+            ctrl.display._refresh_date = result.ref.refresh_date ? CRM.utils.formatDate(result.ref.refresh_date, null, true) : ts('never');
+            if (result.job && result.job.id) {
+              ctrl.display._job = result.job;
+            } else {
+              ctrl.display._job = defaultJobParams();
+            }
+          });
+        }
+        if (!ctrl.display.id && !ctrl.display._job) {
+          ctrl.display._job = defaultJobParams();
+        }
+        ctrl.parent.initColumns({label: true});
+      };
+
+      function defaultJobParams() {
+        return {
+          parameters: 'version=4',
+          is_active: false,
+          run_frequency: 'Hourly',
+        };
+      }
+
+      $scope.$watch('$ctrl.display.name', function(newVal, oldVal) {
+        if (!newVal) {
+          newVal = ctrl.display.label;
+        }
+        if (newVal !== oldVal) {
+          ctrl.display.name = _.capitalize(_.camelCase(newVal));
+        }
+      });
+
+    }
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.decorator.js b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.decorator.js
new file mode 100644 (file)
index 0000000..c8565aa
--- /dev/null
@@ -0,0 +1,41 @@
+(function(angular, $, _) {
+  "use strict";
+
+  // Register hooks on the crmSearchAdmin component
+  angular.module('crmSearchAdmin').decorator('crmSearchAdminDirective', function($delegate, crmApi4) {
+    // Register callback for preSaveDisplay hook
+    $delegate[0].controller.hook.preSaveDisplay.push(function(display, apiCalls) {
+      if (display.type === 'entity') {
+        // Unset vars added by the preview (see `crmSearchDisplayEntity`)
+        delete display.settings.limit;
+        delete display.settings.pager;
+        delete display.settings.classes;
+      }
+      if (display.type === 'entity' && display._job) {
+        // Add/update scheduled job
+        display._job.api_entity = 'SK_' + display.name;
+        display._job.api_action = 'refresh';
+        display._job.name = ts('Refresh %1 Table', {1: display.label});
+        display._job.description = ts('Refresh contents of the %1 SearchKit entity', {1: display.label});
+        apiCalls['job_' + display.name] = ['Job', 'save', {
+          records: [display._job],
+          match: ['api_entity', 'api_action']
+        }, 0];
+      }
+    });
+    // Register callback for postSaveDisplay hook
+    $delegate[0].controller.hook.postSaveDisplay.push(function(display, apiResults) {
+      if (display.type === 'entity') {
+        // Refresh entity displays which write to SQL tables. Do this asynchronously because it can be slow.
+        crmApi4('SK_' + display.name, 'refresh', {}, 0).then(function(result) {
+          display._refresh_date = CRM.utils.formatDate(result.refresh_date, null, true);
+        });
+        if (apiResults['job_' + display.name]) {
+          display._job = apiResults['job_' + display.name];
+        }
+      }
+    });
+    return $delegate;
+  });
+
+})(angular, CRM.$, CRM._);
diff --git a/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.html b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.html
new file mode 100644 (file)
index 0000000..168f182
--- /dev/null
@@ -0,0 +1,53 @@
+<div class="form-inline">
+  <label for="crm-search-admin-display-label">{{:: ts('Label') }} <span class="crm-marker">*</span></label>
+  <input id="crm-search-admin-display-label" type="text" class="form-control" ng-model="$ctrl.display.label" required placeholder="{{:: ts('Untitled') }}"/>
+</div>
+<div>
+  <textarea class="form-control" placeholder="{{:: ts('Description') }}" ng-model="$ctrl.display.settings.description"></textarea>
+</div>
+<div class="form-inline">
+  <label for="crm-search-admin-display-api">{{:: ts('API Name') }} <span class="crm-marker">*</span></label>
+  <div class="input-group">
+    <span class="input-group-addon" id="basic-addon1">SK_</span>
+    <input id="crm-search-admin-display-api" type="text" class="form-control" ng-model="$ctrl.display.name" required />
+  </div>
+</div>
+<p ng-if="$ctrl.display.id">
+  <i class="crm-i fa-clock-o"></i>
+  <strong ng-if="$ctrl.display._refresh_date">{{:: ts('Last refreshed: %1. Click "Save" to refresh now.', {1: $ctrl.display._refresh_date}) }}</strong>
+  <strong ng-if="!$ctrl.display._refresh_date">{{:: ts('Checking last refresh date...') }}</strong>
+</p>
+<div class="form-inline" ng-if="$ctrl.display._job">
+  <div class="checkbox-inline form-control">
+    <label>
+      <input type="checkbox" ng-model="$ctrl.display._job.is_active">
+      <span>{{:: ts('Auto-Refresh') }}</span>
+    </label>
+  </div>
+  <select class="form-control" ng-if="$ctrl.display._job.is_active" ng-model="$ctrl.display._job.run_frequency">
+    <option ng-repeat="opt in $ctrl.jobFrequency" value="{{:: opt.id }}">{{:: opt.label }}</option>
+  </select>
+</div>
+<fieldset ng-include="'~/crmSearchAdmin/crmSearchAdminDisplaySort.html'"></fieldset>
+
+<fieldset class="crm-search-admin-edit-columns-wrapper">
+  <legend>
+    {{:: ts('Columns') }}
+    <div ng-if="$ctrl.parent.hiddenColumns.length" ng-include="'~/crmSearchAdmin/displays/common/addColMenu.html'" class="btn-group btn-group-xs"></div>
+  </legend>
+  <div class="crm-search-admin-edit-columns" ng-model="$ctrl.display.settings.columns" ui-sortable="$ctrl.parent.sortableOptions">
+    <fieldset ng-repeat="col in $ctrl.display.settings.columns" class="crm-draggable">
+      <legend>
+        <i class="crm-i fa-arrows crm-search-move-icon"></i>
+        {{ $ctrl.parent.getColLabel(col) }}
+      </legend>
+      <div class="form-inline crm-search-admin-flex-row">
+        <label for="crm-search-admin-edit-col-{{ $index }}">{{:: ts('Label') }}</label>
+        <input id="crm-search-admin-edit-col-{{ $index }}" class="form-control crm-flex-1" type="text" ng-model="col.label" >
+        <button type="button" class="btn-xs" ng-click="$ctrl.parent.removeCol($index)" title="{{:: ts('Remove') }}">
+          <i class="crm-i fa-ban"></i>
+        </button>
+      </div>
+    </fieldset>
+  </div>
+</fieldset>
index 55e4e32504a777b45179c1c6806b11818d9cad12..9949a1d7704a71da0abfa88b5f671b2fcd9b4e63 100644 (file)
@@ -109,4 +109,25 @@ return [
       'match' => ['option_group_id', 'name'],
     ],
   ],
+  [
+    'name' => 'SearchDisplayType:entity',
+    'entity' => 'OptionValue',
+    'cleanup' => 'always',
+    'update' => 'always',
+    'params' => [
+      'version' => 4,
+      'values' => [
+        'option_group_id.name' => 'search_display_type',
+        'value' => 'entity',
+        'name' => 'crm-search-display-entity',
+        'label' => E::ts('DB Entity'),
+        'description' => E::ts('Saves the search results in a database table which can be accessed with SearchKit, the API, or SQL-based tools.'),
+        'icon' => 'fa-database',
+        'is_reserved' => TRUE,
+        'is_active' => TRUE,
+        'domain_id' => NULL,
+      ],
+      'match' => ['option_group_id', 'name'],
+    ],
+  ],
 ];
index a17a0e35e9eff0b5a47c64f8f3d546e15c3274ea..1347c6f4020ce37d3fd69afcebaf40ab88de8eba 100644 (file)
@@ -109,3 +109,58 @@ function search_kit_civicrm_post($op, $entity, $id, $object) {
     \Civi::cache('metadata')->clear();
   }
 }
+
+/**
+ * Implements hook_civicrm_entityTypes().
+ */
+function search_kit_civicrm_entityTypes(array &$entityTypes): void {
+  foreach (_getSearchKitEntityDisplays() as $display) {
+    $entityTypes[$display['entityName']] = [
+      'name' => $display['entityName'],
+      'class' => \Civi\BAO\SK_Entity::class,
+      'table' => $display['tableName'],
+    ];
+  }
+}
+
+/**
+ * Returns a SQL-safe table name for a display (for use with displays of type "entity")
+ *
+ * @param string $displayName
+ * @return string
+ */
+function _getSearchKitDisplayTableName(string $displayName): string {
+  return CRM_Utils_String::munge('civicrm_sk_' . CRM_Utils_String::convertStringToSnakeCase($displayName), '_', 64);
+}
+
+/**
+ * Uncached function to fetch displays of type "entity" to be used by boot-level code
+ *
+ * @return array
+ * @throws CRM_Core_Exception
+ */
+function _getSearchKitEntityDisplays(): array {
+  $displays = [];
+  // Can't use the API to fetch search displays because this is called by pre-boot hooks
+  $select = CRM_Utils_SQL_Select::from('civicrm_search_display')
+    ->where('type = "entity"')
+    ->select(['id', 'name', 'label', 'settings']);
+  try {
+    $display = CRM_Core_DAO::executeQuery($select->toSQL());
+    while ($display->fetch()) {
+      $displays[] = [
+        'id' => $display->id,
+        'label' => $display->label,
+        'name' => $display->name,
+        'entityName' => 'SK_' . $display->name,
+        'tableName' => _getSearchKitDisplayTableName($display->name),
+        'settings' => CRM_Core_DAO::unSerializeField($display->settings, \CRM_Core_DAO::SERIALIZE_JSON),
+      ];
+    }
+  }
+  // If the extension hasn't fully installed and the table doesn't exist yet, suppress errors
+  catch (CRM_Core_Exception $e) {
+    return [];
+  }
+  return $displays;
+}
diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/EntityDisplayTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/EntityDisplayTest.php
new file mode 100644 (file)
index 0000000..d4eb1eb
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+
+namespace api\v4\SearchDisplay;
+
+// Not sure why this is needed but without it Jenkins crashed
+require_once __DIR__ . '/../../../../../../../tests/phpunit/api/v4/Api4TestBase.php';
+
+use api\v4\Api4TestBase;
+use Civi\Api4\SearchDisplay;
+use Civi\Test\CiviEnvBuilder;
+
+/**
+ * @group headless
+ */
+class EntityDisplayTest extends Api4TestBase {
+
+  public function setUpHeadless(): CiviEnvBuilder {
+    return \Civi\Test::headless()
+      ->installMe(__DIR__)
+      ->apply();
+  }
+
+  public function testEntityDisplay() {
+    $lastName = uniqid(__FUNCTION__);
+
+    $this->saveTestRecords('Contact', [
+      'records' => [
+        ['last_name' => $lastName, 'first_name' => 'c', 'prefix_id:name' => 'Ms.'],
+        ['last_name' => $lastName, 'first_name' => 'b', 'prefix_id:name' => 'Dr.'],
+        ['last_name' => $lastName, 'first_name' => 'a'],
+      ],
+    ]);
+
+    $savedSearch = $this->createTestRecord('SavedSearch', [
+      'label' => __FUNCTION__,
+      'api_entity' => 'Contact',
+      'api_params' => [
+        'version' => 4,
+        'select' => ['id', 'first_name', 'last_name', 'prefix_id:label'],
+        'where' => [['last_name', '=', $lastName]],
+      ],
+    ]);
+
+    $display = SearchDisplay::create(FALSE)
+      ->addValue('saved_search_id', $savedSearch['id'])
+      ->addValue('type', 'entity')
+      ->addValue('label', 'MyNewEntity')
+      ->addValue('name', 'MyNewEntity')
+      ->addValue('settings', [
+        'columns' => [
+          [
+            'key' => 'id',
+            'label' => 'Contact ID',
+            'type' => 'field',
+          ],
+          [
+            'key' => 'first_name',
+            'label' => 'First Name',
+            'type' => 'field',
+          ],
+          [
+            'key' => 'last_name',
+            'label' => 'Last Name',
+            'type' => 'field',
+          ],
+          [
+            'key' => 'prefix_id:label',
+            'label' => 'Prefix',
+            'type' => 'field',
+          ],
+        ],
+        'sort' => [
+          ['first_name', 'ASC'],
+        ],
+      ])
+      ->execute()->first();
+
+    $schema = \CRM_Core_DAO::executeQuery('DESCRIBE civicrm_sk_my_new_entity')->fetchAll();
+    $this->assertCount(5, $schema);
+    $this->assertEquals('_row', $schema[0]['Field']);
+    $this->assertStringStartsWith('int', $schema[0]['Type']);
+    $this->assertEquals('PRI', $schema[0]['Key']);
+
+    $rows = \CRM_Core_DAO::executeQuery('SELECT * FROM civicrm_sk_my_new_entity')->fetchAll();
+    $this->assertCount(0, $rows);
+
+    civicrm_api4('SK_MyNewEntity', 'refresh');
+
+    $rows = \CRM_Core_DAO::executeQuery('SELECT * FROM civicrm_sk_my_new_entity ORDER BY `_row`')->fetchAll();
+    $this->assertCount(3, $rows);
+    $this->assertEquals('a', $rows[0]['first_name']);
+    $this->assertEquals('c', $rows[2]['first_name']);
+
+    // Add a contact
+    $this->createTestRecord('Contact', [
+      'last_name' => $lastName,
+      'first_name' => 'b2',
+    ]);
+    civicrm_api4('SK_MyNewEntity', 'refresh');
+
+    $rows = civicrm_api4('SK_MyNewEntity', 'get', [
+      'select' => ['first_name', 'prefix_id:label'],
+      'orderBy' => ['_row' => 'ASC'],
+    ]);
+    $this->assertCount(4, $rows);
+    $this->assertEquals('a', $rows[0]['first_name']);
+    $this->assertEquals('Dr.', $rows[1]['prefix_id:label']);
+    $this->assertEquals('b', $rows[1]['first_name']);
+    $this->assertEquals('b2', $rows[2]['first_name']);
+    $this->assertEquals('c', $rows[3]['first_name']);
+    $this->assertEquals('Ms.', $rows[3]['prefix_id:label']);
+  }
+
+}