diff --git a/CRM/Core/Permission.php b/CRM/Core/Permission.php index 5166b65466ae..2ad9e9977e06 100644 --- a/CRM/Core/Permission.php +++ b/CRM/Core/Permission.php @@ -841,6 +841,10 @@ public static function getCorePermissions() { $prefix . ts('administer payment processors'), ts('Add, Update, or Disable Payment Processors'), ], + 'render templates' => [ + $prefix . ts('render templates'), + ts('Render open-ended template content. (Additional constraints may apply to autoloaded records and specific notations.)'), + ], 'edit message templates' => [ $prefix . ts('edit message templates'), ], diff --git a/Civi/Api4/Action/ExampleData/Get.php b/Civi/Api4/Action/ExampleData/Get.php new file mode 100644 index 000000000000..9f91c82221f5 --- /dev/null +++ b/Civi/Api4/Action/ExampleData/Get.php @@ -0,0 +1,58 @@ +select !== [] && !in_array('name', $this->select)) { + $this->select[] = 'name'; + } + parent::_run($result); + } + + protected function getRecords() { + return \Civi\Test::examples()->getMetas(); + } + + protected function selectArray($values) { + $result = parent::selectArray($values); + + $heavyFields = array_intersect( + explode(',', ExampleDataLoader::HEAVY_FIELDS), + $this->select ?: [] + ); + if (!empty($heavyFields)) { + foreach ($result as &$item) { + $heavy = \Civi\Test::examples()->getFull($item['name']); + $item = array_merge($item, \CRM_Utils_Array::subset($heavy, $heavyFields)); + } + } + + return $result; + } + +} diff --git a/Civi/Api4/Action/WorkflowMessage/GetTemplateFields.php b/Civi/Api4/Action/WorkflowMessage/GetTemplateFields.php new file mode 100644 index 000000000000..bef1735e03d5 --- /dev/null +++ b/Civi/Api4/Action/WorkflowMessage/GetTemplateFields.php @@ -0,0 +1,69 @@ +workflow); + /** @var \Civi\WorkflowMessage\FieldSpec[] $fields */ + $fields = $item->getFields(); + $array = []; + $genericExamples = [ + 'string[]' => ['example-string1', 'example-string2...'], + 'string' => 'example-string', + 'int[]' => [1, 2, 3], + 'int' => 123, + 'double[]' => [1.23, 4.56], + 'double' => 1.23, + 'array' => [], + ]; + + switch ($this->format) { + case 'metadata': + foreach ($fields as $name => $field) { + $array[$name] = $field->toArray(); + } + return $array; + + case 'example': + foreach ($fields as $name => $field) { + $array[$name] = NULL; + foreach (array_intersect(array_keys($genericExamples), $field->getType()) as $ex) { + $array[$name] = $genericExamples[$ex]; + } + } + ksort($array); + return [$array]; + + default: + throw new \RuntimeException("Unrecognized format"); + } + } + +} diff --git a/Civi/Api4/Action/WorkflowMessage/Render.php b/Civi/Api4/Action/WorkflowMessage/Render.php new file mode 100644 index 000000000000..86611072b9f7 --- /dev/null +++ b/Civi/Api4/Action/WorkflowMessage/Render.php @@ -0,0 +1,168 @@ +Hello {contact.first_name}!

