From 2552305999f807864bbf678cd2a6af6394ed9f6d Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Wed, 8 Jul 2020 18:15:39 -0400 Subject: [PATCH] Add search extension --- .gitignore | 1 + ext/search/CRM/Search/Page/Build.php | 174 ++++++ ext/search/CRM/Search/Upgrader.php | 36 ++ ext/search/CRM/Search/Upgrader/Base.php | 391 +++++++++++++ ext/search/README.md | 7 + ext/search/ang/search.ang.php | 17 + ext/search/ang/search.module.js | 83 +++ ext/search/ang/search/SaveSmartGroup.ctrl.js | 54 ++ .../ang/search/crmSearchActions.component.js | 62 ++ ext/search/ang/search/crmSearchActions.html | 10 + .../crmSearchActionDelete.ctrl.js | 24 + .../crmSearchActionDelete.html | 10 + .../crmSearchActionUpdate.ctrl.js | 63 ++ .../crmSearchActionUpdate.html | 16 + .../ang/search/crmSearchBuild.component.js | 539 ++++++++++++++++++ ext/search/ang/search/crmSearchBuild.html | 20 + .../ang/search/crmSearchBuild/build.html | 48 ++ .../ang/search/crmSearchBuild/controls.html | 25 + .../ang/search/crmSearchBuild/debug.html | 7 + .../ang/search/crmSearchBuild/pager.html | 35 ++ .../ang/search/crmSearchBuild/results.html | 32 ++ .../ang/search/crmSearchClause.directive.js | 75 +++ ext/search/ang/search/crmSearchClause.html | 41 ++ .../ang/search/crmSearchFunction.component.js | 28 + ext/search/ang/search/crmSearchFunction.html | 4 + .../ang/search/crmSearchValue.directive.js | 115 ++++ ext/search/ang/search/saveSmartGroup.html | 26 + ext/search/css/search.css | 149 +++++ ext/search/info.xml | 27 + ext/search/search.civix.php | 477 ++++++++++++++++ ext/search/search.php | 128 +++++ .../templates/CRM/Search/Page/Build.tpl | 0 ext/search/xml/Menu/search.xml | 8 + 33 files changed, 2732 insertions(+) create mode 100644 ext/search/CRM/Search/Page/Build.php create mode 100644 ext/search/CRM/Search/Upgrader.php create mode 100644 ext/search/CRM/Search/Upgrader/Base.php create mode 100644 ext/search/README.md create mode 100644 ext/search/ang/search.ang.php create mode 100644 ext/search/ang/search.module.js create mode 100644 ext/search/ang/search/SaveSmartGroup.ctrl.js create mode 100644 ext/search/ang/search/crmSearchActions.component.js create mode 100644 ext/search/ang/search/crmSearchActions.html create mode 100644 ext/search/ang/search/crmSearchActions/crmSearchActionDelete.ctrl.js create mode 100644 ext/search/ang/search/crmSearchActions/crmSearchActionDelete.html create mode 100644 ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.ctrl.js create mode 100644 ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.html create mode 100644 ext/search/ang/search/crmSearchBuild.component.js create mode 100644 ext/search/ang/search/crmSearchBuild.html create mode 100644 ext/search/ang/search/crmSearchBuild/build.html create mode 100644 ext/search/ang/search/crmSearchBuild/controls.html create mode 100644 ext/search/ang/search/crmSearchBuild/debug.html create mode 100644 ext/search/ang/search/crmSearchBuild/pager.html create mode 100644 ext/search/ang/search/crmSearchBuild/results.html create mode 100644 ext/search/ang/search/crmSearchClause.directive.js create mode 100644 ext/search/ang/search/crmSearchClause.html create mode 100644 ext/search/ang/search/crmSearchFunction.component.js create mode 100644 ext/search/ang/search/crmSearchFunction.html create mode 100644 ext/search/ang/search/crmSearchValue.directive.js create mode 100644 ext/search/ang/search/saveSmartGroup.html create mode 100644 ext/search/css/search.css create mode 100644 ext/search/info.xml create mode 100644 ext/search/search.civix.php create mode 100644 ext/search/search.php create mode 100644 ext/search/templates/CRM/Search/Page/Build.tpl create mode 100644 ext/search/xml/Menu/search.xml diff --git a/.gitignore b/.gitignore index c9d3abef61..390c2a0494 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ !/ext/sequentialcreditnotes !/ext/flexmailer !/ext/eventcart +!/ext/search backdrop/ bower_components CRM/Case/xml/configuration diff --git a/ext/search/CRM/Search/Page/Build.php b/ext/search/CRM/Search/Page/Build.php new file mode 100644 index 0000000000..8829e79914 --- /dev/null +++ b/ext/search/CRM/Search/Page/Build.php @@ -0,0 +1,174 @@ + ts('Search'), + 'url' => CRM_Utils_System::url('civicrm/search'), + ]; + CRM_Utils_System::appendBreadCrumb([$breadCrumb]); + + $this->getSchema(); + + // If user does not have permission to search any entity, bye bye. + if (!$this->allowedEntities) { + CRM_Utils_System::permissionDenied(); + } + + // Add client-side vars for the search UI + $vars = [ + 'operators' => \CRM_Core_DAO::acceptedSQLOperators(), + 'schema' => $this->schema, + 'links' => $this->getLinks(), + 'loadOptions' => $this->loadOptions, + 'actions' => $this->getActions(), + 'functions' => CRM_Api4_Page_Api4Explorer::getSqlFunctions(), + ]; + + Civi::resources() + ->addPermissions(['edit groups', 'administer reserved groups']) + ->addVars('search', $vars); + + // Load angular module + $loader = new Civi\Angular\AngularLoader(); + $loader->setModules(['search']); + $loader->setPageName('civicrm/search'); + $loader->useApp([ + 'defaultRoute' => '/Contact', + ]); + $loader->load(); + parent::run(); + } + + /** + * Populates $this->schema & $this->allowedEntities + */ + private function getSchema() { + $schema = \Civi\Api4\Entity::get() + ->addSelect('name', 'title', 'description', 'icon') + ->addWhere('name', '!=', 'Entity') + ->addOrderBy('title') + ->setChain([ + 'get' => ['$name', 'getActions', ['where' => [['name', '=', 'get']]], ['params']], + ])->execute(); + $getFields = ['name', 'title', 'description', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize']; + foreach ($schema as $entity) { + // Skip if entity doesn't have a 'get' action or the user doesn't have permission to use get + if ($entity['get']) { + // Get fields and pre-load options for certain prominent entities + $loadOptions = in_array($entity['name'], ['Contact', 'Group']) ? $this->loadOptions : FALSE; + if ($loadOptions) { + $entity['optionsLoaded'] = TRUE; + } + $entity['fields'] = civicrm_api4($entity['name'], 'getFields', [ + 'select' => $getFields, + 'where' => [['permission', 'IS NULL']], + 'orderBy' => ['title'], + 'loadOptions' => $loadOptions, + ]); + // Get the names of params this entity supports (minus some obvious ones) + $params = $entity['get'][0]; + CRM_Utils_Array::remove($params, 'checkPermissions', 'debug', 'chain', 'language'); + unset($entity['get']); + $this->schema[] = ['params' => array_keys($params)] + array_filter($entity); + $this->allowedEntities[] = $entity['name']; + } + } + } + + /** + * @return array + */ + private function getLinks() { + $results = []; + $keys = array_flip(['alias', 'entity', 'joinType']); + foreach (civicrm_api4('Entity', 'getLinks', ['where' => [['entity', 'IN', $this->allowedEntities]]], ['entity' => 'links']) as $entity => $links) { + $entityLinks = []; + foreach ($links as $link) { + if (!empty($link['entity']) && in_array($link['entity'], $this->allowedEntities)) { + // Use entity.alias as array key to avoid duplicates + $entityLinks[$link['entity'] . $link['alias']] = array_intersect_key($link, $keys); + } + } + $results[$entity] = array_values($entityLinks); + } + return array_filter($results); + } + + /** + * @return array[] + */ + private function getActions() { + // Note: the placeholder %1 will be replaced with entity name on the clientside + $actions = [ + 'export' => [ + 'title' => ts('Export %1'), + 'icon' => 'fa-file-excel-o', + 'entities' => array_keys(CRM_Export_BAO_Export::getComponents()), + 'crmPopup' => [ + 'path' => "'civicrm/export/standalone'", + 'query' => "{entity: entity, id: ids.join(',')}", + ], + ], + 'update' => [ + 'title' => ts('Update %1'), + 'icon' => 'fa-save', + 'entities' => [], + 'uiDialog' => ['templateUrl' => '~/search/crmSearchActions/crmSearchActionUpdate.html'], + ], + 'delete' => [ + 'title' => ts('Delete %1'), + 'icon' => 'fa-trash', + 'entities' => [], + 'uiDialog' => ['templateUrl' => '~/search/crmSearchActions/crmSearchActionDelete.html'], + ], + ]; + + // Check permissions for update & delete actions + foreach ($this->allowedEntities as $entity) { + $result = civicrm_api4($entity, 'getActions', [ + 'where' => [['name', 'IN', ['update', 'delete']]], + ], ['name']); + foreach ($result as $action) { + // Contacts have their own delete action + if (!($entity === 'Contact' && $action === 'delete')) { + $actions[$action]['entities'][] = $entity; + } + } + } + + // Add contact tasks which support standalone mode (with a 'url' property) + $contactTasks = CRM_Contact_Task::permissionedTaskTitles(CRM_Core_Permission::getPermission()); + foreach (CRM_Contact_Task::tasks() as $id => $task) { + if (isset($contactTasks[$id]) && !empty($task['url'])) { + $actions['contact.' . $id] = [ + 'title' => $task['title'], + 'entities' => ['Contact'], + 'icon' => $task['icon'] ?? 'fa-gear', + 'crmPopup' => [ + 'path' => "'{$task['url']}'", + 'query' => "{cids: ids.join(',')}", + ], + ]; + } + } + + return $actions; + } + +} diff --git a/ext/search/CRM/Search/Upgrader.php b/ext/search/CRM/Search/Upgrader.php new file mode 100644 index 0000000000..d42a70f862 --- /dev/null +++ b/ext/search/CRM/Search/Upgrader.php @@ -0,0 +1,36 @@ +setCheckPermissions(FALSE) + ->addValue('parent_id:name', 'Search') + ->addValue('label', E::ts('Build Search for...')) + ->addValue('name', 'build_search') + ->addValue('url', 'civicrm/search') + ->addValue('icon', 'crm-i fa-search-plus') + ->addValue('has_separator', 2) + ->addValue('weight', 99) + ->execute(); + } + + /** + * Delete menu item when disabled. + */ + public function disable() { + \Civi\Api4\Navigation::delete() + ->setCheckPermissions(FALSE) + ->addWhere('name', '=', 'build_search') + ->addWhere('domain_id', '=', 'current_domain') + ->execute(); + } + +} diff --git a/ext/search/CRM/Search/Upgrader/Base.php b/ext/search/CRM/Search/Upgrader/Base.php new file mode 100644 index 0000000000..3015d7908f --- /dev/null +++ b/ext/search/CRM/Search/Upgrader/Base.php @@ -0,0 +1,391 @@ +ctx = array_shift($args); + $instance->queue = $instance->ctx->queue; + $method = array_shift($args); + return call_user_func_array([$instance, $method], $args); + } + + /** + * CRM_Search_Upgrader_Base constructor. + * + * @param $extensionName + * @param $extensionDir + */ + public function __construct($extensionName, $extensionDir) { + $this->extensionName = $extensionName; + $this->extensionDir = $extensionDir; + } + + // ******** Task helpers ******** + + /** + * Run a CustomData file. + * + * @param string $relativePath the CustomData XML file path (relative to this extension's dir) + * @return bool + */ + public function executeCustomDataFile($relativePath) { + $xml_file = $this->extensionDir . '/' . $relativePath; + return $this->executeCustomDataFileByAbsPath($xml_file); + } + + /** + * Run a CustomData file + * + * @param string $xml_file the CustomData XML file path (absolute path) + * + * @return bool + */ + protected function executeCustomDataFileByAbsPath($xml_file) { + $import = new CRM_Utils_Migrate_Import(); + $import->run($xml_file); + return TRUE; + } + + /** + * Run a SQL file. + * + * @param string $relativePath the SQL file path (relative to this extension's dir) + * + * @return bool + */ + public function executeSqlFile($relativePath) { + CRM_Utils_File::sourceSQLFile( + CIVICRM_DSN, + $this->extensionDir . DIRECTORY_SEPARATOR . $relativePath + ); + return TRUE; + } + + /** + * Run the sql commands in the specified file. + * + * @param string $tplFile + * The SQL file path (relative to this extension's dir). + * Ex: "sql/mydata.mysql.tpl". + * + * @return bool + * @throws \CRM_Core_Exception + */ + public function executeSqlTemplate($tplFile) { + // Assign multilingual variable to Smarty. + $upgrade = new CRM_Upgrade_Form(); + + $tplFile = CRM_Utils_File::isAbsolute($tplFile) ? $tplFile : $this->extensionDir . DIRECTORY_SEPARATOR . $tplFile; + $smarty = CRM_Core_Smarty::singleton(); + $smarty->assign('domainID', CRM_Core_Config::domainID()); + CRM_Utils_File::sourceSQLFile( + CIVICRM_DSN, $smarty->fetch($tplFile), NULL, TRUE + ); + return TRUE; + } + + /** + * Run one SQL query. + * + * This is just a wrapper for CRM_Core_DAO::executeSql, but it + * provides syntactic sugar for queueing several tasks that + * run different queries + * + * @return bool + */ + public function executeSql($query, $params = []) { + // FIXME verify that we raise an exception on error + CRM_Core_DAO::executeQuery($query, $params); + return TRUE; + } + + /** + * Syntactic sugar for enqueuing a task which calls a function in this class. + * + * The task is weighted so that it is processed + * as part of the currently-pending revision. + * + * After passing the $funcName, you can also pass parameters that will go to + * the function. Note that all params must be serializable. + */ + public function addTask($title) { + $args = func_get_args(); + $title = array_shift($args); + $task = new CRM_Queue_Task( + [get_class($this), '_queueAdapter'], + $args, + $title + ); + return $this->queue->createItem($task, ['weight' => -1]); + } + + // ******** Revision-tracking helpers ******** + + /** + * Determine if there are any pending revisions. + * + * @return bool + */ + public function hasPendingRevisions() { + $revisions = $this->getRevisions(); + $currentRevision = $this->getCurrentRevision(); + + if (empty($revisions)) { + return FALSE; + } + if (empty($currentRevision)) { + return TRUE; + } + + return ($currentRevision < max($revisions)); + } + + /** + * Add any pending revisions to the queue. + */ + public function enqueuePendingRevisions(CRM_Queue_Queue $queue) { + $this->queue = $queue; + + $currentRevision = $this->getCurrentRevision(); + foreach ($this->getRevisions() as $revision) { + if ($revision > $currentRevision) { + $title = E::ts('Upgrade %1 to revision %2', [ + 1 => $this->extensionName, + 2 => $revision, + ]); + + // note: don't use addTask() because it sets weight=-1 + + $task = new CRM_Queue_Task( + [get_class($this), '_queueAdapter'], + ['upgrade_' . $revision], + $title + ); + $this->queue->createItem($task); + + $task = new CRM_Queue_Task( + [get_class($this), '_queueAdapter'], + ['setCurrentRevision', $revision], + $title + ); + $this->queue->createItem($task); + } + } + } + + /** + * Get a list of revisions. + * + * @return array(revisionNumbers) sorted numerically + */ + public function getRevisions() { + if (!is_array($this->revisions)) { + $this->revisions = []; + + $clazz = new ReflectionClass(get_class($this)); + $methods = $clazz->getMethods(); + foreach ($methods as $method) { + if (preg_match('/^upgrade_(.*)/', $method->name, $matches)) { + $this->revisions[] = $matches[1]; + } + } + sort($this->revisions, SORT_NUMERIC); + } + + return $this->revisions; + } + + public function getCurrentRevision() { + $revision = CRM_Core_BAO_Extension::getSchemaVersion($this->extensionName); + if (!$revision) { + $revision = $this->getCurrentRevisionDeprecated(); + } + return $revision; + } + + private function getCurrentRevisionDeprecated() { + $key = $this->extensionName . ':version'; + if ($revision = \Civi::settings()->get($key)) { + $this->revisionStorageIsDeprecated = TRUE; + } + return $revision; + } + + public function setCurrentRevision($revision) { + CRM_Core_BAO_Extension::setSchemaVersion($this->extensionName, $revision); + // clean up legacy schema version store (CRM-19252) + $this->deleteDeprecatedRevision(); + return TRUE; + } + + private function deleteDeprecatedRevision() { + if ($this->revisionStorageIsDeprecated) { + $setting = new CRM_Core_BAO_Setting(); + $setting->name = $this->extensionName . ':version'; + $setting->delete(); + CRM_Core_Error::debug_log_message("Migrated extension schema revision ID for {$this->extensionName} from civicrm_setting (deprecated) to civicrm_extension.\n"); + } + } + + // ******** Hook delegates ******** + + /** + * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_install + */ + public function onInstall() { + $files = glob($this->extensionDir . '/sql/*_install.sql'); + if (is_array($files)) { + foreach ($files as $file) { + CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file); + } + } + $files = glob($this->extensionDir . '/sql/*_install.mysql.tpl'); + if (is_array($files)) { + foreach ($files as $file) { + $this->executeSqlTemplate($file); + } + } + $files = glob($this->extensionDir . '/xml/*_install.xml'); + if (is_array($files)) { + foreach ($files as $file) { + $this->executeCustomDataFileByAbsPath($file); + } + } + if (is_callable([$this, 'install'])) { + $this->install(); + } + } + + /** + * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_postInstall + */ + public function onPostInstall() { + $revisions = $this->getRevisions(); + if (!empty($revisions)) { + $this->setCurrentRevision(max($revisions)); + } + if (is_callable([$this, 'postInstall'])) { + $this->postInstall(); + } + } + + /** + * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_uninstall + */ + public function onUninstall() { + $files = glob($this->extensionDir . '/sql/*_uninstall.mysql.tpl'); + if (is_array($files)) { + foreach ($files as $file) { + $this->executeSqlTemplate($file); + } + } + if (is_callable([$this, 'uninstall'])) { + $this->uninstall(); + } + $files = glob($this->extensionDir . '/sql/*_uninstall.sql'); + if (is_array($files)) { + foreach ($files as $file) { + CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file); + } + } + } + + /** + * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable + */ + public function onEnable() { + // stub for possible future use + if (is_callable([$this, 'enable'])) { + $this->enable(); + } + } + + /** + * @see https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_disable + */ + public function onDisable() { + // stub for possible future use + if (is_callable([$this, 'disable'])) { + $this->disable(); + } + } + + public function onUpgrade($op, CRM_Queue_Queue $queue = NULL) { + switch ($op) { + case 'check': + return [$this->hasPendingRevisions()]; + + case 'enqueue': + return $this->enqueuePendingRevisions($queue); + + default: + } + } + +} diff --git a/ext/search/README.md b/ext/search/README.md new file mode 100644 index 0000000000..ab3e537391 --- /dev/null +++ b/ext/search/README.md @@ -0,0 +1,7 @@ +# org.civicrm.search + +A core extension to build advanced searches. + +## Usage + +Once enabled, navigate to **Search > Build Search for...** in the menu. diff --git a/ext/search/ang/search.ang.php b/ext/search/ang/search.ang.php new file mode 100644 index 0000000000..67275088f1 --- /dev/null +++ b/ext/search/ang/search.ang.php @@ -0,0 +1,17 @@ + [ + 'ang/*.js', + 'ang/search/*.js', + 'ang/search/*/*.js', + ], + 'css' => [ + 'css/*.css', + ], + 'partials' => [ + 'ang/search', + ], + 'basePages' => [], + 'requires' => ['crmUi', 'crmUtil', 'ngRoute', 'crmRouteBinder', 'ui.sortable', 'ui.bootstrap', 'dialogService', 'api4'], +]; diff --git a/ext/search/ang/search.module.js b/ext/search/ang/search.module.js new file mode 100644 index 0000000000..ca99534ae1 --- /dev/null +++ b/ext/search/ang/search.module.js @@ -0,0 +1,83 @@ +(function(angular, $, _) { + "use strict"; + + // Shared between router and searchMeta service + var searchEntity; + + // Declare module and route/controller/services + angular.module('search', CRM.angRequires('search')) + + .config(function($routeProvider) { + $routeProvider.when('/:entity', { + controller: 'searchRoute', + template: '', + reloadOnSearch: false + }); + }) + + // Controller binds entity to route + .controller('searchRoute', function($scope, $routeParams, $location) { + searchEntity = $scope.entity = $routeParams.entity; + + // Changing entity will refresh the angular page + $scope.$watch('entity', function(newEntity, oldEntity) { + if (newEntity && oldEntity && newEntity !== oldEntity) { + $location.url('/' + newEntity); + } + }); + }) + + .factory('searchMeta', function() { + function getEntity(entityName) { + if (entityName) { + entityName = entityName === true ? searchEntity : entityName; + return _.find(CRM.vars.search.schema, {name: entityName}); + } + } + function getField(name) { + var dotSplit = name.split('.'), + joinEntity = dotSplit.length > 1 ? dotSplit[0] : null, + fieldName = _.last(dotSplit).split(':')[0], + entityName = searchEntity; + if (joinEntity) { + entityName = _.find(CRM.vars.search.links[entityName], {alias: joinEntity}).entity; + } + return _.find(getEntity(entityName).fields, {name: fieldName}); + } + return { + getEntity: getEntity, + getField: getField, + parseExpr: function(expr) { + var result = {}, + fieldName = expr, + bracketPos = expr.indexOf('('); + if (bracketPos >= 0) { + fieldName = expr.match(/[A-Z( _]*([\w.:]+)/)[1]; + result.fn = _.find(CRM.vars.search.functions, {name: expr.substring(0, bracketPos)}); + } + result.field = getField(fieldName); + var split = fieldName.split(':'), + dotPos = split[0].indexOf('.'); + result.path = split[0]; + result.prefix = result.path.substring(0, dotPos + 1); + result.suffix = !split[1] ? '' : ':' + split[1]; + return result; + } + }; + }) + + // Reformat an array of objects for compatibility with select2 + // Todo this probably belongs in core + .factory('formatForSelect2', function() { + return function(input, key, label, extra) { + return _.transform(input, function(result, item) { + var formatted = {id: item[key], text: item[label]}; + if (extra) { + _.merge(formatted, _.pick(item, extra)); + } + result.push(formatted); + }, []); + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/SaveSmartGroup.ctrl.js b/ext/search/ang/search/SaveSmartGroup.ctrl.js new file mode 100644 index 0000000000..74401b9847 --- /dev/null +++ b/ext/search/ang/search/SaveSmartGroup.ctrl.js @@ -0,0 +1,54 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').controller('SaveSmartGroup', function ($scope, crmApi4, dialogService) { + var ts = $scope.ts = CRM.ts(), + model = $scope.model; + $scope.groupEntityRefParams = { + entity: 'Group', + api: { + params: {is_hidden: 0, is_active: 1, 'saved_search_id.api_entity': model.entity}, + extra: ['saved_search_id', 'description', 'visibility', 'group_type'] + }, + select: { + allowClear: true, + minimumInputLength: 0, + placeholder: ts('Select existing group') + } + }; + if (!CRM.checkPerm('administer reserved groups')) { + $scope.groupEntityRefParams.api.params.is_reserved = 0; + } + $scope.perm = { + administerReservedGroups: CRM.checkPerm('administer reserved groups') + }; + $scope.groupFields = _.indexBy(_.find(CRM.vars.search.schema, {name: 'Group'}).fields, 'name'); + $scope.$watch('model.id', function (id) { + if (id) { + _.assign(model, $('#api-save-search-select-group').select2('data').extra); + } + }); + $scope.cancel = function () { + dialogService.cancel('saveSearchDialog'); + }; + $scope.save = function () { + $('.ui-dialog:visible').block(); + var group = model.id ? {id: model.id} : {title: model.title}; + group.description = model.description; + group.visibility = model.visibility; + group.group_type = model.group_type; + group.saved_search_id = '$id'; + var savedSearch = { + api_entity: model.entity, + api_params: model.params + }; + if (group.id) { + savedSearch.id = model.saved_search_id; + } + crmApi4('SavedSearch', 'save', {records: [savedSearch], chain: {group: ['Group', 'save', {'records': [group]}]}}) + .then(function (result) { + dialogService.close('saveSearchDialog', result[0]); + }); + }; + }); +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/crmSearchActions.component.js b/ext/search/ang/search/crmSearchActions.component.js new file mode 100644 index 0000000000..7e88e2812e --- /dev/null +++ b/ext/search/ang/search/crmSearchActions.component.js @@ -0,0 +1,62 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').component('crmSearchActions', { + bindings: { + entity: '<', + ids: '<' + }, + require: { + search: '^crmSearchBuild' + }, + templateUrl: '~/search/crmSearchActions.html', + controller: function($scope, crmApi4, dialogService, searchMeta) { + var ts = $scope.ts = CRM.ts(), + entityTitle = searchMeta.getEntity(this.entity).title, + ctrl = this; + + this.init = function() { + if (!ctrl.actions) { + var actions = _.transform(_.cloneDeep(CRM.vars.search.actions), function (actions, action) { + if (_.includes(action.entities, ctrl.entity)) { + action.title = action.title.replace('%1', entityTitle); + actions.push(action); + } + }, []); + ctrl.actions = _.sortBy(actions, 'title'); + } + }; + + this.isActionAllowed = function(action) { + return !action.number || $scope.eval('' + $ctrl.ids.length + action.number); + }; + + this.doAction = function(action) { + if (!ctrl.isActionAllowed(action) || !ctrl.ids.length) { + return; + } + var data = { + ids: ctrl.ids, + entity: ctrl.entity + }; + // If action uses a crmPopup form + if (action.crmPopup) { + var path = $scope.$eval(action.crmPopup.path, data), + query = action.crmPopup.query && $scope.$eval(action.crmPopup.query, data); + CRM.loadForm(CRM.url(path, query)) + .on('crmFormSuccess', ctrl.search.refreshPage); + } + // If action uses dialogService + else if (action.uiDialog) { + var options = CRM.utils.adjustDialogDefaults({ + autoOpen: false, + title: action.title + }); + dialogService.open('crmSearchAction', action.uiDialog.templateUrl, data, options) + .then(ctrl.search.refreshPage); + } + }; + } + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/crmSearchActions.html b/ext/search/ang/search/crmSearchActions.html new file mode 100644 index 0000000000..dbeae2c84c --- /dev/null +++ b/ext/search/ang/search/crmSearchActions.html @@ -0,0 +1,10 @@ +
+ + +
diff --git a/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.ctrl.js b/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.ctrl.js new file mode 100644 index 0000000000..28a401e652 --- /dev/null +++ b/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.ctrl.js @@ -0,0 +1,24 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').controller('crmSearchActionDelete', function($scope, crmApi4, dialogService, searchMeta) { + var ts = $scope.ts = CRM.ts(), + model = $scope.model, + ctrl = $scope.$ctrl = this; + + this.entity = searchMeta.getEntity(model.entity); + + this.cancel = function() { + dialogService.cancel('crmSearchAction'); + }; + + this.delete = function() { + crmApi4(model.entity, 'Delete', { + where: [['id', 'IN', model.ids]], + }).then(function() { + dialogService.close('crmSearchAction'); + }); + }; + + }); +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.html b/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.html new file mode 100644 index 0000000000..a8c95de8a2 --- /dev/null +++ b/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.html @@ -0,0 +1,10 @@ +
+
+

