Add search extension
authorColeman Watts <coleman@civicrm.org>
Wed, 8 Jul 2020 22:15:39 +0000 (18:15 -0400)
committerColeman Watts <coleman@civicrm.org>
Thu, 9 Jul 2020 01:04:44 +0000 (21:04 -0400)
33 files changed:
.gitignore
ext/search/CRM/Search/Page/Build.php [new file with mode: 0644]
ext/search/CRM/Search/Upgrader.php [new file with mode: 0644]
ext/search/CRM/Search/Upgrader/Base.php [new file with mode: 0644]
ext/search/README.md [new file with mode: 0644]
ext/search/ang/search.ang.php [new file with mode: 0644]
ext/search/ang/search.module.js [new file with mode: 0644]
ext/search/ang/search/SaveSmartGroup.ctrl.js [new file with mode: 0644]
ext/search/ang/search/crmSearchActions.component.js [new file with mode: 0644]
ext/search/ang/search/crmSearchActions.html [new file with mode: 0644]
ext/search/ang/search/crmSearchActions/crmSearchActionDelete.ctrl.js [new file with mode: 0644]
ext/search/ang/search/crmSearchActions/crmSearchActionDelete.html [new file with mode: 0644]
ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.ctrl.js [new file with mode: 0644]
ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.html [new file with mode: 0644]
ext/search/ang/search/crmSearchBuild.component.js [new file with mode: 0644]
ext/search/ang/search/crmSearchBuild.html [new file with mode: 0644]
ext/search/ang/search/crmSearchBuild/build.html [new file with mode: 0644]
ext/search/ang/search/crmSearchBuild/controls.html [new file with mode: 0644]
ext/search/ang/search/crmSearchBuild/debug.html [new file with mode: 0644]
ext/search/ang/search/crmSearchBuild/pager.html [new file with mode: 0644]
ext/search/ang/search/crmSearchBuild/results.html [new file with mode: 0644]
ext/search/ang/search/crmSearchClause.directive.js [new file with mode: 0644]
ext/search/ang/search/crmSearchClause.html [new file with mode: 0644]
ext/search/ang/search/crmSearchFunction.component.js [new file with mode: 0644]
ext/search/ang/search/crmSearchFunction.html [new file with mode: 0644]
ext/search/ang/search/crmSearchValue.directive.js [new file with mode: 0644]
ext/search/ang/search/saveSmartGroup.html [new file with mode: 0644]
ext/search/css/search.css [new file with mode: 0644]
ext/search/info.xml [new file with mode: 0644]
ext/search/search.civix.php [new file with mode: 0644]
ext/search/search.php [new file with mode: 0644]
ext/search/templates/CRM/Search/Page/Build.tpl [new file with mode: 0644]
ext/search/xml/Menu/search.xml [new file with mode: 0644]

