From ec43b669cb8b060751aab184869d3f0b7c78c88f Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Sun, 19 Mar 2023 18:33:18 -0400 Subject: [PATCH] SearchKit - Add display of type `entity` 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). --- .../Traits/SavedSearchInspectorTrait.php | 11 +- Civi/Schema/Traits/OptionsSpecTrait.php | 15 +- .../Api4/Action/SKEntity/GetRefreshDate.php | 34 + .../Civi/Api4/Action/SKEntity/Refresh.php | 50 + .../Event/Subscriber/SKEntitySubscriber.php | 230 +++ ext/search_kit/Civi/Api4/SKEntity.php | 85 ++ .../Spec/Provider/SKEntitySpecProvider.php | 92 ++ ext/search_kit/Civi/BAO/SK_Entity.php | 73 + ext/search_kit/Civi/Search/Admin.php | 4 + .../crmSearchAdmin.component.js | 1278 +++++++++-------- .../crmSearchDisplayEntity.component.js | 31 + .../searchAdminDisplayEntity.component.js | 69 + .../searchAdminDisplayEntity.decorator.js | 41 + .../displays/searchAdminDisplayEntity.html | 53 + .../managed/SearchDisplayType.mgd.php | 21 + ext/search_kit/search_kit.php | 55 + .../v4/SearchDisplay/EntityDisplayTest.php | 114 ++ 17 files changed, 1627 insertions(+), 629 deletions(-) create mode 100644 ext/search_kit/Civi/Api4/Action/SKEntity/GetRefreshDate.php create mode 100644 ext/search_kit/Civi/Api4/Action/SKEntity/Refresh.php create mode 100644 ext/search_kit/Civi/Api4/Event/Subscriber/SKEntitySubscriber.php create mode 100644 ext/search_kit/Civi/Api4/SKEntity.php create mode 100644 ext/search_kit/Civi/Api4/Service/Spec/Provider/SKEntitySpecProvider.php create mode 100644 ext/search_kit/Civi/BAO/SK_Entity.php create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/crmSearchDisplayEntity.component.js create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.component.js create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.decorator.js create mode 100644 ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.html create mode 100644 ext/search_kit/tests/phpunit/api/v4/SearchDisplay/EntityDisplayTest.php diff --git a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php index 1e2b87ce66eb..ce6cbb325b46 100644 --- a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php +++ b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php @@ -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 @@ protected function loadSavedSearch() { ]; $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' => []]; } diff --git a/Civi/Schema/Traits/OptionsSpecTrait.php b/Civi/Schema/Traits/OptionsSpecTrait.php index aebe2694bfda..22df97adb842 100644 --- a/Civi/Schema/Traits/OptionsSpecTrait.php +++ b/Civi/Schema/Traits/OptionsSpecTrait.php @@ -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 @@ public function setSuffixes($suffixes) { /** * @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 index 000000000000..c6da7a45faad --- /dev/null +++ b/ext/search_kit/Civi/Api4/Action/SKEntity/GetRefreshDate.php @@ -0,0 +1,34 @@ +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 index 000000000000..1e5a732e285c --- /dev/null +++ b/ext/search_kit/Civi/Api4/Action/SKEntity/Refresh.php @@ -0,0 +1,50 @@ +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 index 000000000000..66952b29a196 --- /dev/null +++ b/ext/search_kit/Civi/Api4/Event/Subscriber/SKEntitySubscriber.php @@ -0,0 +1,230 @@ + '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 index 000000000000..1a80f55dd247 --- /dev/null +++ b/ext/search_kit/Civi/Api4/SKEntity.php @@ -0,0 +1,85 @@ +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 index 000000000000..7b101e82d229 --- /dev/null +++ b/ext/search_kit/Civi/Api4/Service/Spec/Provider/SKEntitySpecProvider.php @@ -0,0 +1,92 @@ +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 index 000000000000..787ac96ddfac --- /dev/null +++ b/ext/search_kit/Civi/BAO/SK_Entity.php @@ -0,0 +1,73 @@ + $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') diff --git a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js index d5dde6b1e3ab..51f3bc014581 100644 --- a/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js +++ b/ext/search_kit/ang/crmSearchAdmin/crmSearchAdmin.component.js @@ -1,720 +1,754 @@ (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('
'), 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('
'), 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 index 000000000000..5ee7e6329450 --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/crmSearchDisplayEntity.component.js @@ -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 index 000000000000..bf1ffc98f8f3 --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.component.js @@ -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 index 000000000000..c8565aa4e0c3 --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.decorator.js @@ -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 index 000000000000..168f182bfe0a --- /dev/null +++ b/ext/search_kit/ang/crmSearchAdmin/displays/searchAdminDisplayEntity.html @@ -0,0 +1,53 @@ +
+ + +
+
+ +
+
+ +
+ SK_ + +
+
+

+ + {{:: ts('Last refreshed: %1. Click "Save" to refresh now.', {1: $ctrl.display._refresh_date}) }} + {{:: ts('Checking last refresh date...') }} +

+
+
+ +
+ +
+
+ +
+ + {{:: ts('Columns') }} +
+
+
+
+ + + {{ $ctrl.parent.getColLabel(col) }} + +
+ + + +
+
+
+
diff --git a/ext/search_kit/managed/SearchDisplayType.mgd.php b/ext/search_kit/managed/SearchDisplayType.mgd.php index 55e4e32504a7..9949a1d7704a 100644 --- a/ext/search_kit/managed/SearchDisplayType.mgd.php +++ b/ext/search_kit/managed/SearchDisplayType.mgd.php @@ -109,4 +109,25 @@ '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'], + ], + ], ]; diff --git a/ext/search_kit/search_kit.php b/ext/search_kit/search_kit.php index a17a0e35e9ef..1347c6f4020c 100644 --- a/ext/search_kit/search_kit.php +++ b/ext/search_kit/search_kit.php @@ -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 index 000000000000..d4eb1eb8bf42 --- /dev/null +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/EntityDisplayTest.php @@ -0,0 +1,114 @@ +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']); + } + +}