{{:: ts('Are you sure you want to delete %1 %2?', {1: model.ids.length, 2: $ctrl.entity.title}) }}

+
+
+ + +
+
+
diff --git a/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.ctrl.js b/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.ctrl.js new file mode 100644 index 0000000000..63394a2427 --- /dev/null +++ b/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.ctrl.js @@ -0,0 +1,63 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').controller('crmSearchActionUpdate', function ($scope, $timeout, crmApi4, dialogService, searchMeta) { + var ts = $scope.ts = CRM.ts(), + model = $scope.model, + ctrl = $scope.$ctrl = this; + + this.entity = searchMeta.getEntity(model.entity); + this.values = []; + this.add = null; + + function fieldInUse(fieldName) { + return _.includes(_.collect(ctrl.values, 0), fieldName); + } + + this.updateField = function(index) { + // Debounce the onchange event using timeout + $timeout(function() { + if (!ctrl.values[index][0]) { + ctrl.values.splice(index, 1); + } + }); + }; + + this.addField = function() { + // Debounce the onchange event using timeout + $timeout(function() { + if (ctrl.add) { + ctrl.values.push([ctrl.add, '']); + } + ctrl.add = null; + }); + }; + + this.availableFields = function() { + var results = _.transform(ctrl.entity.fields, function(result, item) { + var formatted = {id: item.name, text: item.title, description: item.description}; + if (fieldInUse(item.name)) { + formatted.disabled = true; + } + if (item.name !== 'id') { + result.push(formatted); + } + }, []); + return {results: results}; + }; + + this.cancel = function() { + dialogService.cancel('crmSearchAction'); + }; + + this.save = function() { + crmApi4(model.entity, 'Update', { + where: [['id', 'IN', model.ids]], + values: _.zipObject(ctrl.values) + }).then(function() { + dialogService.close('crmSearchAction'); + }); + }; + + }); +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.html b/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.html new file mode 100644 index 0000000000..63178a0bae --- /dev/null +++ b/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.html @@ -0,0 +1,16 @@ +
+
+
+ + +
+
+ +
+
+
+ + +
+
+
diff --git a/ext/search/ang/search/crmSearchBuild.component.js b/ext/search/ang/search/crmSearchBuild.component.js new file mode 100644 index 0000000000..972863a703 --- /dev/null +++ b/ext/search/ang/search/crmSearchBuild.component.js @@ -0,0 +1,539 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').component('crmSearchBuild', { + bindings: { + entity: '=' + }, + templateUrl: '~/search/crmSearchBuild.html', + controller: function($scope, $element, $timeout, crmApi4, dialogService, searchMeta, formatForSelect2) { + var ts = $scope.ts = CRM.ts(), + ctrl = this; + this.selectedRows = []; + this.limit = CRM.cache.get('searchPageSize', 30); + this.page = 1; + this.params = {}; + // After a search this.results is an object of result arrays keyed by page, + // Prior to searching it's an empty string because 1: falsey and 2: doesn't throw an error if you try to access undefined properties + this.results = ''; + this.rowCount = false; + // Have the filters (WHERE, HAVING, GROUP BY, JOIN) changed? + this.stale = true; + this.allRowsSelected = false; + + $scope.controls = {}; + $scope.joinTypes = [{k: false, v: ts('Optional')}, {k: true, v: ts('Required')}]; + $scope.entities = formatForSelect2(CRM.vars.search.schema, 'name', 'title', ['description', 'icon']); + this.perm = { + editGroups: CRM.checkPerm('edit groups') + }; + + this.getEntity = searchMeta.getEntity; + + this.paramExists = function(param) { + return _.includes(searchMeta.getEntity(ctrl.entity).params, param); + }; + + $scope.getJoinEntities = function() { + var joinEntities = _.transform(CRM.vars.search.links[ctrl.entity], function(joinEntities, link) { + var entity = searchMeta.getEntity(link.entity); + if (entity) { + joinEntities.push({ + id: link.entity + ' AS ' + link.alias, + text: entity.title, + description: '(' + link.alias + ')', + icon: entity.icon + }); + } + }, []); + return {results: joinEntities}; + }; + + $scope.addJoin = function() { + // Debounce the onchange event using timeout + $timeout(function() { + if ($scope.controls.join) { + ctrl.params.join = ctrl.params.join || []; + ctrl.params.join.push([$scope.controls.join, false]); + loadFieldOptions(); + } + $scope.controls.join = ''; + }); + }; + + $scope.changeJoin = function(idx) { + if (ctrl.params.join[idx][0]) { + ctrl.params.join[idx].length = 2; + loadFieldOptions(); + } else { + ctrl.clearParam('join', idx); + } + }; + + $scope.changeGroupBy = function(idx) { + if (!ctrl.params.groupBy[idx]) { + ctrl.clearParam('groupBy', idx); + } + }; + + /** + * Called when clicking on a column header + * @param col + * @param $event + */ + $scope.setOrderBy = function(col, $event) { + var dir = $scope.getOrderBy(col) === 'fa-sort-asc' ? 'DESC' : 'ASC'; + if (!$event.shiftKey) { + ctrl.params.orderBy = {}; + } + ctrl.params.orderBy[col] = dir; + }; + + /** + * Returns crm-i icon class for a sortable column + * @param col + * @returns {string} + */ + $scope.getOrderBy = function(col) { + var dir = ctrl.params.orderBy && ctrl.params.orderBy[col]; + if (dir) { + return 'fa-sort-' + dir.toLowerCase(); + } + return 'fa-sort disabled'; + }; + + $scope.addParam = function(name) { + if ($scope.controls[name] && !_.contains(ctrl.params[name], $scope.controls[name])) { + ctrl.params[name].push($scope.controls[name]); + if (name === 'groupBy') { + // Expand the aggregate block + $timeout(function() { + $('#crm-search-build-group-aggregate.collapsed .collapsible-title').click(); + }, 10); + } + } + $scope.controls[name] = ''; + }; + + // Deletes an item from an array param + this.clearParam = function(name, idx) { + ctrl.params[name].splice(idx, 1); + }; + + // Prevent visual jumps in results table height during loading + function lockTableHeight() { + var $table = $('.crm-search-results', $element); + $table.css('height', $table.height()); + } + + function unlockTableHeight() { + $('.crm-search-results', $element).css('height', ''); + } + + // Debounced callback for loadResults + function _loadResultsCallback() { + // Multiply limit to read 2 pages at once & save ajax requests + var params = angular.merge({debug: true, limit: ctrl.limit * 2}, ctrl.params); + lockTableHeight(); + $scope.error = false; + if (ctrl.stale) { + ctrl.page = 1; + ctrl.rowCount = false; + } + if (ctrl.rowCount === false) { + params.select.push('row_count'); + } + params.offset = ctrl.limit * (ctrl.page - 1); + crmApi4(ctrl.entity, 'get', params).then(function(success) { + if (ctrl.stale) { + ctrl.results = {}; + } + if (ctrl.rowCount === false) { + ctrl.rowCount = success.count; + } + ctrl.debug = success.debug; + // populate this page & the next + ctrl.results[ctrl.page] = success.slice(0, ctrl.limit); + if (success.length > ctrl.limit) { + ctrl.results[ctrl.page + 1] = success.slice(ctrl.limit); + } + $scope.loading = false; + ctrl.stale = false; + unlockTableHeight(); + }, function(error) { + $scope.loading = false; + ctrl.results = {}; + ctrl.stale = true; + ctrl.debug = error.debug; + $scope.error = errorMsg(error); + }); + } + + var _loadResults = _.debounce(_loadResultsCallback, 250); + + function loadResults() { + $scope.loading = true; + _loadResults(); + } + + // What to tell the user when search returns an error from the server + // Todo: parse error codes and give helpful feedback. + function errorMsg(error) { + return ts('Ensure all search critera are set correctly and try again.'); + } + + this.changePage = function() { + if (ctrl.stale || !ctrl.results[ctrl.page]) { + lockTableHeight(); + loadResults(); + } + }; + + this.refreshAll = function() { + ctrl.stale = true; + ctrl.selectedRows.length = 0; + loadResults(); + }; + + // Refresh results while staying on current page. + this.refreshPage = function() { + lockTableHeight(); + ctrl.results = {}; + loadResults(); + }; + + $scope.onClickSearch = function() { + if (ctrl.autoSearch) { + ctrl.autoSearch = false; + } else { + ctrl.refreshAll(); + } + }; + + $scope.onClickAuto = function() { + ctrl.autoSearch = !ctrl.autoSearch; + if (ctrl.autoSearch && ctrl.stale) { + ctrl.refreshAll(); + } + }; + + $scope.onChangeLimit = function() { + // Refresh only if search has already been run + if (ctrl.autoSearch || ctrl.results) { + // Save page size in localStorage + CRM.cache.set('searchPageSize', ctrl.limit); + ctrl.refreshAll(); + } + }; + + function onChangeSelect(newSelect, oldSelect) { + // Re-arranging or removing columns doesn't merit a refresh, only adding columns does + if (!oldSelect || _.difference(newSelect, oldSelect).length) { + if (ctrl.autoSearch) { + ctrl.refreshPage(); + } else { + ctrl.stale = true; + } + } + } + + function onChangeOrderBy() { + if (ctrl.results) { + ctrl.refreshPage(); + } + } + + function onChangeFilters() { + ctrl.stale = true; + ctrl.selectedRows.length = 0; + if (ctrl.autoSearch) { + ctrl.refreshAll(); + } + } + + $scope.selectAllRows = function() { + // Deselect all + if (ctrl.allRowsSelected) { + ctrl.allRowsSelected = false; + ctrl.selectedRows.length = 0; + return; + } + // Select all + ctrl.allRowsSelected = true; + if (ctrl.page === 1 && ctrl.results[1].length < ctrl.limit) { + ctrl.selectedRows = _.pluck(ctrl.results[1], 'id'); + return; + } + // If more than one page of results, use ajax to fetch all ids + $scope.loadingAllRows = true; + var params = _.cloneDeep(ctrl.params); + params.select = ['id']; + crmApi4(ctrl.entity, 'get', params, ['id']).then(function(ids) { + $scope.loadingAllRows = false; + ctrl.selectedRows = _.toArray(ids); + }); + }; + + $scope.selectRow = function(row) { + var index = ctrl.selectedRows.indexOf(row.id); + if (index < 0) { + ctrl.selectedRows.push(row.id); + ctrl.allRowsSelected = (ctrl.rowCount === ctrl.selectedRows.length); + } else { + ctrl.allRowsSelected = false; + ctrl.selectedRows.splice(index, 1); + } + }; + + $scope.isRowSelected = function(row) { + return ctrl.allRowsSelected || _.includes(ctrl.selectedRows, row.id); + }; + + this.getFieldLabel = function(col) { + var info = searchMeta.parseExpr(col), + label = info.field.title; + if (info.fn) { + label = '(' + info.fn.title + ') ' + label; + } + return label; + }; + + // Is a column eligible to use an aggregate function? + this.canAggregate = function(col) { + // If the column is used for a groupBy, no + if (ctrl.params.groupBy.indexOf(col) > -1) { + return false; + } + // If the entity this column belongs to is being grouped by id, then also no + var info = searchMeta.parseExpr(col); + return ctrl.params.groupBy.indexOf(info.prefix + 'id') < 0; + }; + + $scope.formatResult = function formatResult(row, col) { + var info = searchMeta.parseExpr(col), + key = info.fn ? (info.fn.name + ':' + info.path + info.suffix) : col, + value = row[key]; + // Handle grouped results + if (info.fn && info.fn.name === 'GROUP_CONCAT' && value) { + return formatGroupConcatValues(info, value); + } + return formatFieldValue(info.field, value); + }; + + function formatFieldValue(field, value) { + var type = field.data_type; + if (value && (type === 'Date' || type === 'Timestamp') && /^\d{4}-\d{2}-\d{2}/.test(value)) { + return CRM.utils.formatDate(value, null, type === 'Timestamp'); + } + else if (type === 'Boolean' && typeof value === 'boolean') { + return value ? ts('Yes') : ts('No'); + } + return value; + } + + function formatGroupConcatValues(info, values) { + return _.transform(values.split(','), function(result, val) { + if (info.field.options && !info.suffix) { + result.push(_.result(getOption(info.field, val), 'label')); + } else { + result.push(formatFieldValue(info.field, val)); + } + }).join(', '); + } + + function getOption(field, value) { + return _.find(field.options, function(option) { + // Type coersion is intentional + return option.id == value; + }); + } + + $scope.fieldsForGroupBy = function() { + return {results: getAllFields('', function(key) { + return _.contains(ctrl.params.groupBy, key); + }) + }; + }; + + $scope.fieldsForSelect = function() { + return {results: getAllFields(':label', function(key) { + return _.contains(ctrl.params.select, key); + }) + }; + }; + + $scope.fieldsForWhere = function() { + return {results: getAllFields(':name', _.noop)}; + }; + + $scope.fieldsForHaving = function() { + return {results: _.transform(ctrl.params.select, function(fields, name) { + fields.push({id: name, text: ctrl.getFieldLabel(name)}); + })}; + }; + + function getDefaultSelect() { + return _.filter(['id', 'display_name', 'label', 'title', 'location_type_id:label'], searchMeta.getField); + } + + function getAllFields(suffix, disabledIf) { + function formatFields(entityName, prefix) { + return _.transform(searchMeta.getEntity(entityName).fields, function(result, field) { + var item = { + id: prefix + field.name + (field.options ? suffix : ''), + text: field.title, + description: field.description + }; + if (disabledIf(item.id)) { + item.disabled = true; + } + result.push(item); + }, []); + } + + var mainEntity = searchMeta.getEntity(ctrl.entity), + result = [{ + text: mainEntity.title, + icon: mainEntity.icon, + children: formatFields(ctrl.entity, '') + }]; + _.each(ctrl.params.join, function(join) { + var joinName = join[0].split(' AS '), + joinEntity = searchMeta.getEntity(joinName[0]); + result.push({ + text: joinEntity.title + ' (' + joinName[1] + ')', + icon: joinEntity.icon, + children: formatFields(joinEntity.name, joinName[1] + '.') + }); + }); + return result; + } + + /** + * Fetch pseudoconstants for main entity + joined entities + * + * Sets an optionsLoaded property on each entity to avoid duplicate requests + */ + function loadFieldOptions() { + var mainEntity = searchMeta.getEntity(ctrl.entity), + entities = {}; + + function enqueue(entity) { + entity.optionsLoaded = false; + entities[entity.name] = [entity.name, 'getFields', { + loadOptions: CRM.vars.search.loadOptions, + where: [['options', '!=', false]], + select: ['options'] + }, {name: 'options'}]; + } + + if (typeof mainEntity.optionsLoaded === 'undefined') { + enqueue(mainEntity); + } + _.each(ctrl.params.join, function(join) { + var joinName = join[0].split(' AS '), + joinEntity = searchMeta.getEntity(joinName[0]); + if (typeof joinEntity.optionsLoaded === 'undefined') { + enqueue(joinEntity); + } + }); + if (!_.isEmpty(entities)) { + crmApi4(entities).then(function(results) { + _.each(results, function(fields, entityName) { + var entity = searchMeta.getEntity(entityName); + _.each(fields, function(options, fieldName) { + _.find(entity.fields, {name: fieldName}).options = options; + }); + entity.optionsLoaded = true; + }); + }); + } + } + + this.$onInit = function() { + loadFieldOptions(); + + $scope.$bindToRoute({ + expr: '$ctrl.params.select', + param: 'select', + format: 'json', + default: getDefaultSelect() + }); + $scope.$watchCollection('$ctrl.params.select', onChangeSelect); + + $scope.$bindToRoute({ + expr: '$ctrl.params.orderBy', + param: 'orderBy', + format: 'json', + default: {} + }); + $scope.$watchCollection('$ctrl.params.orderBy', onChangeOrderBy); + + $scope.$bindToRoute({ + expr: '$ctrl.params.where', + param: 'where', + format: 'json', + default: [], + deep: true + }); + $scope.$watch('$ctrl.params.where', onChangeFilters, true); + + if (this.paramExists('groupBy')) { + $scope.$bindToRoute({ + expr: '$ctrl.params.groupBy', + param: 'groupBy', + format: 'json', + default: [] + }); + } + $scope.$watchCollection('$ctrl.params.groupBy', onChangeFilters); + + if (this.paramExists('join')) { + $scope.$bindToRoute({ + expr: '$ctrl.params.join', + param: 'join', + format: 'json', + default: [], + deep: true + }); + } + $scope.$watch('$ctrl.params.join', onChangeFilters, true); + + if (this.paramExists('having')) { + $scope.$bindToRoute({ + expr: '$ctrl.params.having', + param: 'having', + format: 'json', + default: [], + deep: true + }); + } + $scope.$watch('$ctrl.params.having', onChangeFilters, true); + }; + + $scope.saveGroup = function() { + var selectField = ctrl.entity === 'Contact' ? 'id' : 'contact_id'; + if (ctrl.entity !== 'Contact' && !searchMeta.getField('contact_id')) { + CRM.alert(ts('Cannot create smart group from %1.', {1: searchMeta.getEntity(true).title}), ts('Missing contact_id'), 'error', {expires: 5000}); + return; + } + var model = { + title: '', + description: '', + visibility: 'User and User Admin Only', + group_type: [], + id: null, + entity: ctrl.entity, + params: angular.extend({}, ctrl.params, {version: 4, select: [selectField]}) + }; + delete model.params.orderBy; + var options = CRM.utils.adjustDialogDefaults({ + autoOpen: false, + title: ts('Save smart group') + }); + dialogService.open('saveSearchDialog', '~/search/saveSmartGroup.html', model, options); + }; + } + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/crmSearchBuild.html b/ext/search/ang/search/crmSearchBuild.html new file mode 100644 index 0000000000..f236894013 --- /dev/null +++ b/ext/search/ang/search/crmSearchBuild.html @@ -0,0 +1,20 @@ + diff --git a/ext/search/ang/search/crmSearchBuild/build.html b/ext/search/ang/search/crmSearchBuild/build.html new file mode 100644 index 0000000000..6bb500a741 --- /dev/null +++ b/ext/search/ang/search/crmSearchBuild/build.html @@ -0,0 +1,48 @@ +
+
+
+ + +
+
+
+
+ + + +
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+
+
+
+
+
diff --git a/ext/search/ang/search/crmSearchBuild/controls.html b/ext/search/ang/search/crmSearchBuild/controls.html new file mode 100644 index 0000000000..101b4da47d --- /dev/null +++ b/ext/search/ang/search/crmSearchBuild/controls.html @@ -0,0 +1,25 @@ +
+
+
+ + +
+ +
+ + +
+
diff --git a/ext/search/ang/search/crmSearchBuild/debug.html b/ext/search/ang/search/crmSearchBuild/debug.html new file mode 100644 index 0000000000..e7e4bd255e --- /dev/null +++ b/ext/search/ang/search/crmSearchBuild/debug.html @@ -0,0 +1,7 @@ + diff --git a/ext/search/ang/search/crmSearchBuild/pager.html b/ext/search/ang/search/crmSearchBuild/pager.html new file mode 100644 index 0000000000..2c4a2b4ba1 --- /dev/null +++ b/ext/search/ang/search/crmSearchBuild/pager.html @@ -0,0 +1,35 @@ +
+
+
+ + + +
+
+
+
    +
    +
    + + +
    +
    diff --git a/ext/search/ang/search/crmSearchBuild/results.html b/ext/search/ang/search/crmSearchBuild/results.html new file mode 100644 index 0000000000..ab14f816d2 --- /dev/null +++ b/ext/search/ang/search/crmSearchBuild/results.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + +
    + + + + {{ $ctrl.getFieldLabel(col) }} + + + +
    + + + {{ formatResult(row, col) }} +
    +
    +

    {{:: ts('An error occurred') }}

    +

    {{ error }}

    +
    diff --git a/ext/search/ang/search/crmSearchClause.directive.js b/ext/search/ang/search/crmSearchClause.directive.js new file mode 100644 index 0000000000..ab59aff122 --- /dev/null +++ b/ext/search/ang/search/crmSearchClause.directive.js @@ -0,0 +1,75 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').directive('crmSearchClause', function() { + return { + scope: { + data: '= ($scope.data.skip || 0)) { + if (typeof clause !== 'undefined' && !clause[0]) { + values.splice(index, 1); + } + // Add/remove value if operator allows for one + else if (typeof clause[1] === 'string' && _.contains(clause[1], 'NULL')) { + clause.length = 2; + } else if (typeof clause[1] === 'string' && clause.length === 2) { + clause.push(''); + } + } + }); + }, true); + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/crmSearchClause.html b/ext/search/ang/search/crmSearchClause.html new file mode 100644 index 0000000000..1a0567f9cf --- /dev/null +++ b/ext/search/ang/search/crmSearchClause.html @@ -0,0 +1,41 @@ +{{ data.label || ts('%1 group', {1: $ctrl.conjunctions[data.op]}) }} +
    + +
    +
    +
    +
    +
    + + {{ data.label }} + {{ $ctrl.conjunctions[data.op] }} + + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    diff --git a/ext/search/ang/search/crmSearchFunction.component.js b/ext/search/ang/search/crmSearchFunction.component.js new file mode 100644 index 0000000000..e75bac9571 --- /dev/null +++ b/ext/search/ang/search/crmSearchFunction.component.js @@ -0,0 +1,28 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').component('crmSearchFunction', { + bindings: { + expr: '=', + cat: '<' + }, + templateUrl: '~/search/crmSearchFunction.html', + controller: function($scope, formatForSelect2, searchMeta) { + var ts = $scope.ts = CRM.ts(), + ctrl = this; + this.functions = formatForSelect2(_.where(CRM.vars.search.functions, {category: this.cat}), 'name', 'title'); + + this.$onInit = function() { + var fieldInfo = searchMeta.parseExpr(ctrl.expr); + ctrl.path = fieldInfo.path; + ctrl.field = fieldInfo.field; + ctrl.fn = !fieldInfo.fn ? '' : fieldInfo.fn.name; + }; + + this.selectFunction = function() { + ctrl.expr = ctrl.fn ? (ctrl.fn + '(' + ctrl.path + ')') : ctrl.path; + }; + } + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/crmSearchFunction.html b/ext/search/ang/search/crmSearchFunction.html new file mode 100644 index 0000000000..a88cc0a31a --- /dev/null +++ b/ext/search/ang/search/crmSearchFunction.html @@ -0,0 +1,4 @@ +
    + + +
    diff --git a/ext/search/ang/search/crmSearchValue.directive.js b/ext/search/ang/search/crmSearchValue.directive.js new file mode 100644 index 0000000000..0fb1600884 --- /dev/null +++ b/ext/search/ang/search/crmSearchValue.directive.js @@ -0,0 +1,115 @@ +(function(angular, $, _) { + "use strict"; + + angular.module('search').directive('crmSearchValue', function($interval, searchMeta, formatForSelect2) { + return { + scope: { + data: '=crmSearchValue' + }, + require: 'ngModel', + link: function (scope, element, attrs, ngModel) { + var ts = scope.ts = CRM.ts(), + multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], scope.data.op), + format = scope.data.format; + + function destroyWidget() { + var $el = $(element); + if ($el.is('.crm-form-date-wrapper .crm-hidden-date')) { + $el.crmDatepicker('destroy'); + } + if ($el.is('.select2-container + input')) { + $el.crmEntityRef('destroy'); + } + $(element).removeData().removeAttr('type').removeAttr('placeholder').show(); + } + + function makeWidget(field, op, optionKey) { + var $el = $(element), + inputType = field.input_type, + dataType = field.data_type; + if (!op) { + op = field.serialize || dataType === 'Array' ? 'IN' : '='; + } + multi = _.includes(['IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN'], op); + if (op === 'IS NULL' || op === 'IS NOT NULL') { + $el.hide(); + return; + } + if (inputType === 'Date') { + if (_.includes(['=', '!=', '<>', '>', '>=', '<', '<='], op)) { + $el.crmDatepicker({time: (field.input_attrs && field.input_attrs.time) || false}); + } + } else if (_.includes(['=', '!=', '<>', 'IN', 'NOT IN'], op) && (field.fk_entity || field.options || dataType === 'Boolean')) { + if (field.options) { + if (field.options === true) { + $el.addClass('loading'); + var waitForOptions = $interval(function() { + if (field.options !== true) { + $interval.cancel(waitForOptions); + $el.removeClass('loading').crmSelect2({data: getFieldOptions, multiple: multi}); + } + }, 200); + } + $el.attr('placeholder', ts('select')).crmSelect2({data: getFieldOptions, multiple: multi}); + } else if (field.fk_entity) { + $el.crmEntityRef({entity: field.fk_entity, select:{multiple: multi}}); + } else if (dataType === 'Boolean') { + $el.attr('placeholder', ts('- select -')).crmSelect2({allowClear: false, multiple: multi, placeholder: ts('- select -'), data: [ + // FIXME: it would be more correct to use real true/false booleans instead of numbers, but select2 doesn't seem to like them + {id: 1, text: ts('Yes')}, + {id: 0, text: ts('No')} + ]}); + } + } else if (dataType === 'Integer' && !multi) { + $el.attr('type', 'number'); + } + + function getFieldOptions() { + return {results: formatForSelect2(field.options, optionKey, 'label', ['description', 'color', 'icon'])}; + } + } + + // Copied from ng-list but applied conditionally if field is multi-valued + var parseList = function(viewValue) { + // If the viewValue is invalid (say required but empty) it will be `undefined` + if (_.isUndefined(viewValue)) return; + + if (!multi) { + return format === 'json' ? JSON.stringify(viewValue) : viewValue; + } + + var list = []; + + if (viewValue) { + _.each(viewValue.split(','), function(value) { + if (value) list.push(_.trim(value)); + }); + } + + return list; + }; + + // Copied from ng-list + ngModel.$parsers.push(parseList); + ngModel.$formatters.push(function(value) { + return _.isArray(value) ? value.join(', ') : (format === 'json' && value !== '' ? JSON.parse(value) : value); + }); + + // Copied from ng-list + ngModel.$isEmpty = function(value) { + return !value || !value.length; + }; + + scope.$watchCollection('data', function(data) { + destroyWidget(); + var field = searchMeta.parseExpr(data.field).field; + if (field) { + var optionKey = data.field.split(':')[1] || 'id'; + makeWidget(field, data.op, optionKey); + } + }); + } + }; + }); + +})(angular, CRM.$, CRM._); diff --git a/ext/search/ang/search/saveSmartGroup.html b/ext/search/ang/search/saveSmartGroup.html new file mode 100644 index 0000000000..a0caf4af2c --- /dev/null +++ b/ext/search/ang/search/saveSmartGroup.html @@ -0,0 +1,26 @@ +
    +
    + + + +
    + + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    diff --git a/ext/search/css/search.css b/ext/search/css/search.css new file mode 100644 index 0000000000..cf549baf4e --- /dev/null +++ b/ext/search/css/search.css @@ -0,0 +1,149 @@ +.crm-flex-box { + display: flex; +} +.crm-flex-box > * { + flex: 1; +} +.crm-flex-box > .crm-flex-2 { + flex: 2; +} +.crm-flex-box > .crm-flex-3 { + flex: 3; +} +.crm-flex-box > .crm-flex-4 { + flex: 4; +} +#bootstrap-theme #crm-search-results-page-size { + width: 5em; +} +#bootstrap-theme .crm-search-results { + min-height: 200px; +} +.crm-search-results thead th[ng-repeat] { + cursor: pointer; +} +.crm-search-results thead th[ng-repeat] > span { + cursor: move; +} + +#bootstrap-theme.crm-search fieldset { + padding: 6px; + border-top: 1px solid lightgrey; + margin-top: 10px; + margin-bottom: 10px; + position: relative; +} + +#bootstrap-theme.crm-search fieldset fieldset { + padding-top: 0; + border-left: 1px solid lightgrey; + border-right: 1px solid lightgrey; + border-bottom: 1px solid lightgrey; +} + +#bootstrap-theme.crm-search fieldset legend { + background-color: white; + font-size: 13px; + margin: 0; + width: auto; + border: 0 none; + padding: 2px 5px; + text-transform: capitalize; +} +#bootstrap-theme.crm-search fieldset > .btn-group { + position: absolute; + right: 0; + top: 11px; +} +#bootstrap-theme.crm-search fieldset > .btn-group .btn { + border: 0 none; +} + +#bootstrap-theme.crm-search fieldset div.api4-input { + margin-bottom: 10px; +} + +#bootstrap-theme.crm-search fieldset div.api4-input.ui-sortable-helper { + background-color: rgba(255, 255, 255, .9); +} + +#bootstrap-theme.crm-search fieldset div.api4-input.ui-sortable-helper { + background-color: rgba(255, 255, 255, .9); +} + +#bootstrap-theme.crm-search div.api4-input.form-inline label.form-control { + margin-right: 6px; +} +#bootstrap-theme.crm-search div.api4-input.form-inline label.form-control input[type=checkbox] { + margin: 0 2px 0 0; +} + +#bootstrap-theme.crm-search div.api4-input.form-inline label.form-control:not(.api4-option-selected) { + transition: none; + box-shadow: none; + -webkit-box-shadow: none; + -moz-box-shadow: none; + font-weight: normal; +} + +#bootstrap-theme.crm-search div.api4-input.form-inline .form-control label { + font-weight: normal; + position: relative; + top: -2px; +} +#bootstrap-theme.crm-search .api4-clause-fieldset fieldset { + float: right; + width: calc(100% - 58px); + margin-top: -8px; +} + +#bootstrap-theme.crm-search .api4-clause-fieldset.api4-sorting fieldset .api4-clause-group-sortable { + min-height: 3.5em; +} + +#bootstrap-theme.crm-search .api4-input-group { + display: inline-block; +} + +#bootstrap-theme.crm-search i.fa-arrows { + cursor: move; +} + +#bootstrap-theme.crm-search .api4-clause-badge { + width: 55px; + display: inline-block; + cursor: move; +} +#bootstrap-theme.crm-search .api4-clause-badge .badge { + opacity: .5; + position: relative; +} +#bootstrap-theme.crm-search .api4-clause-badge .caret { + margin: 0; +} +#bootstrap-theme.crm-search .api4-clause-badge .crm-i { + display: none; + padding: 0 6px; +} +#bootstrap-theme.crm-search .ui-sortable-helper .api4-clause-badge .badge span { + display: none; +} +#bootstrap-theme.crm-search .ui-sortable-helper .api4-clause-badge .crm-i { + display: inline-block; +} + +#bootstrap-theme.crm-search .api4-operator { + width: 90px; +} + +#bootstrap-theme.crm-search .api4-add-where-group-menu { + min-width: 80px; + background-color: rgba(186, 225, 251, 0.94); +} +#bootstrap-theme.crm-search .api4-add-where-group-menu a { + padding: 5px 10px; +} + +#bootstrap-theme.crm-search .btn.form-control { + height: 36px; +} diff --git a/ext/search/info.xml b/ext/search/info.xml new file mode 100644 index 0000000000..e64d56f318 --- /dev/null +++ b/ext/search/info.xml @@ -0,0 +1,27 @@ + + + search + Search + Build searches for a wide variety of CiviCRM entities + AGPL-3.0 + + Coleman Watts + coleman@civicrm.org + + + http://www.gnu.org/licenses/agpl-3.0.html + + 2020-07-08 + 1.0 + stable + + 5.28 + + Distributed with CiviCRM core + + + + + CRM/Search + + diff --git a/ext/search/search.civix.php b/ext/search/search.civix.php new file mode 100644 index 0000000000..2a696e2cff --- /dev/null +++ b/ext/search/search.civix.php @@ -0,0 +1,477 @@ +getUrl(self::LONG_NAME), '/'); + } + return CRM_Core_Resources::singleton()->getUrl(self::LONG_NAME, $file); + } + + /** + * Get the path of a resource file (in this extension). + * + * @param string|NULL $file + * Ex: NULL. + * Ex: 'css/foo.css'. + * @return string + * Ex: '/var/www/example.org/sites/default/ext/org.example.foo'. + * Ex: '/var/www/example.org/sites/default/ext/org.example.foo/css/foo.css'. + */ + public static function path($file = NULL) { + // return CRM_Core_Resources::singleton()->getPath(self::LONG_NAME, $file); + return __DIR__ . ($file === NULL ? '' : (DIRECTORY_SEPARATOR . $file)); + } + + /** + * Get the name of a class within this extension. + * + * @param string $suffix + * Ex: 'Page_HelloWorld' or 'Page\\HelloWorld'. + * @return string + * Ex: 'CRM_Foo_Page_HelloWorld'. + */ + public static function findClass($suffix) { + return self::CLASS_PREFIX . '_' . str_replace('\\', '_', $suffix); + } + +} + +use CRM_Search_ExtensionUtil as E; + +/** + * (Delegated) Implements hook_civicrm_config(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config + */ +function _search_civix_civicrm_config(&$config = NULL) { + static $configured = FALSE; + if ($configured) { + return; + } + $configured = TRUE; + + $template =& CRM_Core_Smarty::singleton(); + + $extRoot = dirname(__FILE__) . DIRECTORY_SEPARATOR; + $extDir = $extRoot . 'templates'; + + if (is_array($template->template_dir)) { + array_unshift($template->template_dir, $extDir); + } + else { + $template->template_dir = [$extDir, $template->template_dir]; + } + + $include_path = $extRoot . PATH_SEPARATOR . get_include_path(); + set_include_path($include_path); +} + +/** + * (Delegated) Implements hook_civicrm_xmlMenu(). + * + * @param $files array(string) + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_xmlMenu + */ +function _search_civix_civicrm_xmlMenu(&$files) { + foreach (_search_civix_glob(__DIR__ . '/xml/Menu/*.xml') as $file) { + $files[] = $file; + } +} + +/** + * Implements hook_civicrm_install(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_install + */ +function _search_civix_civicrm_install() { + _search_civix_civicrm_config(); + if ($upgrader = _search_civix_upgrader()) { + $upgrader->onInstall(); + } +} + +/** + * Implements hook_civicrm_postInstall(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_postInstall + */ +function _search_civix_civicrm_postInstall() { + _search_civix_civicrm_config(); + if ($upgrader = _search_civix_upgrader()) { + if (is_callable([$upgrader, 'onPostInstall'])) { + $upgrader->onPostInstall(); + } + } +} + +/** + * Implements hook_civicrm_uninstall(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_uninstall + */ +function _search_civix_civicrm_uninstall() { + _search_civix_civicrm_config(); + if ($upgrader = _search_civix_upgrader()) { + $upgrader->onUninstall(); + } +} + +/** + * (Delegated) Implements hook_civicrm_enable(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable + */ +function _search_civix_civicrm_enable() { + _search_civix_civicrm_config(); + if ($upgrader = _search_civix_upgrader()) { + if (is_callable([$upgrader, 'onEnable'])) { + $upgrader->onEnable(); + } + } +} + +/** + * (Delegated) Implements hook_civicrm_disable(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_disable + * @return mixed + */ +function _search_civix_civicrm_disable() { + _search_civix_civicrm_config(); + if ($upgrader = _search_civix_upgrader()) { + if (is_callable([$upgrader, 'onDisable'])) { + $upgrader->onDisable(); + } + } +} + +/** + * (Delegated) Implements hook_civicrm_upgrade(). + * + * @param $op string, the type of operation being performed; 'check' or 'enqueue' + * @param $queue CRM_Queue_Queue, (for 'enqueue') the modifiable list of pending up upgrade tasks + * + * @return mixed + * based on op. for 'check', returns array(boolean) (TRUE if upgrades are pending) + * for 'enqueue', returns void + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_upgrade + */ +function _search_civix_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) { + if ($upgrader = _search_civix_upgrader()) { + return $upgrader->onUpgrade($op, $queue); + } +} + +/** + * @return CRM_Search_Upgrader + */ +function _search_civix_upgrader() { + if (!file_exists(__DIR__ . '/CRM/Search/Upgrader.php')) { + return NULL; + } + else { + return CRM_Search_Upgrader_Base::instance(); + } +} + +/** + * Search directory tree for files which match a glob pattern. + * + * Note: Dot-directories (like "..", ".git", or ".svn") will be ignored. + * Note: In Civi 4.3+, delegate to CRM_Utils_File::findFiles() + * + * @param string $dir base dir + * @param string $pattern , glob pattern, eg "*.txt" + * + * @return array + */ +function _search_civix_find_files($dir, $pattern) { + if (is_callable(['CRM_Utils_File', 'findFiles'])) { + return CRM_Utils_File::findFiles($dir, $pattern); + } + + $todos = [$dir]; + $result = []; + while (!empty($todos)) { + $subdir = array_shift($todos); + foreach (_search_civix_glob("$subdir/$pattern") as $match) { + if (!is_dir($match)) { + $result[] = $match; + } + } + if ($dh = opendir($subdir)) { + while (FALSE !== ($entry = readdir($dh))) { + $path = $subdir . DIRECTORY_SEPARATOR . $entry; + if ($entry[0] == '.') { + } + elseif (is_dir($path)) { + $todos[] = $path; + } + } + closedir($dh); + } + } + return $result; +} + +/** + * (Delegated) Implements hook_civicrm_managed(). + * + * Find any *.mgd.php files, merge their content, and return. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_managed + */ +function _search_civix_civicrm_managed(&$entities) { + $mgdFiles = _search_civix_find_files(__DIR__, '*.mgd.php'); + sort($mgdFiles); + foreach ($mgdFiles as $file) { + $es = include $file; + foreach ($es as $e) { + if (empty($e['module'])) { + $e['module'] = E::LONG_NAME; + } + if (empty($e['params']['version'])) { + $e['params']['version'] = '3'; + } + $entities[] = $e; + } + } +} + +/** + * (Delegated) Implements hook_civicrm_caseTypes(). + * + * Find any and return any files matching "xml/case/*.xml" + * + * Note: This hook only runs in CiviCRM 4.4+. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_caseTypes + */ +function _search_civix_civicrm_caseTypes(&$caseTypes) { + if (!is_dir(__DIR__ . '/xml/case')) { + return; + } + + foreach (_search_civix_glob(__DIR__ . '/xml/case/*.xml') as $file) { + $name = preg_replace('/\.xml$/', '', basename($file)); + if ($name != CRM_Case_XMLProcessor::mungeCaseType($name)) { + $errorMessage = sprintf("Case-type file name is malformed (%s vs %s)", $name, CRM_Case_XMLProcessor::mungeCaseType($name)); + throw new CRM_Core_Exception($errorMessage); + } + $caseTypes[$name] = [ + 'module' => E::LONG_NAME, + 'name' => $name, + 'file' => $file, + ]; + } +} + +/** + * (Delegated) Implements hook_civicrm_angularModules(). + * + * Find any and return any files matching "ang/*.ang.php" + * + * Note: This hook only runs in CiviCRM 4.5+. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_angularModules + */ +function _search_civix_civicrm_angularModules(&$angularModules) { + if (!is_dir(__DIR__ . '/ang')) { + return; + } + + $files = _search_civix_glob(__DIR__ . '/ang/*.ang.php'); + foreach ($files as $file) { + $name = preg_replace(':\.ang\.php$:', '', basename($file)); + $module = include $file; + if (empty($module['ext'])) { + $module['ext'] = E::LONG_NAME; + } + $angularModules[$name] = $module; + } +} + +/** + * (Delegated) Implements hook_civicrm_themes(). + * + * Find any and return any files matching "*.theme.php" + */ +function _search_civix_civicrm_themes(&$themes) { + $files = _search_civix_glob(__DIR__ . '/*.theme.php'); + foreach ($files as $file) { + $themeMeta = include $file; + if (empty($themeMeta['name'])) { + $themeMeta['name'] = preg_replace(':\.theme\.php$:', '', basename($file)); + } + if (empty($themeMeta['ext'])) { + $themeMeta['ext'] = E::LONG_NAME; + } + $themes[$themeMeta['name']] = $themeMeta; + } +} + +/** + * Glob wrapper which is guaranteed to return an array. + * + * The documentation for glob() says, "On some systems it is impossible to + * distinguish between empty match and an error." Anecdotally, the return + * result for an empty match is sometimes array() and sometimes FALSE. + * This wrapper provides consistency. + * + * @link http://php.net/glob + * @param string $pattern + * + * @return array + */ +function _search_civix_glob($pattern) { + $result = glob($pattern); + return is_array($result) ? $result : []; +} + +/** + * Inserts a navigation menu item at a given place in the hierarchy. + * + * @param array $menu - menu hierarchy + * @param string $path - path to parent of this item, e.g. 'my_extension/submenu' + * 'Mailing', or 'Administer/System Settings' + * @param array $item - the item to insert (parent/child attributes will be + * filled for you) + * + * @return bool + */ +function _search_civix_insert_navigation_menu(&$menu, $path, $item) { + // If we are done going down the path, insert menu + if (empty($path)) { + $menu[] = [ + 'attributes' => array_merge([ + 'label' => CRM_Utils_Array::value('name', $item), + 'active' => 1, + ], $item), + ]; + return TRUE; + } + else { + // Find an recurse into the next level down + $found = FALSE; + $path = explode('/', $path); + $first = array_shift($path); + foreach ($menu as $key => &$entry) { + if ($entry['attributes']['name'] == $first) { + if (!isset($entry['child'])) { + $entry['child'] = []; + } + $found = _search_civix_insert_navigation_menu($entry['child'], implode('/', $path), $item); + } + } + return $found; + } +} + +/** + * (Delegated) Implements hook_civicrm_navigationMenu(). + */ +function _search_civix_navigationMenu(&$nodes) { + if (!is_callable(['CRM_Core_BAO_Navigation', 'fixNavigationMenu'])) { + _search_civix_fixNavigationMenu($nodes); + } +} + +/** + * Given a navigation menu, generate navIDs for any items which are + * missing them. + */ +function _search_civix_fixNavigationMenu(&$nodes) { + $maxNavID = 1; + array_walk_recursive($nodes, function($item, $key) use (&$maxNavID) { + if ($key === 'navID') { + $maxNavID = max($maxNavID, $item); + } + }); + _search_civix_fixNavigationMenuItems($nodes, $maxNavID, NULL); +} + +function _search_civix_fixNavigationMenuItems(&$nodes, &$maxNavID, $parentID) { + $origKeys = array_keys($nodes); + foreach ($origKeys as $origKey) { + if (!isset($nodes[$origKey]['attributes']['parentID']) && $parentID !== NULL) { + $nodes[$origKey]['attributes']['parentID'] = $parentID; + } + // If no navID, then assign navID and fix key. + if (!isset($nodes[$origKey]['attributes']['navID'])) { + $newKey = ++$maxNavID; + $nodes[$origKey]['attributes']['navID'] = $newKey; + $nodes[$newKey] = $nodes[$origKey]; + unset($nodes[$origKey]); + $origKey = $newKey; + } + if (isset($nodes[$origKey]['child']) && is_array($nodes[$origKey]['child'])) { + _search_civix_fixNavigationMenuItems($nodes[$origKey]['child'], $maxNavID, $nodes[$origKey]['attributes']['navID']); + } + } +} + +/** + * (Delegated) Implements hook_civicrm_alterSettingsFolders(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_alterSettingsFolders + */ +function _search_civix_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) { + $settingsDir = __DIR__ . DIRECTORY_SEPARATOR . 'settings'; + if (!in_array($settingsDir, $metaDataFolders) && is_dir($settingsDir)) { + $metaDataFolders[] = $settingsDir; + } +} + +/** + * (Delegated) Implements hook_civicrm_entityTypes(). + * + * Find any *.entityType.php files, merge their content, and return. + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes + */ +function _search_civix_civicrm_entityTypes(&$entityTypes) { + $entityTypes = array_merge($entityTypes, []); +} diff --git a/ext/search/search.php b/ext/search/search.php new file mode 100644 index 0000000000..7c7f9e1a83 --- /dev/null +++ b/ext/search/search.php @@ -0,0 +1,128 @@ + + + + civicrm/search + CRM_Search_Page_Build + access CiviCRM + + -- 2.25.1