index c9d3abef614c953de3ca1a314b2121cd61e23466..390c2a04945cf0aeafecd194188c0e5866f103e3 100644 (file)
@@ -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 (file)
index 0000000..8829e79
--- /dev/null
@@ -0,0 +1,174 @@
+<?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;
+  }
+
+}
diff --git a/ext/search/CRM/Search/Upgrader.php b/ext/search/CRM/Search/Upgrader.php
new file mode 100644 (file)
index 0000000..d42a70f
--- /dev/null
@@ -0,0 +1,36 @@
+<?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();
+  }
+
+}
diff --git a/ext/search/CRM/Search/Upgrader/Base.php b/ext/search/CRM/Search/Upgrader/Base.php
new file mode 100644 (file)
index 0000000..3015d79
--- /dev/null
@@ -0,0 +1,391 @@
+<?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:
+    }
+  }
+
+}
diff --git a/ext/search/README.md b/ext/search/README.md
new file mode 100644 (file)
index 0000000..ab3e537
--- /dev/null
@@ -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 (file)
index 0000000..6727508
--- /dev/null
@@ -0,0 +1,17 @@
+<?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'],
+];
diff --git a/ext/search/ang/search.module.js b/ext/search/ang/search.module.js
new file mode 100644 (file)
index 0000000..ca99534
--- /dev/null
@@ -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: '<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._);
diff --git a/ext/search/ang/search/SaveSmartGroup.ctrl.js b/ext/search/ang/search/SaveSmartGroup.ctrl.js
new file mode 100644 (file)
index 0000000..74401b9
--- /dev/null
@@ -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 (file)
index 0000000..7e88e28
--- /dev/null
@@ -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 (file)
index 0000000..dbeae2c
--- /dev/null
@@ -0,0 +1,10 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.ctrl.js b/ext/search/ang/search/crmSearchActions/crmSearchActionDelete.ctrl.js
new file mode 100644 (file)
index 0000000..28a401e
--- /dev/null
@@ -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 (file)
index 0000000..a8c95de
--- /dev/null
@@ -0,0 +1,10 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.ctrl.js b/ext/search/ang/search/crmSearchActions/crmSearchActionUpdate.ctrl.js
new file mode 100644 (file)
index 0000000..63394a2
--- /dev/null
@@ -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 (file)
index 0000000..63178a0
--- /dev/null
@@ -0,0 +1,16 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchBuild.component.js b/ext/search/ang/search/crmSearchBuild.component.js
new file mode 100644 (file)
index 0000000..972863a
--- /dev/null
@@ -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 (file)
index 0000000..f236894
--- /dev/null
@@ -0,0 +1,20 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchBuild/build.html b/ext/search/ang/search/crmSearchBuild/build.html
new file mode 100644 (file)
index 0000000..6bb500a
--- /dev/null
@@ -0,0 +1,48 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchBuild/controls.html b/ext/search/ang/search/crmSearchBuild/controls.html
new file mode 100644 (file)
index 0000000..101b4da
--- /dev/null
@@ -0,0 +1,25 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchBuild/debug.html b/ext/search/ang/search/crmSearchBuild/debug.html
new file mode 100644 (file)
index 0000000..e7e4bd2
--- /dev/null
@@ -0,0 +1,7 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchBuild/pager.html b/ext/search/ang/search/crmSearchBuild/pager.html
new file mode 100644 (file)
index 0000000..2c4a2b4
--- /dev/null
@@ -0,0 +1,35 @@
+<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="&lsaquo;"
+        next-text="&rsaquo;"
+        first-text="&laquo;"
+        last-text="&raquo;"
+    ></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>
diff --git a/ext/search/ang/search/crmSearchBuild/results.html b/ext/search/ang/search/crmSearchBuild/results.html
new file mode 100644 (file)
index 0000000..ab14f81
--- /dev/null
@@ -0,0 +1,32 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchClause.directive.js b/ext/search/ang/search/crmSearchClause.directive.js
new file mode 100644 (file)
index 0000000..ab59aff
--- /dev/null
@@ -0,0 +1,75 @@
+(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._);
diff --git a/ext/search/ang/search/crmSearchClause.html b/ext/search/ang/search/crmSearchClause.html
new file mode 100644 (file)
index 0000000..1a0567f
--- /dev/null
@@ -0,0 +1,41 @@
+<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 &lt; (data.skip || 0)}">
+    <div ng-if="index &gt;= (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 &gt; (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>
diff --git a/ext/search/ang/search/crmSearchFunction.component.js b/ext/search/ang/search/crmSearchFunction.component.js
new file mode 100644 (file)
index 0000000..e75bac9
--- /dev/null
@@ -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 (file)
index 0000000..a88cc0a
--- /dev/null
@@ -0,0 +1,4 @@
+<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>
diff --git a/ext/search/ang/search/crmSearchValue.directive.js b/ext/search/ang/search/crmSearchValue.directive.js
new file mode 100644 (file)
index 0000000..0fb1600
--- /dev/null
@@ -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 (file)
index 0000000..a0caf4a
--- /dev/null
@@ -0,0 +1,26 @@
+<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>
diff --git a/ext/search/css/search.css b/ext/search/css/search.css
new file mode 100644 (file)
index 0000000..cf549ba
--- /dev/null
@@ -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 (file)
index 0000000..e64d56f
--- /dev/null
@@ -0,0 +1,27 @@
+<?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>
diff --git a/ext/search/search.civix.php b/ext/search/search.civix.php
new file mode 100644 (file)
index 0000000..2a696e2
--- /dev/null
@@ -0,0 +1,477 @@
+<?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, []);
+}
diff --git a/ext/search/search.php b/ext/search/search.php
new file mode 100644 (file)
index 0000000..7c7f9e1
--- /dev/null
@@ -0,0 +1,128 @@
+<?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);
+}
diff --git a/ext/search/templates/CRM/Search/Page/Build.tpl b/ext/search/templates/CRM/Search/Page/Build.tpl
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/ext/search/xml/Menu/search.xml b/ext/search/xml/Menu/search.xml
new file mode 100644 (file)
index 0000000..44f7932
--- /dev/null
@@ -0,0 +1,8 @@
+<?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>