!/ext/sequentialcreditnotes
!/ext/flexmailer
!/ext/eventcart
+!/ext/search
backdrop/
bower_components
CRM/Case/xml/configuration
--- /dev/null
+<?php
+
+class CRM_Search_Page_Build extends CRM_Core_Page {
+ /**
+ * @var string[]
+ */
+ private $loadOptions = ['id', 'name', 'label', 'description', 'color', 'icon'];
+
+ /**
+ * @var array
+ */
+ private $schema = [];
+
+ /**
+ * @var string[]
+ */
+ private $allowedEntities = [];
+
+ public function run() {
+ $breadCrumb = [
+ 'title' => 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;
+ }
+
+}
--- /dev/null
+<?php
+use CRM_Search_ExtensionUtil as E;
+
+/**
+ * Collection of upgrade steps.
+ */
+class CRM_Search_Upgrader extends CRM_Search_Upgrader_Base {
+
+ /**
+ * Add menu item when enabled.
+ */
+ public function enable() {
+ \Civi\Api4\Navigation::create()
+ ->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();
+ }
+
+}
--- /dev/null
+<?php
+
+// AUTO-GENERATED FILE -- Civix may overwrite any changes made to this file
+use CRM_Search_ExtensionUtil as E;
+
+/**
+ * Base class which provides helpers to execute upgrade logic
+ */
+class CRM_Search_Upgrader_Base {
+
+ /**
+ * @var CRM_Search_Upgrader_Base
+ */
+ public static $instance;
+
+ /**
+ * @var CRM_Queue_TaskContext
+ */
+ protected $ctx;
+
+ /**
+ * @var string
+ * eg 'com.example.myextension'
+ */
+ protected $extensionName;
+
+ /**
+ * @var string
+ * full path to the extension's source tree
+ */
+ protected $extensionDir;
+
+ /**
+ * @var array
+ * sorted numerically
+ */
+ private $revisions;
+
+ /**
+ * @var bool
+ * Flag to clean up extension revision data in civicrm_setting
+ */
+ private $revisionStorageIsDeprecated = FALSE;
+
+ /**
+ * Obtain a reference to the active upgrade handler.
+ */
+ public static function instance() {
+ if (!self::$instance) {
+ // FIXME auto-generate
+ self::$instance = new CRM_Search_Upgrader(
+ 'org.civicrm.search',
+ realpath(__DIR__ . '/../../../')
+ );
+ }
+ return self::$instance;
+ }
+
+ /**
+ * Adapter that lets you add normal (non-static) member functions to the queue.
+ *
+ * Note: Each upgrader instance should only be associated with one
+ * task-context; otherwise, this will be non-reentrant.
+ *
+ * @code
+ * CRM_Search_Upgrader_Base::_queueAdapter($ctx, 'methodName', 'arg1', 'arg2');
+ * @endcode
+ */
+ public static function _queueAdapter() {
+ $instance = self::instance();
+ $args = func_get_args();
+ $instance->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:
+ }
+ }
+
+}
--- /dev/null
+# org.civicrm.search
+
+A core extension to build advanced searches.
+
+## Usage
+
+Once enabled, navigate to **Search > Build Search for...** in the menu.
--- /dev/null
+<?php
+// Autoloader data for search builder.
+return [
+ 'js' => [
+ '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'],
+];
--- /dev/null
+(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: '<div id="bootstrap-theme" class="crm-search"><crm-search-build entity="entity"></crm-search-build></div>',
+ 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._);
--- /dev/null
+(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._);
--- /dev/null
+(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._);
--- /dev/null
+<div class="btn-group" title="{{:: ts('Perform action on selected items.') }}">
+ <button type="button" ng-disabled="!$ctrl.ids.length" ng-click="$ctrl.init()" class="btn form-control dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ {{:: ts('Action') }} <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu" ng-if=":: $ctrl.actions">
+ <li ng-disabled="!$ctrl.isActionAllowed(action)" ng-repeat="action in $ctrl.actions">
+ <a href ng-click="$ctrl.doAction(action)"><i class="fa {{action.icon}}"></i> {{ action.title }}</a>
+ </li>
+ </ul>
+</div>
--- /dev/null
+(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._);
--- /dev/null
+<div id="bootstrap-theme">
+ <div ng-controller="crmSearchActionDelete">
+ <p>{{:: ts('Are you sure you want to delete %1 %2?', {1: model.ids.length, 2: $ctrl.entity.title}) }}</p>
+ <hr />
+ <div class="buttons pull-right">
+ <button type="button" ng-click="$ctrl.cancel()" class="btn btn-danger">{{:: ts('Cancel') }}</button>
+ <button ng-click="$ctrl.delete()" class="btn btn-primary">{{:: ts('Delete %1 %2', {1: model.ids.length, 2: $ctrl.entity.title}) }}</button>
+ </div>
+ </div>
+</div>
--- /dev/null
+(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._);
--- /dev/null
+<div id="bootstrap-theme">
+ <div ng-controller="crmSearchActionUpdate">
+ <div class="form-inline" ng-repeat="clause in $ctrl.values" >
+ <input class="form-control" ng-change="$ctrl.updateField($index)" ng-model="clause[0]" crm-ui-select="{data: $ctrl.availableFields, allowClear: true, placeholder: 'Field'}" />
+ <input class="form-control" ng-model="clause[1]" crm-search-value="{field: clause[0]}" />
+ </div>
+ <div class="form-inline">
+ <input class="form-control twenty" style="width: 15em;" ng-model="$ctrl.add" ng-change="$ctrl.addField()" crm-ui-select="{data: $ctrl.availableFields, placeholder: ts('Add Value')}"/>
+ </div>
+ <hr />
+ <div class="buttons pull-right">
+ <button type="button" ng-click="$ctrl.cancel()" class="btn btn-danger">{{:: ts('Cancel') }}</button>
+ <button ng-click="$ctrl.save()" class="btn btn-primary" ng-disabled="!$ctrl.values.length">{{:: ts('Update %1 %2', {1: model.ids.length, 2: $ctrl.entity.title}) }}</button>
+ </div>
+ </div>
+</div>
--- /dev/null
+(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._);
--- /dev/null
+<div id="bootstrap-theme" class="crm-search">
+ <h1 crm-page-title>{{:: ts('Build Search for %1', {1: $ctrl.getEntity($ctrl.entity).title}) }}</h1>
+
+ <!--This warning will show if bootstrap is unavailable. Normally it will be hidden by the bootstrap .collapse class.-->
+ <div class="messages warning no-popup collapse">
+ <p>
+ <i class="crm-i fa-exclamation-triangle" aria-hidden="true"></i>
+ <strong>{{:: ts('Bootstrap theme not found.') }}</strong>
+ </p>
+ <p>{{:: ts('This screen may not work correctly without a bootstrap-based theme such as Shoreditch installed.') }}</p>
+ </div>
+
+ <form>
+ <div ng-include="'~/search/crmSearchBuild/build.html'"></div>
+ <div ng-include="'~/search/crmSearchBuild/controls.html'"></div>
+ <div ng-include="'~/search/crmSearchBuild/debug.html'" ng-if="$ctrl.debug"></div>
+ <div ng-include="'~/search/crmSearchBuild/results.html'" class="crm-search-results"></div>
+ <div ng-include="'~/search/crmSearchBuild/pager.html'"></div>
+ </form>
+</div>
--- /dev/null
+<div class="crm-flex-box">
+ <div>
+ <div class="form-inline">
+ <label for="crm-search-main-entity">{{:: ts('Search for') }}</label>
+ <input id="crm-search-main-entity" class="form-control" ng-model="$ctrl.entity" crm-ui-select="::{allowClear: false, data: entities}" ng-change="changeEntity()" />
+ </div>
+ <div ng-if=":: $ctrl.paramExists('join')">
+ <fieldset ng-repeat="join in $ctrl.params.join">
+ <div class="form-inline">
+ <label for="crm-search-join-{{ $index }}">{{:: ts('With') }}</label>
+ <input id="crm-search-join-{{ $index }}" class="form-control" ng-model="join[0]" crm-ui-select="{placeholder: ' ', data: getJoinEntities}" ng-change="changeJoin($index)" />
+ <select class="form-control" ng-model="join[1]" ng-options="o.k as o.v for o in ::joinTypes" ></select>
+ </div>
+ <fieldset class="api4-clause-fieldset" crm-search-clause="{format: 'json', clauses: join, skip: 2, op: 'AND', label: ts('If'), fields: fieldsForWhere}">
+ </fieldset>
+ </fieldset>
+ <fieldset>
+ <div class="form-inline">
+ <input id="crm-search-add-join" class="form-control crm-action-menu fa-plus" ng-model="controls.join" crm-ui-select="{placeholder: ts('With'), data: getJoinEntities}" ng-change="addJoin()"/>
+ </div>
+ </fieldset>
+ </div>
+ <fieldset ng-if=":: $ctrl.paramExists('groupBy')">
+ <div class="form-inline" ng-repeat="groupBy in $ctrl.params.groupBy">
+ <label for="crm-search-groupBy-{{ $index }}">{{:: ts('Group By') }}</label>
+ <input id="crm-search-groupBy-{{ $index }}" class="form-control" ng-model="$ctrl.params.groupBy[$index]" crm-ui-select="{placeholder: ' ', data: fieldsForGroupBy}" ng-change="changeGroupBy($index)" />
+ <hr>
+ </div>
+ <div class="form-inline">
+ <input id="crm-search-add-groupBy" class="form-control crm-action-menu fa-plus" ng-model="controls.groupBy" crm-ui-select="{placeholder: ts('Group By'), data: fieldsForGroupBy}" ng-change="addParam('groupBy')"/>
+ </div>
+ <fieldset id="crm-search-build-group-aggregate" ng-if="$ctrl.params.groupBy.length" class="crm-collapsible collapsed">
+ <legend class="collapsible-title">{{:: ts('Aggregate fields') }}</legend>
+ <div>
+ <fieldset ng-repeat="col in $ctrl.params.select" ng-if="$ctrl.canAggregate(col)">
+ <crm-search-function expr="$ctrl.params.select[$index]" cat="'aggregate'"></crm-search-function>
+ </fieldset>
+ </div>
+ </fieldset>
+ </fieldset>
+ </div>
+ <div>
+ <fieldset class="api4-clause-fieldset" crm-search-clause="{format: 'string', clauses: $ctrl.params.where, op: 'AND', label: ts('Where'), fields: fieldsForWhere}">
+ </fieldset>
+ <fieldset ng-if="$ctrl.paramExists('having') && $ctrl.params.groupBy.length" class="api4-clause-fieldset" crm-search-clause="{format: 'string', clauses: $ctrl.params.having, op: 'AND', label: ts('Filter'), fields: fieldsForHaving}">
+ </fieldset>
+ </div>
+</div>
--- /dev/null
+<hr>
+<div class="form-inline">
+ <div class="btn-group" role="group">
+ <button class="btn btn-primary{{ $ctrl.autoSearch ? '-outline' : '' }}" ng-click="onClickSearch()" ng-disabled="loading || (!$ctrl.autoSearch && !$ctrl.stale)">
+ <i class="crm-i {{ loading ? 'fa-spin fa-spinner' : 'fa-search' }}"></i>
+ {{:: ts('Search') }}
+ </button>
+ <button class="btn btn-primary{{ $ctrl.autoSearch ? '' : '-outline' }}" ng-click="onClickAuto()">
+ <i class="crm-i fa-toggle-{{ $ctrl.autoSearch ? 'on' : 'off' }}"></i>
+ {{:: ts('Auto') }}
+ </button>
+ </div>
+ <crm-search-actions entity="$ctrl.entity" ids="$ctrl.selectedRows"></crm-search-actions>
+ <div class="btn-group pull-right">
+ <button type="button" class="btn form-control dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ <i class="crm-i fa-save"></i> {{:: ts('Create')}}
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <li ng-if=":: $ctrl.perm.editGroups">
+ <a href ng-click="saveGroup()">{{:: ts('Smart Group') }}</a>
+ </li>
+ </ul>
+ </div>
+</div>
--- /dev/null
+<fieldset class="crm-collapsible collapsed">
+ <legend class="collapsible-title">{{:: ts('Query Info') }}</legend>
+ <div>
+ <pre ng-if="$ctrl.debug.timeIndex">{{ ts('Request took %1 seconds.', {1: $ctrl.debug.timeIndex}) }}</pre>
+ <pre ng-repeat="query in $ctrl.debug.sql">{{ query }}</pre>
+ </div>
+</fieldset>
--- /dev/null
+<div class="crm-flex-box">
+ <div>
+ <div class="form-inline">
+ <label ng-if="loading && $ctrl.rowCount === false"><i class="crm-i fa-spin fa-spinner"></i></label>
+ <label ng-if="$ctrl.rowCount === 1">
+ {{ $ctrl.selectedRows.length ? ts('%1 selected of 1 result', {1: $ctrl.selectedRows.length}) : ts('1 result') }}
+ </label>
+ <label ng-if="$ctrl.rowCount === 0 || $ctrl.rowCount > 1">
+ {{ $ctrl.selectedRows.length ? ts('%1 selected of %2 results', {1: $ctrl.selectedRows.length, 2: $ctrl.rowCount}) : ts('%1 results', {1: $ctrl.rowCount}) }}
+ </label>
+ </div>
+ </div>
+ <div class="text-center crm-flex-2">
+ <ul uib-pagination ng-if="$ctrl.rowCount && !$ctrl.stale"
+ class="pagination"
+ boundary-links="true"
+ total-items="$ctrl.rowCount"
+ ng-model="$ctrl.page"
+ ng-change="$ctrl.changePage()"
+ items-per-page="$ctrl.limit"
+ max-size="6"
+ force-ellipses="true"
+ previous-text="‹"
+ next-text="›"
+ first-text="«"
+ last-text="»"
+ ></ul>
+ </div>
+ <div class="form-inline text-right">
+ <label for="crm-search-results-page-size" >
+ {{:: ts('Page size:') }}
+ </label>
+ <input class="form-control" id="crm-search-results-page-size" type="number" ng-model="$ctrl.limit" min="10" step="10" ng-change="onChangeLimit()">
+ </div>
+</div>
--- /dev/null
+<table>
+ <thead>
+ <tr ng-model="$ctrl.params.select" ui-sortable="{axis: 'x'}">
+ <th class="crm-search-result-select">
+ <input type="checkbox" ng-checked="$ctrl.allRowsSelected" ng-click="selectAllRows()" ng-disabled="!(loading === false && !loadingAllRows && $ctrl.results[$ctrl.page] && $ctrl.results[$ctrl.page][0].id)">
+ </th>
+ <th ng-repeat="col in $ctrl.params.select" ng-click="setOrderBy(col, $event)" title="{{:: ts('Drag to reorder columns, click to sort results (shift-click to sort by multiple).')}}">
+ <i class="crm-i {{ getOrderBy(col) }}"></i>
+ <span>{{ $ctrl.getFieldLabel(col) }}</span>
+ <a href class="crm-hover-button" title="{{:: ts('Clear') }}" ng-click="$ctrl.clearParam('select', $index)"><i class="crm-i fa-times" aria-hidden="true"></i></a>
+ </th>
+ <th class="form-inline">
+ <input class="form-control crm-action-menu fa-plus" ng-model="controls.select" crm-ui-select="::{data: fieldsForSelect, placeholder: ts('Add')}" ng-change="addParam('select')">
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr ng-repeat="row in $ctrl.results[$ctrl.page]">
+ <td class="crm-search-result-select">
+ <input type="checkbox" ng-checked="isRowSelected(row)" ng-click="selectRow(row)" ng-disabled="!(loading === false && !loadingAllRows && row.id)">
+ </td>
+ <td ng-repeat="col in $ctrl.params.select">
+ {{ formatResult(row, col) }}
+ </td>
+ <td></td>
+ </tr>
+ </tbody>
+</table>
+<div class="messages warning no-popup" ng-if="error">
+ <h4>{{:: ts('An error occurred') }}</h4>
+ <p>{{ error }}</p>
+</div>
--- /dev/null
+(function(angular, $, _) {
+ "use strict";
+
+ angular.module('search').directive('crmSearchClause', function() {
+ return {
+ scope: {
+ data: '<crmSearchClause'
+ },
+ templateUrl: '~/search/crmSearchClause.html',
+ controller: function ($scope, $element, $timeout) {
+ var ts = $scope.ts = CRM.ts();
+ var ctrl = $scope.$ctrl = this;
+ this.conjunctions = {AND: ts('And'), OR: ts('Or'), NOT: ts('Not')};
+ this.operators = CRM.vars.search.operators;
+ this.sortOptions = {
+ axis: 'y',
+ connectWith: '.api4-clause-group-sortable',
+ containment: $element.closest('.api4-clause-fieldset'),
+ over: onSortOver,
+ start: onSort,
+ stop: onSort
+ };
+
+ this.addGroup = function(op) {
+ $scope.data.clauses.push([op, []]);
+ };
+
+ this.removeGroup = function() {
+ $scope.data.groupParent.splice($scope.data.groupIndex, 1);
+ };
+
+ function onSort(event, ui) {
+ $($element).closest('.api4-clause-fieldset').toggleClass('api4-sorting', event.type === 'sortstart');
+ $('.api4-input.form-inline').css('margin-left', '');
+ }
+
+ // Indent clause while dragging between nested groups
+ function onSortOver(event, ui) {
+ var offset = 0;
+ if (ui.sender) {
+ offset = $(ui.placeholder).offset().left - $(ui.sender).offset().left;
+ }
+ $('.api4-input.form-inline.ui-sortable-helper').css('margin-left', '' + offset + 'px');
+ }
+
+ this.addClause = function() {
+ $timeout(function() {
+ if (ctrl.newClause) {
+ $scope.data.clauses.push([ctrl.newClause, '=', '']);
+ ctrl.newClause = null;
+ }
+ });
+ };
+ $scope.$watch('data.clauses', function(values) {
+ // Iterate in reverse order so index doesn't get out-of-sync during splice
+ _.forEachRight(values, function(clause, index) {
+ // Remove empty values
+ if (index >= ($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._);
--- /dev/null
+<legend>{{ data.label || ts('%1 group', {1: $ctrl.conjunctions[data.op]}) }}</legend>
+<div class="btn-group btn-group-xs" ng-if="data.groupParent">
+ <button class="btn btn-danger-outline" ng-click="$ctrl.removeGroup()" title="{{:: ts('Remove group') }}">
+ <i class="crm-i fa-trash" aria-hidden="true"></i>
+ </button>
+</div>
+<div class="api4-clause-group-sortable" ng-model="data.clauses" ui-sortable="$ctrl.sortOptions">
+ <div class="api4-input form-inline clearfix" ng-repeat="(index, clause) in data.clauses" ng-class="{hiddenElement: index < (data.skip || 0)}">
+ <div ng-if="index >= (data.skip || 0)">
+ <div class="api4-clause-badge" title="{{:: ts('Drag to reposition') }}">
+ <span class="badge badge-info">
+ <span ng-if="index === (data.skip || 0) && !data.groupParent">{{ data.label }}</span>
+ <span ng-if="index > (data.skip || 0) || data.groupParent">{{ $ctrl.conjunctions[data.op] }}</span>
+ <i class="crm-i fa-arrows" aria-hidden="true"></i>
+ </span>
+ </div>
+ <div ng-if="!$ctrl.conjunctions[clause[0]]" class="api4-input-group">
+ <input class="form-control" ng-model="clause[0]" crm-ui-select="{data: data.fields, allowClear: true, placeholder: 'Field'}" />
+ <select class="form-control api4-operator" ng-model="clause[1]" ng-options="o for o in $ctrl.operators" ></select>
+ <input class="form-control" ng-model="clause[2]" crm-search-value="{field: clause[0], op: clause[1], format: data.format}" />
+ </div>
+ <fieldset class="clearfix" ng-if="$ctrl.conjunctions[clause[0]]" crm-search-clause="{format: data.format, clauses: clause[1], op: clause[0], fields: data.fields, groupParent: data.clauses, groupIndex: index}">
+ </fieldset>
+ </div>
+ </div>
+</div>
+<div class="api4-input form-inline">
+ <div class="api4-clause-badge">
+ <div class="btn-group btn-group-xs" title="{{ data.groupParent ? ts('Add a subgroup of clauses') : ts('Add a group of clauses') }}">
+ <button type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ {{ $ctrl.conjunctions[data.op] }} <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu api4-add-where-group-menu">
+ <li ng-repeat="(con, label) in $ctrl.conjunctions" ng-show="data.op !== con">
+ <a href ng-click="$ctrl.addGroup(con)">{{ label }}</a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <input class="form-control" ng-model="$ctrl.newClause" ng-change="$ctrl.addClause()" crm-ui-select="{data: data.fields, placeholder: ts('Select field')}" />
+</div>
--- /dev/null
+(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._);
--- /dev/null
+<div class="form-inline">
+ <label>{{ $ctrl.field.title }}:</label>
+ <input class="form-control" style="width: 15em;" ng-model="$ctrl.fn" crm-ui-select="{data: $ctrl.functions, placeholder: ts('Select')}" ng-change="$ctrl.selectFunction()">
+</div>
--- /dev/null
+(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._);
--- /dev/null
+<form id="bootstrap-theme">
+ <div ng-controller="SaveSmartGroup">
+ <input class="form-control" id="api-save-search-select-group" ng-model="model.id" crm-entityref="groupEntityRefParams" >
+ <label ng-show="!model.id">{{:: ts('Or') }}</label>
+ <input class="form-control" placeholder="Create new group" ng-model="model.title" ng-show="!model.id">
+ <hr />
+ <label>{{:: ts('Description:') }}</label>
+ <textarea class="form-control" ng-model="model.description"></textarea>
+ <label>{{:: ts('Group Type:') }}</label>
+ <div class="form-inline">
+ <div class="checkbox" ng-repeat="option in groupFields.group_type.options track by option.id">
+ <label>
+ <input type="checkbox" checklist-model="model.group_type" checklist-value="option.id">
+ {{ option.label }}
+ </label>
+ </div>
+ </div>
+ <label>{{:: ts('Visibility:') }}</label>
+ <select class="form-control" ng-model="model.visibility" ng-options="item.id as item.label for item in groupFields.visibility.options track by item.id"></select>
+ <hr />
+ <div class="buttons pull-right">
+ <button type="button" ng-click="cancel()" class="btn btn-danger">{{:: ts('Cancel') }}</button>
+ <button ng-click="save()" class="btn btn-primary" ng-disabled="!model.title && !model.id">{{:: ts('Save') }}</button>
+ </div>
+ </div>
+</form>
--- /dev/null
+.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;
+}
--- /dev/null
+<?xml version="1.0"?>
+<extension key="org.civicrm.search" type="module">
+ <file>search</file>
+ <name>Search</name>
+ <description>Build searches for a wide variety of CiviCRM entities</description>
+ <license>AGPL-3.0</license>
+ <maintainer>
+ <author>Coleman Watts</author>
+ <email>coleman@civicrm.org</email>
+ </maintainer>
+ <urls>
+ <url desc="Licensing">http://www.gnu.org/licenses/agpl-3.0.html</url>
+ </urls>
+ <releaseDate>2020-07-08</releaseDate>
+ <version>1.0</version>
+ <develStage>stable</develStage>
+ <compatibility>
+ <ver>5.28</ver>
+ </compatibility>
+ <comments>Distributed with CiviCRM core</comments>
+ <classloader>
+ <psr4 prefix="Civi\" path="Civi"/>
+ </classloader>
+ <civix>
+ <namespace>CRM/Search</namespace>
+ </civix>
+</extension>
--- /dev/null
+<?php
+
+// AUTO-GENERATED FILE -- Civix may overwrite any changes made to this file
+
+/**
+ * The ExtensionUtil class provides small stubs for accessing resources of this
+ * extension.
+ */
+class CRM_Search_ExtensionUtil {
+ const SHORT_NAME = "search";
+ const LONG_NAME = "org.civicrm.search";
+ const CLASS_PREFIX = "CRM_Search";
+
+ /**
+ * Translate a string using the extension's domain.
+ *
+ * If the extension doesn't have a specific translation
+ * for the string, fallback to the default translations.
+ *
+ * @param string $text
+ * Canonical message text (generally en_US).
+ * @param array $params
+ * @return string
+ * Translated text.
+ * @see ts
+ */
+ public static function ts($text, $params = []) {
+ if (!array_key_exists('domain', $params)) {
+ $params['domain'] = [self::LONG_NAME, NULL];
+ }
+ return ts($text, $params);
+ }
+
+ /**
+ * Get the URL of a resource file (in this extension).
+ *
+ * @param string|NULL $file
+ * Ex: NULL.
+ * Ex: 'css/foo.css'.
+ * @return string
+ * Ex: 'http://example.org/sites/default/ext/org.example.foo'.
+ * Ex: 'http://example.org/sites/default/ext/org.example.foo/css/foo.css'.
+ */
+ public static function url($file = NULL) {
+ if ($file === NULL) {
+ return rtrim(CRM_Core_Resources::singleton()->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, []);
+}
--- /dev/null
+<?php
+
+require_once 'search.civix.php';
+
+/**
+ * Implements hook_civicrm_config().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config/
+ */
+function search_civicrm_config(&$config) {
+ _search_civix_civicrm_config($config);
+}
+
+/**
+ * Implements hook_civicrm_xmlMenu().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_xmlMenu
+ */
+function search_civicrm_xmlMenu(&$files) {
+ _search_civix_civicrm_xmlMenu($files);
+}
+
+/**
+ * Implements hook_civicrm_install().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_install
+ */
+function search_civicrm_install() {
+ _search_civix_civicrm_install();
+}
+
+/**
+ * Implements hook_civicrm_postInstall().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_postInstall
+ */
+function search_civicrm_postInstall() {
+ _search_civix_civicrm_postInstall();
+}
+
+/**
+ * Implements hook_civicrm_uninstall().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_uninstall
+ */
+function search_civicrm_uninstall() {
+ _search_civix_civicrm_uninstall();
+}
+
+/**
+ * Implements hook_civicrm_enable().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable
+ */
+function search_civicrm_enable() {
+ _search_civix_civicrm_enable();
+}
+
+/**
+ * Implements hook_civicrm_disable().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_disable
+ */
+function search_civicrm_disable() {
+ _search_civix_civicrm_disable();
+}
+
+/**
+ * Implements hook_civicrm_upgrade().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_upgrade
+ */
+function search_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) {
+ return _search_civix_civicrm_upgrade($op, $queue);
+}
+
+/**
+ * Implements hook_civicrm_managed().
+ *
+ * Generate a list of entities to create/deactivate/delete when this module
+ * is installed, disabled, uninstalled.
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_managed
+ */
+function search_civicrm_managed(&$entities) {
+ _search_civix_civicrm_managed($entities);
+}
+
+/**
+ * Implements hook_civicrm_angularModules().
+ *
+ * Generate a list of Angular modules.
+ *
+ * Note: This hook only runs in CiviCRM 4.5+. It may
+ * use features only available in v4.6+.
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_angularModules
+ */
+function search_civicrm_angularModules(&$angularModules) {
+ _search_civix_civicrm_angularModules($angularModules);
+}
+
+/**
+ * Implements hook_civicrm_alterSettingsFolders().
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_alterSettingsFolders
+ */
+function search_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) {
+ _search_civix_civicrm_alterSettingsFolders($metaDataFolders);
+}
+
+/**
+ * Implements hook_civicrm_entityTypes().
+ *
+ * Declare entity types provided by this module.
+ *
+ * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes
+ */
+function search_civicrm_entityTypes(&$entityTypes) {
+ _search_civix_civicrm_entityTypes($entityTypes);
+}
+
+/**
+ * Implements hook_civicrm_thems().
+ */
+function search_civicrm_themes(&$themes) {
+ _search_civix_civicrm_themes($themes);
+}
--- /dev/null
+<?xml version="1.0"?>
+<menu>
+ <item>
+ <path>civicrm/search</path>
+ <page_callback>CRM_Search_Page_Build</page_callback>
+ <access_arguments>access CiviCRM</access_arguments>
+ </item>
+</menu>