`) + */ + protected $messageTemplate; + + /** + * @var \Civi\WorkflowMessage\WorkflowMessageInterface + */ + protected $_model; + + public function _run(\Civi\Api4\Generic\Result $result) { + $this->validateValues(); + + $r = \CRM_Core_BAO_MessageTemplate::renderTemplate([ + 'model' => $this->_model, + 'messageTemplate' => $this->getMessageTemplate(), + 'messageTemplateId' => $this->getMessageTemplateId(), + ]); + + $result[] = \CRM_Utils_Array::subset($r, ['subject', 'html', 'text']); + } + + /** + * The token-processor supports a range of context parameters. We enforce different security rules for each kind of input. + * + * Broadly, this distinguishes between a few values: + * - Autoloaded data (e.g. 'contactId', 'activityId'). We need to ensure that the specific records are visible and extant. + * - Inputted data (e.g. 'contact'). We merely ensure that these are type-correct. + * - Prohibited/extended options, e.g. 'smarty' + */ + protected function validateValues() { + $rows = [$this->getValues()]; + $e = new ValidateValuesEvent($this, $rows, new \CRM_Utils_LazyArray(function () use ($rows) { + return array_map( + function ($row) { + return ['old' => NULL, 'new' => $row]; + }, + $rows + ); + })); + $this->onValidateValues($e); + \Civi::dispatcher()->dispatch('civi.api4.validate', $e); + if (!empty($e->errors)) { + throw $e->toException(); + } + } + + protected function onValidateValues(ValidateValuesEvent $e) { + $errorWeightMap = \CRM_Core_Error_Log::getMap(); + $errorWeight = $errorWeightMap[$this->getErrorLevel()]; + + if (count($e->records) !== 1) { + throw new \CRM_Core_Exception("Expected exactly one record to validate"); + } + foreach ($e->records as $recordKey => $record) { + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $w */ + $w = $this->_model = WorkflowMessage::create($this->getWorkflow(), [ + 'modelProps' => $record, + ]); + $fields = $w->getFields(); + + $unknowns = array_diff(array_keys($record), array_keys($fields)); + foreach ($unknowns as $fieldName) { + $e->addError($recordKey, $fieldName, 'unknown_field', ts('Unknown field (%1). Templates may only be executed with supported fields.', [ + 1 => $fieldName, + ])); + } + + // Merge intrinsic validations + foreach ($w->validate() as $issue) { + if ($errorWeightMap[$issue['severity']] < $errorWeight) { + $e->addError($recordKey, $issue['fields'], $issue['name'], $issue['message']); + } + } + + // Add checks which don't fit in WFM::validate + foreach ($fields as $fieldName => $fieldSpec) { + $fieldValue = $record[$fieldName] ?? NULL; + if ($fieldSpec->getFkEntity() && !empty($fieldValue)) { + if (!empty($params['check_permissions']) && !\Civi\Api4\Utils\CoreUtil::checkAccessDelegated($fieldSpec->getFkEntity(), 'get', ['id' => $fieldValue], CRM_Core_Session::getLoggedInContactID() ?: 0)) { + $e->addError($recordKey, $fieldName, 'nonexistent_id', ts('Referenced record does not exist or is not visible (%1).', [ + 1 => $this->getWorkflow() . '::' . $fieldName, + ])); + } + } + } + } + } + + public function fields() { + return []; + // We don't currently get the name of the workflow. But if we do... + //$item = \Civi\WorkflowMessage\WorkflowMessage::create($this->workflow); + ///** @var \Civi\WorkflowMessage\FieldSpec[] $fields */ + //$fields = $item->getFields(); + //$array = []; + //foreach ($fields as $name => $field) { + // $array[$name] = $field->toArray(); + //} + //return $array; + } + +} diff --git a/Civi/Api4/ExampleData.php b/Civi/Api4/ExampleData.php new file mode 100644 index 000000000000..c53f430b1b9b --- /dev/null +++ b/Civi/Api4/ExampleData.php @@ -0,0 +1,107 @@ +setCheckPermissions($checkPermissions); + } + + /** + * @param bool $checkPermissions + * @return Generic\BasicGetFieldsAction + */ + public static function getFields($checkPermissions = TRUE) { + return (new Generic\BasicGetFieldsAction(__CLASS__, __FUNCTION__, function () { + return [ + [ + 'name' => 'name', + 'title' => 'Example Name', + 'data_type' => 'String', + ], + [ + 'name' => 'title', + 'title' => 'Example Title', + 'data_type' => 'String', + ], + [ + 'name' => 'workflow', + 'title' => 'Workflow Name', + 'data_type' => 'String', + ], + [ + 'name' => 'file', + 'title' => 'File Path', + 'data_type' => 'String', + 'description' => 'If the example is loaded from a file, this is the location.', + ], + [ + 'name' => 'tags', + 'title' => 'Tags', + 'data_type' => 'Array', + 'options' => [ + 'preview' => ts('Preview: Display as an example in the "Preview" dialog'), + 'phpunit' => ts('PHPUnit: Run basic sniff tests in PHPUnit using this example'), + ], + ], + [ + 'type' => 'Extra', + 'name' => 'data', + 'title' => 'Example data', + 'data_type' => 'String', + 'serialize' => \CRM_Core_DAO::SERIALIZE_JSON, + ], + [ + 'type' => 'Extra', + 'name' => 'asserts', + 'title' => 'Test assertions', + 'data_type' => 'String', + 'serialize' => \CRM_Core_DAO::SERIALIZE_JSON, + ], + ]; + }))->setCheckPermissions($checkPermissions); + } + + /** + * @return array + */ + public static function permissions() { + return [ + // FIXME: Perhaps use 'edit message templates' or similar? + "meta" => ["access CiviCRM"], + "default" => ["administer CiviCRM"], + ]; + } + + /** + * @inheritDoc + */ + public static function getInfo() { + $info = parent::getInfo(); + $info['primary_key'] = ['name']; + return $info; + } + +} diff --git a/Civi/Api4/WorkflowMessage.php b/Civi/Api4/WorkflowMessage.php new file mode 100644 index 000000000000..5369e84df1b4 --- /dev/null +++ b/Civi/Api4/WorkflowMessage.php @@ -0,0 +1,139 @@ +`MessageTemplate.workflow_name`). + * The WorkflowMessage defines the _contract_ or _processing_ of the + * message, and the MessageTemplate defines the _literal prose_. The prose + * would change frequently (eg for different deployments, locales, timeframes, + * and other whims), but contract would change conservatively (eg with a + * code-update and with some attention to backward-compatibility/change-management). + * + * @searchable none + * @since 5.43 + * @package Civi\Api4 + */ +class WorkflowMessage extends Generic\AbstractEntity { + + /** + * @param bool $checkPermissions + * @return Generic\BasicGetAction + */ + public static function get($checkPermissions = TRUE) { + return (new Generic\BasicGetAction(__CLASS__, __FUNCTION__, function ($get) { + return \Civi\WorkflowMessage\WorkflowMessage::getWorkflowSpecs(); + }))->setCheckPermissions($checkPermissions); + } + + /** + * @param bool $checkPermissions + * + * @return \Civi\Api4\Action\WorkflowMessage\Render + */ + public static function render($checkPermissions = TRUE) { + return (new Action\WorkflowMessage\Render(__CLASS__, __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + + /** + * @param bool $checkPermissions + * @return Generic\BasicGetFieldsAction + */ + public static function getTemplateFields($checkPermissions = TRUE) { + return (new Action\WorkflowMessage\GetTemplateFields(__CLASS__, __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + + /** + * @param bool $checkPermissions + * @return Generic\BasicGetFieldsAction + */ + public static function getFields($checkPermissions = TRUE) { + return (new Generic\BasicGetFieldsAction(__CLASS__, __FUNCTION__, function() { + return [ + [ + 'name' => 'name', + 'title' => 'Name', + 'data_type' => 'String', + ], + [ + 'name' => 'group', + 'title' => 'Group', + 'data_type' => 'String', + ], + [ + 'name' => 'class', + 'title' => 'Class', + 'data_type' => 'String', + ], + [ + 'name' => 'description', + 'title' => 'Description', + 'data_type' => 'String', + ], + [ + 'name' => 'support', + 'title' => 'Support Level', + 'options' => [ + 'experimental' => ts('Experimental: Message may change substantively with no special communication or facilitation.'), + 'template-only' => ts('Template Support: Changes affecting the content of the message-template will get active support/facilitation.'), + 'full' => ts('Full Support: All changes affecting message-templates or message-senders will get active support/facilitation.'), + ], + 'data_type' => 'String', + ], + ]; + }))->setCheckPermissions($checkPermissions); + } + + public static function permissions() { + return [ + 'meta' => ['access CiviCRM'], + 'default' => ['administer CiviCRM'], + 'render' => [ + // nested array = OR + [ + 'edit message templates', + 'edit user-driven message templates', + 'edit system workflow message templates', + 'render templates', + ], + ], + ]; + } + + /** + * @inheritDoc + */ + public static function getInfo() { + $info = parent::getInfo(); + $info['primary_key'] = ['name']; + return $info; + } + +} diff --git a/Civi/Test.php b/Civi/Test.php index cf9c841ab798..ff498544235d 100644 --- a/Civi/Test.php +++ b/Civi/Test.php @@ -182,6 +182,29 @@ public static function data() { return self::$singletons['data']; } + /** + * @return \Civi\Test\ExampleDataLoader + */ + public static function examples(): \Civi\Test\ExampleDataLoader { + if (!isset(self::$singletons['examples'])) { + self::$singletons['examples'] = new \Civi\Test\ExampleDataLoader(); + } + return self::$singletons['examples']; + } + + /** + * @param string $name + * Symbolic name of the data-set. + * @return array + */ + public static function example(string $name): array { + $result = static::examples()->getFull($name); + if ($result === NULL) { + throw new \CRM_Core_Exception("Failed to load example data-set: $name"); + } + return $result; + } + /** * Prepare and execute a batch of SQL statements. * diff --git a/Civi/Test/ExampleDataInterface.php b/Civi/Test/ExampleDataInterface.php new file mode 100644 index 000000000000..1389b05cdd3d --- /dev/null +++ b/Civi/Test/ExampleDataInterface.php @@ -0,0 +1,32 @@ +build($example);` + * - They are not generated by '$ex->getExamples();' + * - They are returned by `$this->getFull()` + * - They are not returned by `$this->getMeta()`. + */ + const HEAVY_FIELDS = 'data,asserts'; + + /** + * @var array|null + */ + private $metas; + + /** + * Get a list of all examples, including basic metadata (name, title, workflow). + * + * @return array + * Ex: ['my_example' => ['title' => ..., 'workflow' => ..., 'tags' => ...]] + * @throws \ReflectionException + */ + public function getMetas(): array { + if ($this->metas === NULL) { + // $cache = new \CRM_Utils_Cache_NoCache([]); + $cache = \CRM_Utils_Constant::value('CIVICRM_TEST') ? new \CRM_Utils_Cache_NoCache([]) : \Civi::cache('long'); + $cacheKey = \CRM_Utils_String::munge(__CLASS__); + $this->metas = $cache->get($cacheKey); + if ($this->metas === NULL) { + $this->metas = $this->findMetas(); + $cache->set($cacheKey, $this->metas); + } + } + return $this->metas; + } + + public function getMeta(string $name): ?array { + $all = $this->getMetas(); + return $all[$name] ?? NULL; + } + + /** + * @param string $name + * + * @return array|null + */ + public function getFull(string $name): ?array { + $example = $this->getMeta($name); + if ($example === NULL) { + return NULL; + } + + if ($example['file']) { + include_once $example['file']; + } + $obj = new $example['class'](); + $obj->build($example); + return $example; + } + + /** + * Get a list of all examples, including basic metadata (name, title, workflow). + * + * @return array + * Ex: ['my_example' => ['title' => ..., 'workflow' => ..., 'tags' => ...]] + * @throws \ReflectionException + */ + protected function findMetas(): array { + $classes = array_merge( + // This scope of search is decidedly narrow - it should probably be expanded. + $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/'), 'CRM/*/WorkflowMessage', '_'), + $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/'), 'Civi/*/WorkflowMessage', '\\'), + $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/'), 'Civi/WorkflowMessage', '\\'), + $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/tests/phpunit/'), 'CRM/*/WorkflowMessage', '_'), + $this->scanExampleClasses(\Civi::paths()->getPath('[civicrm.root]/tests/phpunit/'), 'Civi/*/WorkflowMessage', '\\') + ); + + $all = []; + foreach ($classes as $file => $class) { + require_once $file; + $obj = new $class(); + $offset = 0; + foreach ($obj->getExamples() as $example) { + $example['file'] = $file; + $example['class'] = $class; + if (!isset($example['name'])) { + $example['name'] = $example['class'] . '#' . $offset; + } + $all[$example['name']] = $example; + $offset++; + } + } + + return $all; + } + + /** + * @param $classRoot + * Ex: Civi root dir. + * @param $classDir + * Folder to search (within the parent). + * @param $classDelim + * Namespace separator, eg underscore or backslash. + * @return array + * Array(string $relativeFileName => string $className). + */ + private function scanExampleClasses($classRoot, $classDir, $classDelim): array { + $r = []; + $exDirs = (array) glob($classRoot . $classDir); + foreach ($exDirs as $exDir) { + foreach (\CRM_Utils_File::findFiles($exDir, '*.ex.php') as $file) { + $file = str_replace(DIRECTORY_SEPARATOR, '/', $file); + $file = \CRM_Utils_File::relativize($file, \CRM_Utils_File::addTrailingSlash($classRoot, '/')); + $class = str_replace('/', $classDelim, preg_replace('/\.ex\.php$/', '', $file)); + $r[$file] = $class; + } + } + return $r; + } + +} diff --git a/Civi/Test/WorkflowMessageTestTrait.php b/Civi/Test/WorkflowMessageTestTrait.php new file mode 100644 index 000000000000..1c4bd7780510 --- /dev/null +++ b/Civi/Test/WorkflowMessageTestTrait.php @@ -0,0 +1,82 @@ +getWorkflowClass(); + return $class::WORKFLOW; + } + + /** + * @return \Civi\Api4\Generic\AbstractGetAction + * @throws \API_Exception + */ + protected function findExamples(): \Civi\Api4\Generic\AbstractGetAction { + return \Civi\Api4\ExampleData::get(0) + ->setSelect(['name', 'title', 'tags', 'data', 'asserts']) + ->addWhere('name', 'LIKE', 'workflow/' . $this->getWorkflowName() . '/%') + ->addWhere('tags', 'CONTAINS', 'phpunit'); + } + + /** + * @param array $exampleProps + * @param string $exampleName + * @throws \Civi\WorkflowMessage\Exception\WorkflowMessageException + */ + protected function assertConstructorEquivalence(array $exampleProps, $exampleName = ''): void { + $class = $this->getWorkflowClass(); + $instances = []; + $instances["factory_$exampleName"] = WorkflowMessage::create($this->getWorkflowName(), $exampleProps); + $instances["class_$exampleName"] = new $class($exampleProps); + + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $refInstance */ + /** @var \Civi\WorkflowMessage\WorkflowMessageInterface $cmpInstance */ + + $refName = $refInstance = NULL; + $comparisons = 0; + foreach ($instances as $cmpName => $cmpInstance) { + if ($refName === NULL) { + $refName = $cmpName; + $refInstance = $cmpInstance; + continue; + } + + $this->assertSameWorkflowMessage($refInstance, $cmpInstance, "Compare $refName vs $cmpName: "); + $comparisons++; + } + $this->assertEquals(1, $comparisons); + } + + /** + * @param \Civi\WorkflowMessage\WorkflowMessageInterface $refInstance + * @param \Civi\WorkflowMessage\WorkflowMessageInterface $cmpInstance + * @param string|null $prefix + */ + protected function assertSameWorkflowMessage(\Civi\WorkflowMessage\WorkflowMessageInterface $refInstance, \Civi\WorkflowMessage\WorkflowMessageInterface $cmpInstance, ?string $prefix = NULL): void { + if ($prefix === NULL) { + $prefix = sprintf('[%s] ', $this->getWorkflowName()); + } + $this->assertEquals($refInstance->export('tplParams'), $cmpInstance->export('tplParams'), "{$prefix}Should have same export(tplParams)"); + $this->assertEquals($refInstance->export('tokenContext'), $cmpInstance->export('tokenContext'), "{$prefix}should have same export(tokenContext)"); + $this->assertEquals($refInstance->export('envelope'), $cmpInstance->export('envelope'), "{$prefix}Should have same export(envelope)"); + $refExportAll = WorkflowMessage::exportAll($refInstance); + $cmpExportAll = WorkflowMessage::exportAll($cmpInstance); + $this->assertEquals($refExportAll, $cmpExportAll, "{$prefix}Should have same exportAll()"); + } + +} diff --git a/Civi/Token/TokenCompatSubscriber.php b/Civi/Token/TokenCompatSubscriber.php index 417450ef93b3..8e9abec17287 100644 --- a/Civi/Token/TokenCompatSubscriber.php +++ b/Civi/Token/TokenCompatSubscriber.php @@ -303,7 +303,7 @@ public function onRender(TokenRenderEvent $e) { $e->string = \CRM_Utils_Token::replaceDomainTokens($e->string, $domain, $isHtml, $e->message['tokens'], $useSmarty); if (!empty($e->context['contact'])) { - \CRM_Utils_Token::replaceGreetingTokens($e->string, $e->context['contact'], $e->context['contact']['contact_id'], NULL, $useSmarty); + \CRM_Utils_Token::replaceGreetingTokens($e->string, $e->context['contact'], $e->context['contact']['contact_id'] ?? $e->context['contactId'], NULL, $useSmarty); } if ($useSmarty) { diff --git a/Civi/WorkflowMessage/GenericWorkflowMessage.php b/Civi/WorkflowMessage/GenericWorkflowMessage.php index 862c3a8d42cf..0c072a485e10 100644 --- a/Civi/WorkflowMessage/GenericWorkflowMessage.php +++ b/Civi/WorkflowMessage/GenericWorkflowMessage.php @@ -24,6 +24,10 @@ * @method int|null getContactId() * @method $this setContact(array|null $contact) * @method array|null getContact() + * + * @support template-only + * GenericWorkflowMessage should aim for "full" support, but it's prudent to keep + * it flexible for the first few months. Consider updating to "full" after Dec 2021. */ class GenericWorkflowMessage implements WorkflowMessageInterface { diff --git a/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.ex.php b/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.ex.php new file mode 100644 index 000000000000..5a7ba7d43762 --- /dev/null +++ b/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.ex.php @@ -0,0 +1,96 @@ + "workflow/{$this->wfName}/{$this->exName}", + 'tags' => [], + ]; + } + + /** + * @inheritDoc + */ + public function build(array &$example): void { + $example['data'] = [ + 'modelProps' => [ + 'contact' => [ + 'contact_id' => '100', + 'contact_type' => 'Individual', + 'contact_sub_type' => NULL, + 'sort_name' => 'D\u00edaz, Alex', + 'display_name' => 'Dr. Alex D\u00edaz', + 'do_not_email' => '1', + 'do_not_phone' => '1', + 'do_not_mail' => '0', + 'do_not_sms' => '0', + 'do_not_trade' => '0', + 'is_opt_out' => '0', + 'legal_identifier' => NULL, + 'external_identifier' => NULL, + 'nick_name' => NULL, + 'legal_name' => NULL, + 'image_URL' => NULL, + 'preferred_communication_method' => NULL, + 'preferred_language' => NULL, + 'preferred_mail_format' => 'Both', + 'first_name' => 'Alex', + 'middle_name' => '', + 'last_name' => 'D\u00edaz', + 'prefix_id' => '4', + 'suffix_id' => NULL, + 'formal_title' => NULL, + 'communication_style_id' => NULL, + 'job_title' => NULL, + 'gender_id' => '1', + 'birth_date' => '1994-04-21', + 'is_deceased' => '0', + 'deceased_date' => NULL, + 'household_name' => NULL, + 'organization_name' => NULL, + 'sic_code' => NULL, + 'contact_is_deleted' => '0', + 'current_employer' => NULL, + 'address_id' => NULL, + 'street_address' => NULL, + 'supplemental_address_1' => NULL, + 'supplemental_address_2' => NULL, + 'supplemental_address_3' => NULL, + 'city' => NULL, + 'postal_code_suffix' => NULL, + 'postal_code' => NULL, + 'geo_code_1' => NULL, + 'geo_code_2' => NULL, + 'state_province_id' => NULL, + 'country_id' => NULL, + 'phone_id' => '7', + 'phone_type_id' => '1', + 'phone' => '293-6934', + 'email_id' => '7', + 'email' => 'daz.alex67@testing.net', + 'on_hold' => '0', + 'im_id' => NULL, + 'provider_id' => NULL, + 'im' => NULL, + 'worldregion_id' => NULL, + 'world_region' => NULL, + 'languages' => NULL, + 'individual_prefix' => 'Dr.', + 'individual_suffix' => NULL, + 'communication_style' => NULL, + 'gender' => 'Female', + 'state_province_name' => NULL, + 'state_province' => NULL, + 'country' => NULL, + ], + ], + ]; + } + +} diff --git a/Civi/WorkflowMessage/WorkflowMessage.php b/Civi/WorkflowMessage/WorkflowMessage.php index 3647286ef558..32593418bbdd 100644 --- a/Civi/WorkflowMessage/WorkflowMessage.php +++ b/Civi/WorkflowMessage/WorkflowMessage.php @@ -12,6 +12,7 @@ namespace Civi\WorkflowMessage; +use Civi\Api4\Utils\ReflectionUtils; use Civi\WorkflowMessage\Exception\WorkflowMessageException; /** @@ -170,4 +171,38 @@ public static function getWorkflowNameClassMap() { return $map; } + /** + * Get general description of available workflow-messages. + * + * @return array + * Array(string $workflowName => string $className). + * Ex: ["case_activity" => ["name" => "case_activity", "group" => "msg_workflow_case"] + * @internal + */ + public static function getWorkflowSpecs() { + $compute = function() { + $keys = ['name', 'group', 'class', 'description', 'comment', 'support']; + $list = []; + foreach (self::getWorkflowNameClassMap() as $name => $class) { + $specs = [ + 'name' => $name, + 'group' => \CRM_Utils_Constant::value($class . '::GROUP'), + 'class' => $class, + ]; + $list[$name] = \CRM_Utils_Array::subset( + array_merge(ReflectionUtils::getCodeDocs(new \ReflectionClass($class)), $specs), + $keys); + } + return $list; + }; + + $cache = \Civi::cache('long'); + $cacheKey = 'WorkflowMessage-' . __FUNCTION__; + $list = $cache->get($cacheKey); + if ($list === NULL) { + $cache->set($cacheKey, $list = $compute()); + } + return $list; + } + } diff --git a/Civi/WorkflowMessage/WorkflowMessageExample.php b/Civi/WorkflowMessage/WorkflowMessageExample.php new file mode 100644 index 000000000000..899c0a91653b --- /dev/null +++ b/Civi/WorkflowMessage/WorkflowMessageExample.php @@ -0,0 +1,79 @@ +wfClass = $m[1]; + $this->wfName = array_search($m[1], \Civi\WorkflowMessage\WorkflowMessage::getWorkflowNameClassMap()); + $this->exName = $m[2]; + } + + /** + * Get an example, merge/extend it with more data, and return the extended + * variant. + * + * @param array $base + * Baseline data to build upon. + * @param array $overrides + * Additional data to recursively add. + * + * @return array + * The result of merging the original example with the $overrides. + */ + public function extend($base, $overrides = []) { + \CRM_Utils_Array::extend($base, $overrides); + return $base; + } + +} diff --git a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity.php b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity.php new file mode 100644 index 000000000000..738f57cd26c0 --- /dev/null +++ b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity.php @@ -0,0 +1,121 @@ + 123, 'display_name' => 'Bob Roberts', role => 'FIXME'] + * + * @var array|null + * @scope tokenContext, tplParams + * @fkEntity Contact + * @required + */ + public $contact; + + /** + * The primary contact associated with this case (eg `civicrm_case_contact.contact_id`). + * + * Existing callers are inconsistent about setting this parameter. + * + * By default, CiviCRM allows one client on any given case, and this should reflect + * that contact. However, some systems may enable multiple clients per case. + * This field may not make sense in the long-term. + * + * @var int + * @scope tplParams as client_id + * @fkEntity Contact + */ + public $clientId; + + /** + * @var string + * @scope tplParams + * @required + */ + public $activitySubject; + + /** + * @var string + * @scope tplParams + * @required + */ + public $activityTypeName; + + /** + * Unique ID for this activity. Unique and difficult to guess. + * + * @var string + * @scope tplParams + * @required + */ + public $idHash; + + /** + * @var bool + * @scope tplParams + * @required + */ + public $isCaseActivity; + + /** + * @var string + * @scope tplParams + */ + public $editActURL; + + /** + * @var string + * @scope tplParams + */ + public $viewActURL; + + /** + * @var string + * @scope tplParams + */ + public $manageCaseURL; + + /** + * List of conventional activity fields. + * + * Example: [['label' => ..., 'category' => ..., 'type' => ..., 'value' => ...]] + * + * @var array + * @scope tplParams as activity.fields + * @required + */ + public $activityFields; + + /** + * List of custom activity fields, grouped by CustomGroup. + * + * Example: ['My Custom Stuff' => [['label' => ..., 'category' => ..., 'type' => ..., 'value' => ...]]] + * + * @var array + * @scope tplParams as activity.customGroups + */ + public $activityCustomGroups = []; + +} diff --git a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseAdhocExample.ex.php b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseAdhocExample.ex.php new file mode 100644 index 000000000000..e05b2590ccf6 --- /dev/null +++ b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseAdhocExample.ex.php @@ -0,0 +1,49 @@ + "workflow/{$this->wfName}/{$this->exName}", + 'title' => ts('Case Activity (Adhoc-style example)'), + 'tags' => [], + ]; + } + + /** + * @inheritDoc + */ + public function build(array &$example): void { + $alex = \Civi\Test::example('workflow/generic/Alex'); + $contact = $this->extend($alex['data']['modelProps']['contact'], [ + 'role' => 'myrole', + ]); + $example['data'] = [ + 'workflow' => $this->wfName, + 'tokenContext' => [ + 'contact' => $contact, + ], + 'tplParams' => [ + 'contact' => $contact, + 'isCaseActivity' => 1, + 'client_id' => 101, + 'activityTypeName' => 'Follow up', + 'activitySubject' => 'Test 123', + 'idHash' => 'abcdefg', + 'activity' => [ + 'fields' => [ + [ + 'label' => 'Case ID', + 'type' => 'String', + 'value' => '1234', + ], + ], + ], + ], + ]; + } + +} diff --git a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.ex.php b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.ex.php new file mode 100644 index 000000000000..b661afa0772e --- /dev/null +++ b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.ex.php @@ -0,0 +1,49 @@ + "workflow/{$this->wfName}/{$this->exName}", + 'title' => ts('Case Activity (Class-style example)'), + 'tags' => ['phpunit', 'preview'], + ]; + } + + /** + * @inheritDoc + */ + public function build(array &$example): void { + $alex = \Civi\Test::example('workflow/generic/Alex'); + $example['data'] = $this->extend($alex['data'], [ + 'workflow' => $this->wfName, + 'modelProps' => [ + 'contact' => [ + 'role' => 'myrole', + ], + 'isCaseActivity' => 1, + 'clientId' => 101, + 'activityTypeName' => 'Follow up', + 'activityFields' => [ + [ + 'label' => 'Case ID', + 'type' => 'String', + 'value' => '1234', + ], + ], + 'activitySubject' => 'Test 123', + 'activityCustomGroups' => [], + 'idHash' => 'abcdefg', + ], + ]); + $example['asserts'] = [ + 'default' => [ + ['for' => 'subject', 'regex' => '/\[case #abcdefg\] Test 123/'], + ['for' => 'text', 'regex' => '/Your Case Role\(s\) : myrole/'], + ], + ]; + } + +} diff --git a/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivityTest.php b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivityTest.php new file mode 100644 index 000000000000..8d29866cf640 --- /dev/null +++ b/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivityTest.php @@ -0,0 +1,78 @@ +setSelect(['name', 'data']) + ->addWhere('name', 'IN', ['workflow/case_activity/CaseAdhocExample', 'workflow/case_activity/CaseModelExample']) + ->execute() + ->indexBy('name') + ->column('data'); + $byAdhoc = Civi\WorkflowMessage\WorkflowMessage::create('case_activity', $examples['workflow/case_activity/CaseAdhocExample']); + $byClass = new CRM_Case_WorkflowMessage_CaseActivity($examples['workflow/case_activity/CaseModelExample']); + $this->assertSameWorkflowMessage($byClass, $byAdhoc, 'Compare byClass and byAdhoc: '); + } + + /** + * Ensure that various methods of constructing a WorkflowMessage all produce similar results. + * + * To see this, we take all the example data and use it with diff constructors. + */ + public function testConstructorEquivalence() { + $examples = $this->findExamples()->execute()->indexBy('name')->column('data'); + $this->assertTrue(count($examples) >= 1, 'Must have at least one example data-set'); + foreach ($examples as $example) { + $this->assertConstructorEquivalence($example); + } + } + + /** + * Basic canary test fetching a specific example. + * + * @throws \API_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + public function testExampleGet() { + $file = \Civi::paths()->getPath('[civicrm.root]/tests/phpunit/CRM/Case/WorkflowMessage/CaseActivity/CaseModelExample.ex.php'); + $name = 'workflow/case_activity/CaseModelExample'; + + $this->assertTrue(file_exists($file), "Expect find canary file ($file)"); + + $get = \Civi\Api4\ExampleData::get() + ->addWhere('name', '=', $name) + ->execute() + ->single(); + $this->assertEquals($name, $get['name']); + $this->assertTrue(!isset($get['data'])); + $this->assertTrue(!isset($get['asserts'])); + + $get = \Civi\Api4\ExampleData::get() + ->addWhere('name', '=', $name) + ->addSelect('data') + ->execute() + ->single(); + $this->assertEquals($name, $get['name']); + $this->assertEquals(100, $get['data']['modelProps']['contact']['contact_id']); + $this->assertEquals('myrole', $get['data']['modelProps']['contact']['role']); + } + +} diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index b288a7ca98ec..f3ae76088940 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -195,38 +195,37 @@ public function testCaseActivityCopyTemplate():void { $client_id = $this->individualCreate(); $contact_id = $this->individualCreate(); - $tplParams = [ - 'isCaseActivity' => 1, - 'client_id' => $client_id, - // activityTypeName means label here not name, but it's ok because label is desired here (dev/core#1116-ok-label) - 'activityTypeName' => 'Follow up', - 'activity' => [ - 'fields' => [ + $msg = \Civi\WorkflowMessage\WorkflowMessage::create('case_activity', [ + 'modelProps' => [ + 'contactId' => $contact_id, + 'contact' => ['role' => 'Sand grain counter'], + 'isCaseActivity' => 1, + 'clientId' => $client_id, + // activityTypeName means label here not name, but it's ok because label is desired here (dev/core#1116-ok-label) + 'activityTypeName' => 'Follow up', + 'activityFields' => [ [ 'label' => 'Case ID', 'type' => 'String', 'value' => '1234', ], ], + 'activitySubject' => 'Test 123', + 'idHash' => substr(sha1(CIVICRM_SITE_KEY . '1234'), 0, 7), ], - 'activitySubject' => 'Test 123', - 'idHash' => substr(sha1(CIVICRM_SITE_KEY . '1234'), 0, 7), - 'contact' => ['role' => 'Sand grain counter'], - ]; + ]); - [, $subject, $message] = CRM_Core_BAO_MessageTemplate::sendTemplate( - [ - 'valueName' => 'case_activity', - 'contactId' => $contact_id, - 'tplParams' => $tplParams, - 'from' => 'admin@example.com', - 'toName' => 'Demo', - 'toEmail' => 'admin@example.com', - 'attachments' => NULL, - ] - ); + $this->assertEquals([], \Civi\Test\Invasive::get([$msg, '_extras'])); + + [, $subject, $message] = $msg->sendTemplate([ + 'valueName' => 'case_activity', + 'from' => 'admin@example.com', + 'toName' => 'Demo', + 'toEmail' => 'admin@example.com', + 'attachments' => NULL, + ]); - $this->assertEquals('[case #' . $tplParams['idHash'] . '] Test 123', $subject); + $this->assertEquals('[case #' . $msg->getIdHash() . '] Test 123', $subject); $this->assertStringContainsString('Your Case Role(s) : Sand grain counter', $message); $this->assertStringContainsString('Case ID : 1234', $message); } diff --git a/tests/phpunit/api/v4/Entity/ExampleDataTest.php b/tests/phpunit/api/v4/Entity/ExampleDataTest.php new file mode 100644 index 000000000000..8f71f57240fe --- /dev/null +++ b/tests/phpunit/api/v4/Entity/ExampleDataTest.php @@ -0,0 +1,66 @@ +getPath('[civicrm.root]/Civi/WorkflowMessage/GenericWorkflowMessage/Alex.ex.php'); + $name = 'workflow/generic/Alex'; + + $this->assertTrue(file_exists($file), "Expect find canary file ($file)"); + + $get = \Civi\Api4\ExampleData::get() + ->addWhere('name', '=', $name) + ->execute() + ->single(); + $this->assertEquals($name, $get['name']); + $this->assertTrue(!isset($get['data']), 'Default "get" should not return "data"'); + $this->assertTrue(!isset($get['asserts']), 'Default "get" should not return "asserts"'); + + $get = \Civi\Api4\ExampleData::get() + ->addWhere('name', 'LIKE', 'workflow/generic/%') + ->execute(); + $this->assertTrue($get->count() > 0); + foreach ($get as $gotten) { + $this->assertStringStartsWith('workflow/generic/', $gotten['name']); + } + + $get = \Civi\Api4\ExampleData::get() + ->addWhere('name', '=', $name) + ->addSelect('workflow', 'data') + ->execute() + ->single(); + $this->assertEquals($name, $get['name']); + $this->assertEquals(100, $get['data']['modelProps']['contact']['contact_id']); + } + +} diff --git a/tests/phpunit/api/v4/Entity/WorkflowMessageTest.php b/tests/phpunit/api/v4/Entity/WorkflowMessageTest.php new file mode 100644 index 000000000000..43cef7085b02 --- /dev/null +++ b/tests/phpunit/api/v4/Entity/WorkflowMessageTest.php @@ -0,0 +1,94 @@ +addWhere('name', 'LIKE', 'case%') + ->execute() + ->indexBy('name'); + $this->assertTrue(isset($result['case_activity'])); + } + + public function testRenderDefaultTemplate() { + $ex = \Civi\Api4\ExampleData::get(0) + ->addWhere('name', '=', 'workflow/case_activity/CaseModelExample') + ->addSelect('data') + ->addChain('render', WorkflowMessage::render() + ->setWorkflow('$data.workflow') + ->setValues('$data.modelProps')) + ->execute() + ->single(); + $result = $ex['render'][0]; + $this->assertRegExp('/Case ID : 1234/', $result['text']); + } + + public function testRenderCustomTemplate() { + $ex = \Civi\Api4\ExampleData::get(0) + ->addWhere('name', '=', 'workflow/case_activity/CaseModelExample') + ->addSelect('data') + ->execute() + ->single(); + $result = \Civi\Api4\WorkflowMessage::render(0) + ->setWorkflow('case_activity') + ->setValues($ex['data']['modelProps']) + ->setMessageTemplate([ + 'msg_text' => 'The role is {$contact.role}.', + ]) + ->execute() + ->single(); + $this->assertRegExp('/The role is myrole./', $result['text']); + } + + public function testRenderExamples() { + $examples = \Civi\Api4\ExampleData::get(0) + ->addWhere('tags', 'CONTAINS', 'phpunit') + ->addSelect('name', 'data', 'asserts') + ->execute(); + $this->assertTrue($examples->rowCount >= 1); + foreach ($examples as $example) { + $this->assertTrue(!empty($example['data']['modelProps']), sprintf("Example (%s) is tagged phpunit. It should have modelProps.", $example['name'])); + $this->assertTrue(!empty($example['asserts']['default']), sprintf("Example (%s) is tagged phpunit. It should have assertions.", $example['name'])); + $result = \Civi\Api4\WorkflowMessage::render(0) + ->setWorkflow($example['data']['workflow']) + ->setValues($example['data']['modelProps']) + ->execute() + ->single(); + foreach ($example['asserts']['default'] as $num => $assert) { + $msg = sprintf('Check assertion(%s) on example (%s)', $num, $example['name']); + if (isset($assert['regex'])) { + $this->assertRegExp($assert['regex'], $result[$assert['for']], $msg); + } + else { + $this->fail('Unrecognized assertion: ' . json_encode($assert)); + } + } + } + } + +}