From ae2504155c65b1557ae147d829cc655ee84f5333 Mon Sep 17 00:00:00 2001 From: colemanw Date: Wed, 16 Aug 2023 08:56:11 -0500 Subject: [PATCH] Add Scheduled Communications extension Permits scheduled reminders to be created based on a SavedSearch --- .gitignore | 1 + CRM/Utils/SQL/Select.php | 7 + .../Traits/SavedSearchInspectorTrait.php | 80 ++++++- distmaker/core-ext.txt | 1 + .../Civi/Search/ActionMapping.php | 180 ++++++++++++++++ ext/scheduled_communications/info.xml | 34 +++ .../scheduled_communications.civix.php | 200 ++++++++++++++++++ .../scheduled_communications.php | 31 +++ .../Api4/Action/SearchDisplay/GetDefault.php | 75 +------ .../ActionSchedule/AbstractMappingTest.php | 6 +- .../ActionSchedule/SavedSearchMappingTest.php | 166 +++++++++++++++ 11 files changed, 703 insertions(+), 78 deletions(-) create mode 100644 ext/scheduled_communications/Civi/Search/ActionMapping.php create mode 100644 ext/scheduled_communications/info.xml create mode 100644 ext/scheduled_communications/scheduled_communications.civix.php create mode 100644 ext/scheduled_communications/scheduled_communications.php create mode 100644 tests/phpunit/Civi/ActionSchedule/SavedSearchMappingTest.php diff --git a/.gitignore b/.gitignore index 89b405c6af..20ab4f93a8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ !/ext/civi_member !/ext/civi_pledge !/ext/civi_report +!/ext/scheduled_communications backdrop/ bower_components CRM/Case/xml/configuration diff --git a/CRM/Utils/SQL/Select.php b/CRM/Utils/SQL/Select.php index 3915c117d1..2e93386531 100644 --- a/CRM/Utils/SQL/Select.php +++ b/CRM/Utils/SQL/Select.php @@ -647,4 +647,11 @@ class CRM_Utils_SQL_Select extends CRM_Utils_SQL_BaseParamQuery { $freeDAO, $i18nRewrite, $trapException); } + /** + * @return string + */ + public function getFrom(): string { + return $this->from; + } + } diff --git a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php index 4f034e5bd2..f42d77a15e 100644 --- a/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php +++ b/Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php @@ -4,7 +4,10 @@ namespace Civi\Api4\Generic\Traits; use Civi\API\Exception\UnauthorizedException; use Civi\API\Request; +use Civi\Api4\Query\SqlEquation; use Civi\Api4\Query\SqlExpression; +use Civi\Api4\Query\SqlField; +use Civi\Api4\Query\SqlFunction; use Civi\Api4\SavedSearch; use Civi\Api4\Utils\CoreUtil; @@ -44,6 +47,11 @@ trait SavedSearchInspectorTrait { */ private $_searchEntityFields; + /** + * @var array + */ + private $_joinMap; + /** * If SavedSearch is supplied as a string, this will load it as an array * @param int|null $id @@ -66,7 +74,7 @@ trait SavedSearchInspectorTrait { $this->savedSearch['api_params'] += ['version' => 4, 'select' => [], 'where' => []]; } // Reset internal cached metadata - $this->_selectQuery = $this->_selectClause = $this->_searchEntityFields = NULL; + $this->_selectQuery = $this->_selectClause = $this->_searchEntityFields = $this->_joinMap = NULL; $this->_apiParams = ($this->savedSearch['api_params'] ?? []) + ['select' => [], 'where' => []]; } @@ -118,11 +126,12 @@ trait SavedSearchInspectorTrait { } /** - * @param $joinAlias + * @param string $joinAlias + * Alias of the join, with or without the trailing dot * @return array{entity: string, alias: string, table: string, bridge: string|NULL}|NULL */ - protected function getJoin($joinAlias) { - return $this->getQuery() ? $this->getQuery()->getExplicitJoin($joinAlias) : NULL; + protected function getJoin(string $joinAlias) { + return $this->getQuery() ? $this->getQuery()->getExplicitJoin(rtrim($joinAlias, '.')) : NULL; } /** @@ -360,4 +369,67 @@ trait SavedSearchInspectorTrait { } } + /** + * @param \Civi\Api4\Query\SqlExpression $expr + * @return string + */ + protected function getColumnLabel(SqlExpression $expr) { + if ($expr instanceof SqlFunction) { + $args = []; + foreach ($expr->getArgs() as $arg) { + foreach ($arg['expr'] ?? [] as $ex) { + $args[] = $this->getColumnLabel($ex); + } + } + return '(' . $expr->getTitle() . ')' . ($args ? ' ' . implode(',', array_filter($args)) : ''); + } + if ($expr instanceof SqlEquation) { + $args = []; + foreach ($expr->getArgs() as $arg) { + if (is_array($arg) && !empty($arg['expr'])) { + $args[] = $this->getColumnLabel(SqlExpression::convert($arg['expr'])); + } + } + return '(' . implode(',', array_filter($args)) . ')'; + } + elseif ($expr instanceof SqlField) { + $field = $this->getField($expr->getExpr()); + $label = ''; + if (!empty($field['explicit_join'])) { + $label = $this->getJoinLabel($field['explicit_join']) . ': '; + } + if (!empty($field['implicit_join']) && empty($field['custom_field_id'])) { + $field = $this->getField(substr($expr->getAlias(), 0, -1 - strlen($field['name']))); + } + return $label . $field['label']; + } + else { + return NULL; + } + } + + /** + * @param string $joinAlias + * @return string + */ + protected function getJoinLabel($joinAlias) { + if (!isset($this->_joinMap)) { + $this->_joinMap = []; + $joinCount = [$this->savedSearch['api_entity'] => 1]; + foreach ($this->savedSearch['api_params']['join'] ?? [] as $join) { + [$entityName, $alias] = explode(' AS ', $join[0]); + $num = ''; + if (!empty($joinCount[$entityName])) { + $num = ' ' . (++$joinCount[$entityName]); + } + else { + $joinCount[$entityName] = 1; + } + $label = CoreUtil::getInfoItem($entityName, 'title'); + $this->_joinMap[$alias] = $label . $num; + } + } + return $this->_joinMap[$joinAlias]; + } + } diff --git a/distmaker/core-ext.txt b/distmaker/core-ext.txt index e6f1dee3a5..9e2a64087f 100644 --- a/distmaker/core-ext.txt +++ b/distmaker/core-ext.txt @@ -34,3 +34,4 @@ civi_mail civi_member civi_pledge civi_report +scheduled_communications diff --git a/ext/scheduled_communications/Civi/Search/ActionMapping.php b/ext/scheduled_communications/Civi/Search/ActionMapping.php new file mode 100644 index 0000000000..7986f6f543 --- /dev/null +++ b/ext/scheduled_communications/Civi/Search/ActionMapping.php @@ -0,0 +1,180 @@ +loadSavedSearch($actionSchedule->entity_value); + return \CRM_Core_DAO_AllCoreTables::getTableForEntityName($this->savedSearch['api_entity']); + } + + public function modifySpec(\Civi\Api4\Service\Spec\RequestSpec $spec) { + $spec->getFieldByName('entity_value') + ->setLabel(ts('Saved Search')) + ->setInputAttr('multiple', FALSE); + $spec->getFieldByName('entity_status') + ->setLabel(ts('Contact ID Field')) + ->setInputAttr('multiple', FALSE) + ->setRequired(TRUE); + } + + /** + * + * @return array + */ + public function getValueLabels(): array { + return SavedSearch::get(FALSE) + ->addSelect('id', 'name', 'label') + ->addOrderBy('label') + ->addWhere('api_entity', 'IS NOT NULL') + ->addWhere('is_current', '=', TRUE) + // Limit to searches that have something to do with contacts + // FIXME: Matching `api_params LIKE %contact%` is a cheap trick with no real understanding of the appropriateness of the SavedSearch for use as a Scheduled Reminder. + ->addClause('OR', ['api_entity', '=', 'Contact'], ['api_params', 'LIKE', '%contact%']) + ->execute()->getArrayCopy(); + } + + /** + * @param array|null $entityValue + * @return array + */ + public function getStatusLabels(?array $entityValue): array { + if (!$entityValue) { + return []; + } + $this->loadSavedSearch(\CRM_Utils_Array::first($entityValue)); + $fieldNames = []; + foreach ($this->getSelectClause() as $columnAlias => $columnInfo) { + // TODO: It would be nice to return only fields with an FK to contact.id + // For now returning fields of type Int or unknown + if (in_array($columnInfo['dataType'], ['Integer', NULL], TRUE)) { + $fieldNames[$columnAlias] = $this->getColumnLabel($columnInfo['expr']); + } + } + return $fieldNames; + } + + /** + * @param array|null $entityValue + * @return array + */ + public function getDateFields(?array $entityValue = NULL): array { + if (!$entityValue) { + return []; + } + $this->loadSavedSearch(\CRM_Utils_Array::first($entityValue)); + $fieldNames = []; + foreach ($this->getSelectClause() as $columnAlias => $columnInfo) { + // Only return date fields + // For now also including fields of unknown type since SQL functions sometimes don't know their return type + if (in_array($columnInfo['dataType'], ['Date', 'Timestamp', NULL], TRUE)) { + $fieldNames[$columnAlias] = $this->getColumnLabel($columnInfo['expr']); + } + } + return $fieldNames; + } + + /** + * @param $schedule + * @return bool + */ + public function resetOnTriggerDateChange($schedule): bool { + return FALSE; + } + + /** + * Generate a query to locate recipients. + * + * @param \CRM_Core_DAO_ActionSchedule $schedule + * The schedule as configured by the administrator. + * @param string $phase + * See, e.g., RecipientBuilder::PHASE_RELATION_FIRST. + * + * @param array $defaultParams + * + * @return \CRM_Utils_SQL_Select + * @see RecipientBuilder + */ + public function createQuery($schedule, $phase, $defaultParams): \CRM_Utils_SQL_Select { + $this->loadSavedSearch($schedule->entity_value); + $this->savedSearch['api_params']['checkPermissions'] = FALSE; + $mainTableAlias = Api4Query::MAIN_TABLE_ALIAS; + // This mapping type requires exactly one 'entity_status': the name of the contact.id field. + $contactIdFieldName = $schedule->entity_status; + // The RecipientBuilder needs to know the name of the Contact table. + // Check if Contact is the main table or an explicit join + if ($contactIdFieldName === 'id' || str_ends_with($contactIdFieldName, '.id')) { + $contactPrefix = substr($contactIdFieldName, 0, strrpos($contactIdFieldName, 'id')); + $contactJoin = $this->getJoin($contactPrefix); + $contactTable = $contactJoin['alias'] ?? $mainTableAlias; + } + // Else if contact id is an FK field, use implicit join syntax + else { + $contactPrefix = $contactIdFieldName . '.'; + } + // Exclude deceased and deleted contacts + $this->savedSearch['api_params']['where'][] = [$contactPrefix . 'is_deceased', '=', FALSE]; + $this->savedSearch['api_params']['where'][] = [$contactPrefix . 'is_deleted', '=', FALSE]; + // Refresh search query with new api params + $this->loadSavedSearch(); + $apiQuery = $this->getQuery(); + // If contact id is an FK field, find table name by rendering the id field and stripping off the field name + if (!isset($contactTable)) { + $contactIdSql = SqlExpression::convert($contactPrefix . 'id')->render($apiQuery); + $contactTable = str_replace('.`id`', '', $contactIdSql); + } + $apiQuery->getSql(); + $sqlSelect = \CRM_Utils_SQL_Select::from($apiQuery->getQuery()->getFrom()); + $sqlSelect->merge($apiQuery->getQuery(), ['joins', 'wheres']); + $sqlSelect->param($defaultParams); + $sqlSelect['casAddlCheckFrom'] = $sqlSelect->getFrom(); + $sqlSelect['casContactIdField'] = SqlExpression::convert($contactIdFieldName)->render($apiQuery); + $sqlSelect['casEntityIdField'] = '`' . $mainTableAlias . '`.`' . CoreUtil::getIdFieldName($this->savedSearch['api_entity']) . '`'; + $sqlSelect['casContactTableAlias'] = $contactTable; + if ($schedule->absolute_date) { + $sqlSelect['casDateField'] = "'" . \CRM_Utils_Type::escape($schedule->absolute_date, 'String') . "'"; + } + else { + $sqlSelect['casDateField'] = SqlExpression::convert($schedule->start_action_date)->render($apiQuery); + } + return $sqlSelect; + } + +} diff --git a/ext/scheduled_communications/info.xml b/ext/scheduled_communications/info.xml new file mode 100644 index 0000000000..b098fcb82e --- /dev/null +++ b/ext/scheduled_communications/info.xml @@ -0,0 +1,34 @@ + + + scheduled_communications + Scheduled Communications + Schedule communications using SearchKit + AGPL-3.0 + + CiviCRM + info@civicrm.org + + + https://chat.civicrm.org/civicrm/channels/search-improvements + http://www.gnu.org/licenses/agpl-3.0.html + + 2023-09-04 + 5.66.alpha1 + beta + + 5.66 + + Click on the chat link above to discuss development, report problems or ask questions. + + + + + + CRM/ScheduledCommunications + 23.02.1 + crmScheduledCommunications + + + scan-classes@1.0.0 + + diff --git a/ext/scheduled_communications/scheduled_communications.civix.php b/ext/scheduled_communications/scheduled_communications.civix.php new file mode 100644 index 0000000000..3a1cbc6393 --- /dev/null +++ b/ext/scheduled_communications/scheduled_communications.civix.php @@ -0,0 +1,200 @@ +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_ScheduledCommunications_ExtensionUtil as E; + +/** + * (Delegated) Implements hook_civicrm_config(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config + */ +function _scheduled_communications_civix_civicrm_config($config = NULL) { + static $configured = FALSE; + if ($configured) { + return; + } + $configured = TRUE; + + $extRoot = __DIR__ . DIRECTORY_SEPARATOR; + $include_path = $extRoot . PATH_SEPARATOR . get_include_path(); + set_include_path($include_path); + // Based on , this does not currently require mixin/polyfill.php. +} + +/** + * Implements hook_civicrm_install(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_install + */ +function _scheduled_communications_civix_civicrm_install() { + _scheduled_communications_civix_civicrm_config(); + // Based on , this does not currently require mixin/polyfill.php. +} + +/** + * (Delegated) Implements hook_civicrm_enable(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable + */ +function _scheduled_communications_civix_civicrm_enable(): void { + _scheduled_communications_civix_civicrm_config(); + // Based on , this does not currently require mixin/polyfill.php. +} + +/** + * 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 _scheduled_communications_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' => $item['name'] ?? NULL, + '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 = _scheduled_communications_civix_insert_navigation_menu($entry['child'], implode('/', $path), $item); + } + } + return $found; + } +} + +/** + * (Delegated) Implements hook_civicrm_navigationMenu(). + */ +function _scheduled_communications_civix_navigationMenu(&$nodes) { + if (!is_callable(['CRM_Core_BAO_Navigation', 'fixNavigationMenu'])) { + _scheduled_communications_civix_fixNavigationMenu($nodes); + } +} + +/** + * Given a navigation menu, generate navIDs for any items which are + * missing them. + */ +function _scheduled_communications_civix_fixNavigationMenu(&$nodes) { + $maxNavID = 1; + array_walk_recursive($nodes, function($item, $key) use (&$maxNavID) { + if ($key === 'navID') { + $maxNavID = max($maxNavID, $item); + } + }); + _scheduled_communications_civix_fixNavigationMenuItems($nodes, $maxNavID, NULL); +} + +function _scheduled_communications_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'])) { + _scheduled_communications_civix_fixNavigationMenuItems($nodes[$origKey]['child'], $maxNavID, $nodes[$origKey]['attributes']['navID']); + } + } +} diff --git a/ext/scheduled_communications/scheduled_communications.php b/ext/scheduled_communications/scheduled_communications.php new file mode 100644 index 0000000000..a6a677453a --- /dev/null +++ b/ext/scheduled_communications/scheduled_communications.php @@ -0,0 +1,31 @@ +getArgs() as $arg) { - foreach ($arg['expr'] ?? [] as $ex) { - $args[] = $this->getColumnLabel($ex); - } - } - return '(' . $expr->getTitle() . ')' . ($args ? ' ' . implode(',', array_filter($args)) : ''); - } - if ($expr instanceof SqlEquation) { - $args = []; - foreach ($expr->getArgs() as $arg) { - if (is_array($arg) && !empty($arg['expr'])) { - $args[] = $this->getColumnLabel(SqlExpression::convert($arg['expr'])); - } - } - return '(' . implode(',', array_filter($args)) . ')'; - } - elseif ($expr instanceof SqlField) { - $field = $this->getField($expr->getExpr()); - $label = ''; - if (!empty($field['explicit_join'])) { - $label = $this->getJoinLabel($field['explicit_join']) . ': '; - } - if (!empty($field['implicit_join']) && empty($field['custom_field_id'])) { - $field = $this->getField(substr($expr->getAlias(), 0, -1 - strlen($field['name']))); - } - return $label . $field['label']; - } - else { - return NULL; - } - } - - /** - * @param string $joinAlias - * @return string - */ - private function getJoinLabel($joinAlias) { - if (!isset($this->_joinMap)) { - $this->_joinMap = []; - $joinCount = [$this->savedSearch['api_entity'] => 1]; - foreach ($this->savedSearch['api_params']['join'] ?? [] as $join) { - [$entityName, $alias] = explode(' AS ', $join[0]); - $num = ''; - if (!empty($joinCount[$entityName])) { - $num = ' ' . (++$joinCount[$entityName]); - } - else { - $joinCount[$entityName] = 1; - } - $label = CoreUtil::getInfoItem($entityName, 'title'); - $this->_joinMap[$alias] = $label . $num; - } - } - return $this->_joinMap[$joinAlias]; - } - /** * @param array $col - * @param array{fields: array, expr: SqlExpression, dataType: string} $clause + * @param array{fields: array, expr: \Civi\Api4\Query\SqlExpression, dataType: string} $clause */ private function getColumnLink(&$col, $clause) { if ($clause['expr'] instanceof SqlField || $clause['expr'] instanceof SqlFunctionGROUP_CONCAT) { diff --git a/tests/phpunit/Civi/ActionSchedule/AbstractMappingTest.php b/tests/phpunit/Civi/ActionSchedule/AbstractMappingTest.php index 438da75c37..fe13dcbe2e 100644 --- a/tests/phpunit/Civi/ActionSchedule/AbstractMappingTest.php +++ b/tests/phpunit/Civi/ActionSchedule/AbstractMappingTest.php @@ -273,12 +273,16 @@ abstract class AbstractMappingTest extends \CiviUnitTestCase { * * @throws \Exception */ - public function testDefault($targetDate, $setupFuncs, $expectMessages) { + public function testDefault(string $targetDate, string $setupFuncs, array $expectMessages) { $this->targetDate = $targetDate; foreach (explode(' ', $setupFuncs) as $setupFunc) { $this->{$setupFunc}(); } + $this->runScheduleAndExpectMessages($expectMessages); + } + + public function runScheduleAndExpectMessages(array $expectMessages): void { $this->schedule->save(); $actualMessages = []; diff --git a/tests/phpunit/Civi/ActionSchedule/SavedSearchMappingTest.php b/tests/phpunit/Civi/ActionSchedule/SavedSearchMappingTest.php new file mode 100644 index 0000000000..7f55df4406 --- /dev/null +++ b/tests/phpunit/Civi/ActionSchedule/SavedSearchMappingTest.php @@ -0,0 +1,166 @@ +getManager()->enable('scheduled_communications'); + $this->useHelloFirstName(); + $this->savedSearch = [ + 'label' => __CLASS__, + 'api_params' => [ + 'select' => [], + 'where' => [], + ], + ]; + } + + protected function tearDown(): void { + parent::tearDown(); + $this->quickCleanup([], TRUE); + } + + public function testContactBirthDate(): void { + $this->targetDate = '2015-02-01 00:00:00'; + + $this->savedSearch['api_entity'] = 'Contact'; + $this->savedSearch['api_params']['where'] = [ + ['id', 'IN', array_column($this->contacts, 'id')], + ]; + + $this->startWeekAfter(); + $this->setIdField('id'); + $this->setDateField('birth_date'); + Contact::save(FALSE) + ->addRecord(['birth_date' => '20150201', 'id' => $this->contacts['alice']['id']]) + ->addRecord(['birth_date' => '20150202', 'id' => $this->contacts['bob']['id']]) + // Deceased contact + ->addRecord(['birth_date' => '20150202', 'id' => $this->contacts['edith']['id']]) + // Date too far in future + ->addRecord(['birth_date' => '20160201', 'id' => $this->contacts['carol']['id']]) + // Francis email on hold + ->addRecord(['birth_date' => '20150201', 'id' => $this->contacts['francis']['id']]) + // Do not mail dave + ->addRecord(['birth_date' => '20150201', 'id' => $this->contacts['dave']['id']]) + ->execute(); + + $this->runScheduleAndExpectMessages([ + [ + 'time' => '2015-02-08 00:00:00', + 'to' => ['alice@example.org'], + 'subject' => '/Hello, Alice.*via subject/', + ], + [ + 'time' => '2015-02-09 00:00:00', + 'to' => ['bob@example.org'], + 'subject' => '/Hello, Bob.*via subject/', + ], + ]); + } + + public function testContactCustomDateField(): void { + $customGroup = $this->customGroupCreate(); + $customField = $this->customFieldCreate([ + 'custom_group_id' => $customGroup['id'], + 'data_type' => 'Date', + 'html_type' => 'Select Date', + 'default_value' => NULL, + ]); + $customFieldName = \CRM_Utils_Array::first($customGroup['values'])['name'] . '.' . \CRM_Utils_Array::first($customField['values'])['name']; + + $this->targetDate = '2015-02-01 00:00:00'; + + $this->savedSearch['api_entity'] = 'Contact'; + + $this->startOnTime(); + $this->setIdField('id'); + $this->setDateField($customFieldName); + Contact::save(FALSE) + ->addRecord([$customFieldName => '20150201', 'id' => $this->contacts['alice']['id']]) + ->execute(); + + $this->runScheduleAndExpectMessages([ + [ + 'time' => '2015-02-01 00:00:00', + 'to' => ['alice@example.org'], + 'subject' => '/Hello, Alice.*via subject/', + ], + ]); + } + + public function testContactAsEntityReferenceField(): void { + $customGroup = $this->customGroupCreate([ + 'extends' => 'Activity', + ]); + $customField = $this->customFieldCreate([ + 'custom_group_id' => $customGroup['id'], + 'html_type' => 'Autocomplete-Select', + 'data_type' => 'EntityReference', + 'fk_entity' => 'Contact', + 'default_value' => NULL, + ]); + $customFieldName = \CRM_Utils_Array::first($customGroup['values'])['name'] . '.' . \CRM_Utils_Array::first($customField['values'])['name']; + + $this->targetDate = '2015-02-01 00:00:00'; + + $this->startOnTime(); + $this->setIdField($customFieldName); + $this->setDateField('activity_date_time'); + $activity = $this->createTestEntity('Activity', [ + 'activity_type_id:name' => 'Meeting', + $customFieldName => $this->contacts['carol']['id'], + 'activity_date_time' => $this->targetDate, + 'source_contact_id' => $this->contacts['dave']['id'], + ]); + + $this->savedSearch['api_entity'] = 'Activity'; + $this->savedSearch['api_params']['where'] = [ + ['id', '=', $activity['id']], + ]; + + $this->runScheduleAndExpectMessages([ + [ + 'time' => '2015-02-01 00:00:00', + 'to' => ['carol@example.org'], + 'subject' => '/Hello, Carol.*via subject/', + ], + ]); + } + + public function runScheduleAndExpectMessages(array $expectMessages): void { + $savedSearch = $this->createTestEntity('SavedSearch', $this->savedSearch); + $this->schedule->mapping_id = 'saved_search'; + $this->schedule->entity_value = $savedSearch['id']; + parent::runScheduleAndExpectMessages($expectMessages); + } + + protected function setIdField(string $fieldName): void { + $this->schedule->entity_status = $fieldName; + $this->savedSearch['api_params']['select'][] = $fieldName; + } + + protected function setDateField(string $fieldName): void { + $this->schedule->start_action_date = $fieldName; + $this->savedSearch['api_params']['select'][] = $fieldName; + } + + /** + * Disable testDefault by returning no test cases + */ + public function createTestCases() { + return []; + } + +} -- 2.25.1