From 09f31d49b1b255d3ad7d3a73883b5778d6173b27 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 5 Aug 2022 03:30:39 -0700 Subject: [PATCH 01/10] APIv4 - Declare option `$translationMode` (for DAOGetAction) --- Civi/Api4/Generic/DAOGetAction.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Civi/Api4/Generic/DAOGetAction.php b/Civi/Api4/Generic/DAOGetAction.php index 0c33d2acc0bf..3d6c8753e8ad 100644 --- a/Civi/Api4/Generic/DAOGetAction.php +++ b/Civi/Api4/Generic/DAOGetAction.php @@ -24,6 +24,8 @@ * * @method $this setHaving(array $clauses) * @method array getHaving() + * @method $this setTranslationMode(string|null $mode) + * @method string|null getTranslationMode() */ class DAOGetAction extends AbstractGetAction { use Traits\DAOActionTrait; @@ -80,6 +82,15 @@ class DAOGetAction extends AbstractGetAction { */ protected $having = []; + /** + * Should we automatically overload the result with translated data? + * How do we pick the suitable translation? + * + * @var string|null + * @options fuzzy,strict + */ + protected $translationMode; + /** * @throws \API_Exception * @throws \CRM_Core_Exception From 9048f45f537f402067307cff1e37ef81389cc034 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 5 Aug 2022 03:31:12 -0700 Subject: [PATCH 02/10] APIv4 - Implement option` $translationMode` via TranslationGetWrapper --- CRM/Core/BAO/TranslateGetWrapper.php | 45 +++++++++ CRM/Core/BAO/Translation.php | 98 +++++++++++++++++++ .../CRM/Core/BAO/MessageTemplateTest.php | 85 ++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 CRM/Core/BAO/TranslateGetWrapper.php diff --git a/CRM/Core/BAO/TranslateGetWrapper.php b/CRM/Core/BAO/TranslateGetWrapper.php new file mode 100644 index 000000000000..67214b242605 --- /dev/null +++ b/CRM/Core/BAO/TranslateGetWrapper.php @@ -0,0 +1,45 @@ +fields = $translated['fields']; + $this->translatedLanguage = $translated['language']; + } + + /** + * @inheritdoc + */ + public function fromApiInput($apiRequest) { + return $apiRequest; + } + + /** + * @inheritdoc + */ + public function toApiOutput($apiRequest, $result) { + foreach ($result as &$value) { + if (!isset($value['id'], $this->fields[$value['id']])) { + continue; + } + $toSet = array_intersect_key($this->fields[$value['id']], $value); + $value = array_merge($value, $toSet); + $value['actual_language'] = $this->translatedLanguage; + } + return $result; + } + +} diff --git a/CRM/Core/BAO/Translation.php b/CRM/Core/BAO/Translation.php index c7ab686cce8b..f508281c9d02 100644 --- a/CRM/Core/BAO/Translation.php +++ b/CRM/Core/BAO/Translation.php @@ -9,6 +9,9 @@ +--------------------------------------------------------------------+ */ +use Civi\Api4\Generic\AbstractAction; +use Civi\Api4\Translation; + /** * * @package CRM @@ -144,4 +147,99 @@ public static function self_civi_api4_validate(\Civi\Api4\Event\ValidateValuesEv } } + /** + * Callback for hook_civicrm_post(). + * + * Flush out cached values. + * + * @param \Civi\Core\Event\PostEvent $event + */ + public static function self_hook_civicrm_post(\Civi\Core\Event\PostEvent $event): void { + unset(Civi::$statics[__CLASS__]); + } + + /** + * Implements hook_civicrm_apiWrappers(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_apiWrappers/ + * + * @see \CRM_Utils_Hook::apiWrappers() + * @throws \CRM_Core_Exception + */ + public static function hook_civicrm_apiWrappers(&$wrappers, $apiRequest): void { + if (!($apiRequest instanceof \Civi\Api4\Generic\DAOGetAction)) { + return; + } + + $mode = $apiRequest->getTranslationMode(); + if ($mode !== 'fuzzy') { + return; + } + + $communicationLanguage = \Civi\Core\Locale::detect()->nominal; + if ($communicationLanguage === Civi::settings()->get('lcMessages')) { + return; + } + + if ($apiRequest['action'] === 'get') { + if (!isset(\Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$communicationLanguage])) { + $translated = self::getTranslatedFieldsForRequest($apiRequest); + // @todo - once https://github.com/civicrm/civicrm-core/pull/24063 is merged + // this could set any defined translation fields that don't have a translation + // for one or more fields in the set to '' - ie 'if any are defined for + // an entity/language then all must be' - it seems like being strict on this + // now will make it easier later.... + //n No, this doesn't work - 'fields' array doesn't look like that. + //n if (!empty($translated['fields']['msg_html']) && !isset($translated['fields']['msg_text'])) { + //n $translated['fields']['msg_text'] = ''; + //n } + foreach ($translated['fields'] ?? [] as $field) { + \Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$communicationLanguage]['fields'][$field['entity_id']][$field['entity_field']] = $field['string']; + \Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$communicationLanguage]['language'] = $translated['language']; + } + } + if (!empty(\Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$communicationLanguage])) { + $wrappers[] = new CRM_Core_BAO_TranslateGetWrapper(\Civi::$statics[__CLASS__]['translate_fields'][$apiRequest['entity']][$communicationLanguage]); + } + } + } + + /** + * @param \Civi\Api4\Generic\AbstractAction $apiRequest + * @return array translated fields. + * + * @throws \CRM_Core_Exception + */ + protected static function getTranslatedFieldsForRequest(AbstractAction $apiRequest): array { + $userLocale = \Civi\Core\Locale::detect(); + + $translations = Translation::get() + ->addWhere('entity_table', '=', CRM_Core_DAO_AllCoreTables::getTableForEntityName($apiRequest['entity'])) + ->setCheckPermissions(FALSE) + ->setSelect(['entity_field', 'entity_id', 'string', 'language']); + if ((substr($userLocale->nominal, '-3', '3') !== '_NO')) { + // Generally we want to check for any translations of the base language + // and prefer, for example, French French over US English for French Canadians. + // Sites that genuinely want to cater to both will add translations for both + // and we work through preferences below. + $translations->addWhere('language', 'LIKE', substr($userLocale->nominal, 0, 2) . '%'); + } + else { + // And here we have ... the Norwegians. They have three main variants which + // share the same country suffix but not language prefix. As with other languages + // any Norwegian is better than no Norwegian and sites that care will do multiple + $translations->addWhere('language', 'LIKE', '%_NO'); + } + $fields = $translations->execute(); + $languages = []; + foreach ($fields as $index => $field) { + $languages[$field['language']][$index] = $field; + } + + $bizLocale = $userLocale->renegotiate(array_keys($languages)); + return $bizLocale + ? ['fields' => $languages[$bizLocale->nominal], 'language' => $bizLocale->nominal] + : []; + } + } diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index 7b87397f885d..431c47101322 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -3,6 +3,7 @@ use Civi\Api4\Address; use Civi\Api4\Contact; use Civi\Api4\MessageTemplate; +use Civi\Api4\Translation; use Civi\Token\TokenProcessor; /** @@ -201,6 +202,56 @@ public function testCaseActivityCopyTemplate():void { $this->assertStringContainsString('Case ID : 1234', $message); } + public function getTranslationSettings(): array { + $es = []; + $es['fr_FR-full'] = [ + ['partial_locales' => FALSE, 'uiLanguages' => ['en_US', 'fr_FR', 'fr_CA']], + ]; + $es['fr_FR-partial'] = [ + ['partial_locales' => TRUE, 'uiLanguages' => ['en_US']], + ]; + return $es; + } + + /** + * Test that translated strings are rendered for templates where they exist. + * + * @dataProvider getTranslationSettings + * @throws \API_Exception|\CRM_Core_Exception + */ + public function testGetTranslatedTemplate($translationSettings): void { + $cleanup = \CRM_Utils_AutoClean::swapSettings($translationSettings); + + $this->individualCreate(['preferred_language' => 'fr_FR']); + $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); + $this->addTranslation(); + + $messageTemplate = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) + ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') + ->setLanguage('fr_FR') + ->setTranslationMode('fuzzy') + ->execute()->indexBy('workflow_name'); + + $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); + + $this->assertStringContainsString('{ts}Contribution Receipt{/ts}', $messageTemplate['contribution_offline_receipt']['msg_subject']); + $this->assertStringContainsString('Below you will find a receipt', $messageTemplate['contribution_offline_receipt']['msg_html']); + $this->assertArrayNotHasKey('actual_language', $messageTemplate['contribution_offline_receipt']); + + $messageTemplate = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) + ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') + ->setLanguage('fr_CA') + ->setTranslationMode('fuzzy') + ->execute()->indexBy('workflow_name'); + + $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); + + } + /** * Test rendering of domain tokens. * @@ -920,4 +971,38 @@ protected function getExpectedContactOutputNewStyle($id, array $tokenData, strin return $expected; } + /** + * @return mixed + * @throws \API_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + private function addTranslation() { + $messageTemplateID = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', '=', 'contribution_online_receipt') + ->addSelect('id') + ->execute()->first()['id']; + + Translation::save()->setRecords([ + ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], + ['entity_field' => 'msg_html', 'string' => 'Voila!'], + ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], + ])->setDefaults([ + 'entity_table' => 'civicrm_msg_template', + 'entity_id' => $messageTemplateID, + 'status_id:name' => 'active', + 'language' => 'fr_FR', + ])->execute(); + return $messageTemplateID; + } + + /** + * @param $contribution_online_receipt + */ + private function assertFrenchTranslationRetrieved($contribution_online_receipt): void { + $this->assertEquals('Bonjour', $contribution_online_receipt['msg_subject']); + $this->assertEquals('Voila!', $contribution_online_receipt['msg_html']); + $this->assertEquals('fr_FR', $contribution_online_receipt['actual_language']); + } + } From f61325efa16dc0f54e6fc2b82a8319e8af184eca Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 7 Aug 2022 23:14:39 -0700 Subject: [PATCH 03/10] (REF) Move test for `$translationMode` to its own class The functionality is bigger than `MessageTemplate`, and the test is fairly long. --- .../CRM/Core/BAO/MessageTemplateTest.php | 85 ------------ .../api/v4/Options/TranslationModeTest.php | 121 ++++++++++++++++++ 2 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 tests/phpunit/api/v4/Options/TranslationModeTest.php diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index 431c47101322..7b87397f885d 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -3,7 +3,6 @@ use Civi\Api4\Address; use Civi\Api4\Contact; use Civi\Api4\MessageTemplate; -use Civi\Api4\Translation; use Civi\Token\TokenProcessor; /** @@ -202,56 +201,6 @@ public function testCaseActivityCopyTemplate():void { $this->assertStringContainsString('Case ID : 1234', $message); } - public function getTranslationSettings(): array { - $es = []; - $es['fr_FR-full'] = [ - ['partial_locales' => FALSE, 'uiLanguages' => ['en_US', 'fr_FR', 'fr_CA']], - ]; - $es['fr_FR-partial'] = [ - ['partial_locales' => TRUE, 'uiLanguages' => ['en_US']], - ]; - return $es; - } - - /** - * Test that translated strings are rendered for templates where they exist. - * - * @dataProvider getTranslationSettings - * @throws \API_Exception|\CRM_Core_Exception - */ - public function testGetTranslatedTemplate($translationSettings): void { - $cleanup = \CRM_Utils_AutoClean::swapSettings($translationSettings); - - $this->individualCreate(['preferred_language' => 'fr_FR']); - $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); - $this->addTranslation(); - - $messageTemplate = MessageTemplate::get() - ->addWhere('is_default', '=', 1) - ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) - ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') - ->setLanguage('fr_FR') - ->setTranslationMode('fuzzy') - ->execute()->indexBy('workflow_name'); - - $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); - - $this->assertStringContainsString('{ts}Contribution Receipt{/ts}', $messageTemplate['contribution_offline_receipt']['msg_subject']); - $this->assertStringContainsString('Below you will find a receipt', $messageTemplate['contribution_offline_receipt']['msg_html']); - $this->assertArrayNotHasKey('actual_language', $messageTemplate['contribution_offline_receipt']); - - $messageTemplate = MessageTemplate::get() - ->addWhere('is_default', '=', 1) - ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) - ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') - ->setLanguage('fr_CA') - ->setTranslationMode('fuzzy') - ->execute()->indexBy('workflow_name'); - - $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); - - } - /** * Test rendering of domain tokens. * @@ -971,38 +920,4 @@ protected function getExpectedContactOutputNewStyle($id, array $tokenData, strin return $expected; } - /** - * @return mixed - * @throws \API_Exception - * @throws \Civi\API\Exception\UnauthorizedException - */ - private function addTranslation() { - $messageTemplateID = MessageTemplate::get() - ->addWhere('is_default', '=', 1) - ->addWhere('workflow_name', '=', 'contribution_online_receipt') - ->addSelect('id') - ->execute()->first()['id']; - - Translation::save()->setRecords([ - ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], - ['entity_field' => 'msg_html', 'string' => 'Voila!'], - ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], - ])->setDefaults([ - 'entity_table' => 'civicrm_msg_template', - 'entity_id' => $messageTemplateID, - 'status_id:name' => 'active', - 'language' => 'fr_FR', - ])->execute(); - return $messageTemplateID; - } - - /** - * @param $contribution_online_receipt - */ - private function assertFrenchTranslationRetrieved($contribution_online_receipt): void { - $this->assertEquals('Bonjour', $contribution_online_receipt['msg_subject']); - $this->assertEquals('Voila!', $contribution_online_receipt['msg_html']); - $this->assertEquals('fr_FR', $contribution_online_receipt['actual_language']); - } - } diff --git a/tests/phpunit/api/v4/Options/TranslationModeTest.php b/tests/phpunit/api/v4/Options/TranslationModeTest.php new file mode 100644 index 000000000000..6cf7c19e9759 --- /dev/null +++ b/tests/phpunit/api/v4/Options/TranslationModeTest.php @@ -0,0 +1,121 @@ +setTranslationMode(...)`. + * + * Broadly, these tests need to: + * - Make some example business records + * - Add translations for them + * - Read back the translations, with variations on the translation-mode. + * + * @group headless + */ +class TranslationModeTest extends Api4TestBase { + + public function getTranslationSettings(): array { + $es = []; + $es['fr_FR-full'] = [ + ['partial_locales' => FALSE, 'uiLanguages' => ['en_US', 'fr_FR', 'fr_CA']], + ]; + $es['fr_FR-partial'] = [ + ['partial_locales' => TRUE, 'uiLanguages' => ['en_US']], + ]; + return $es; + } + + /** + * Test that translated strings are rendered for templates where they exist. + * + * @dataProvider getTranslationSettings + * @throws \API_Exception|\CRM_Core_Exception + * @group locale + */ + public function testGetTranslatedTemplate($translationSettings): void { + $cleanup = \CRM_Utils_AutoClean::swapSettings($translationSettings); + + $cid = $this->createTestRecord('Contact', ['preferred_language' => 'fr_FR'])['id']; + $this->createTestRecord('Contribution', ['contact_id' => $cid]); + $this->addTranslation(); + + $messageTemplate = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) + ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') + ->setLanguage('fr_FR') + ->setTranslationMode('fuzzy') + ->execute()->indexBy('workflow_name'); + + $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); + + $this->assertStringContainsString('{ts}Contribution Receipt{/ts}', $messageTemplate['contribution_offline_receipt']['msg_subject']); + $this->assertStringContainsString('Below you will find a receipt', $messageTemplate['contribution_offline_receipt']['msg_html']); + $this->assertArrayNotHasKey('actual_language', $messageTemplate['contribution_offline_receipt']); + + $messageTemplate = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', 'IN', ['contribution_online_receipt', 'contribution_offline_receipt']) + ->addSelect('id', 'msg_subject', 'msg_html', 'workflow_name') + ->setLanguage('fr_CA') + ->setTranslationMode('fuzzy') + ->execute()->indexBy('workflow_name'); + + $this->assertFrenchTranslationRetrieved($messageTemplate['contribution_online_receipt']); + } + + /** + * @return mixed + * @throws \API_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + private function addTranslation() { + $messageTemplateID = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', '=', 'contribution_online_receipt') + ->addSelect('id') + ->execute()->first()['id']; + + Translation::save()->setRecords([ + ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], + ['entity_field' => 'msg_html', 'string' => 'Voila!'], + ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], + ])->setDefaults([ + 'entity_table' => 'civicrm_msg_template', + 'entity_id' => $messageTemplateID, + 'status_id:name' => 'active', + 'language' => 'fr_FR', + ])->execute(); + return $messageTemplateID; + } + + /** + * @param $contribution_online_receipt + */ + private function assertFrenchTranslationRetrieved($contribution_online_receipt): void { + $this->assertEquals('Bonjour', $contribution_online_receipt['msg_subject']); + $this->assertEquals('Voila!', $contribution_online_receipt['msg_html']); + $this->assertEquals('fr_FR', $contribution_online_receipt['actual_language']); + } + +} From c17f57562d6994cf6be6bf23f96222acb2bfe917 Mon Sep 17 00:00:00 2001 From: Eileen McNaughton Date: Mon, 27 Jun 2022 21:35:53 -0700 Subject: [PATCH 04/10] APIv4 - Add generic 'language', 'translationLanguage' to all APIs Move hook_civicrm_translateFields from message_admin to core Move hook_civicrm_translateFields from message_admin to core m --- CRM/Core/BAO/MessageTemplate.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CRM/Core/BAO/MessageTemplate.php b/CRM/Core/BAO/MessageTemplate.php index a5bc954f9d7c..5f63cb1520ff 100644 --- a/CRM/Core/BAO/MessageTemplate.php +++ b/CRM/Core/BAO/MessageTemplate.php @@ -527,7 +527,7 @@ protected static function loadTemplate(string $workflowName, bool $isTest, int $ $mailContent['subject'] = $subjectOverride; } - return $mailContent; + return [$mailContent, $apiCall->getTranslationLanguage()]; } /** From 5ea3ea091d05ececb9ad62803f8c6376de3e503c Mon Sep 17 00:00:00 2001 From: Eileen McNaughton Date: Mon, 27 Jun 2022 21:39:07 -0700 Subject: [PATCH 05/10] Translations - if a message_template has been translated then get/render the translated version --- CRM/Core/BAO/MessageTemplate.php | 21 ++++- .../hook_civicrm_alterMailParams.evch.php | 3 + .../CRM/Core/BAO/MessageTemplateTest.php | 88 +++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CRM/Core/BAO/MessageTemplate.php b/CRM/Core/BAO/MessageTemplate.php index 5f63cb1520ff..10d357e86b2c 100644 --- a/CRM/Core/BAO/MessageTemplate.php +++ b/CRM/Core/BAO/MessageTemplate.php @@ -341,9 +341,18 @@ protected static function renderTemplateRaw($params) { self::synchronizeLegacyParameters($params); $params = array_merge($modelDefaults, $viewDefaults, $envelopeDefaults, $params); - + $language = $params['language'] ?? (!empty($params['contactId']) ? Civi\Api4\Contact::get(FALSE)->addWhere('id', '=', $params['contactId'])->addSelect('preferred_language')->execute()->first()['preferred_language'] : NULL); CRM_Utils_Hook::alterMailParams($params, 'messageTemplate'); - $mailContent = self::loadTemplate((string) $params['workflow'], $params['isTest'], $params['messageTemplateID'] ?? NULL, $params['groupName'] ?? '', $params['messageTemplate'], $params['subject'] ?? NULL); + [$mailContent, $translatedLanguage] = self::loadTemplate((string) $params['workflow'], $params['isTest'], $params['messageTemplateID'] ?? NULL, $params['groupName'] ?? '', $params['messageTemplate'], $params['subject'] ?? NULL, $language); + global $moneyFormatLocale; + $originalValue = $moneyFormatLocale; + if ($translatedLanguage) { + // If the template has been translated then set the moneyFormatLocale to match the translation. + // Note that in future if we do the same for dates we are likely to want to set it to match + // the preferred_language rather than the translation language - a long discussion is on the + // property in AbstractAction + $moneyFormatLocale = $translatedLanguage; + } self::synchronizeLegacyParameters($params); $rendered = CRM_Core_TokenSmarty::render(CRM_Utils_Array::subset($mailContent, ['text', 'html', 'subject']), $params['tokenContext'], $params['tplParams']); @@ -352,6 +361,7 @@ protected static function renderTemplateRaw($params) { } $nullSet = ['subject' => NULL, 'text' => NULL, 'html' => NULL]; $mailContent = array_merge($nullSet, $mailContent, $rendered); + $moneyFormatLocale = $originalValue; return [$mailContent, $params]; } @@ -459,18 +469,20 @@ protected static function getWorkflowNameIdMap() { * If omitted, the record will be loaded from workflowName/messageTemplateID. * @param string|null $subjectOverride * This option is the older, wonkier version of $messageTemplate['msg_subject']... + * @param string|null $language * * @return array * @throws \API_Exception * @throws \CRM_Core_Exception */ - protected static function loadTemplate(string $workflowName, bool $isTest, int $messageTemplateID = NULL, $groupName = NULL, ?array $messageTemplateOverride = NULL, ?string $subjectOverride = NULL): array { + protected static function loadTemplate(string $workflowName, bool $isTest, int $messageTemplateID = NULL, $groupName = NULL, ?array $messageTemplateOverride = NULL, ?string $subjectOverride = NULL, ?string $language = NULL): array { $base = ['msg_subject' => NULL, 'msg_text' => NULL, 'msg_html' => NULL, 'pdf_format_id' => NULL]; if (!$workflowName && !$messageTemplateID) { throw new CRM_Core_Exception(ts("Message template's option value or ID missing.")); } $apiCall = MessageTemplate::get(FALSE) + ->setLanguage($language) ->addSelect('msg_subject', 'msg_text', 'msg_html', 'pdf_format_id', 'id') ->addWhere('is_default', '=', 1); @@ -480,7 +492,8 @@ protected static function loadTemplate(string $workflowName, bool $isTest, int $ else { $apiCall->addWhere('workflow_name', '=', $workflowName); } - $messageTemplate = array_merge($base, $apiCall->execute()->first() ?: [], $messageTemplateOverride ?: []); + $result = $apiCall->execute(); + $messageTemplate = array_merge($base, $result->first() ?: [], $messageTemplateOverride ?: []); if (empty($messageTemplate['id']) && empty($messageTemplateOverride)) { if ($messageTemplateID) { throw new CRM_Core_Exception(ts('No such message template: id=%1.', [1 => $messageTemplateID])); diff --git a/tests/events/hook_civicrm_alterMailParams.evch.php b/tests/events/hook_civicrm_alterMailParams.evch.php index cc331f1aa254..c41e9ee78aac 100644 --- a/tests/events/hook_civicrm_alterMailParams.evch.php +++ b/tests/events/hook_civicrm_alterMailParams.evch.php @@ -40,6 +40,9 @@ 'Precedence' => ['type' => 'string|NULL', 'for' => ['civimail', 'flexmailer'], 'regex' => '/(bulk|first-class|list)/'], 'job_id' => ['type' => 'int|NULL', 'for' => ['civimail', 'flexmailer']], + // ## Language + 'language' => ['type' => 'string|NULL', 'for' => ['messageTemplate']], + // ## Content 'subject' => ['for' => ['messageTemplate', 'singleEmail'], 'type' => 'string'], diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index 7b87397f885d..4172795d990c 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -46,6 +46,94 @@ public function testRenderTemplate(): void { $this->assertStringContainsString('

Hello testRenderTemplate Abba Baab!

', $rendered['html']); } + /** + * Test that translated strings are rendered for templates where they exist. + * + * @throws \API_Exception|\CRM_Core_Exception + */ + public function testRenderTranslatedTemplate(): void { + $this->individualCreate(['preferred_language' => 'fr_FR']); + $contributionID = $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); + $messageTemplateID = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', '=', 'contribution_online_receipt') + ->addSelect('id') + ->execute()->first()['id']; + + Translation::save()->setRecords([ + ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], + ['entity_field' => 'msg_html', 'string' => 'Voila!'], + ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], + ])->setDefaults([ + 'entity_table' => 'civicrm_msg_template', + 'entity_id' => $messageTemplateID, + 'status_id:name' => 'active', + 'language' => 'fr_FR', + ])->execute(); + + $messageTemplateFrench = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', '=', 'contribution_online_receipt') + ->addSelect('id', 'msg_subject', 'msg_html') + ->setLanguage('fr_FR') + ->execute()->first(); + + $this->assertEquals('Bonjour', $messageTemplateFrench['msg_subject']); + $this->assertEquals('Voila!', $messageTemplateFrench['msg_html']); + + $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ + 'workflow' => 'contribution_online_receipt', + 'tokenContext' => [ + 'contactId' => $this->ids['Contact']['individual_0'], + 'contributionId' => $contributionID, + ], + ]); + $this->assertEquals('Bonjour', $rendered['subject']); + $this->assertEquals('Voila!', $rendered['html']); + $this->assertEquals('100,00 $US', $rendered['text']); + + // French Canadian should ALSO pick up French if there + //is no specific French Canadian. + $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ + 'workflow' => 'contribution_online_receipt', + 'tokenContext' => [ + 'contactId' => $this->ids['Contact']['individual_0'], + 'contributionId' => $contributionID, + ], + 'language' => 'fr_CA', + ]); + $this->assertEquals('Bonjour', $rendered['subject']); + $this->assertEquals('Voila!', $rendered['html']); + // Money is formatted per fr_FR locale as that is the found-template-locale. + $this->assertEquals('100,00 $US', $rendered['text']); + + Translation::save()->setRecords([ + ['entity_field' => 'msg_subject', 'string' => 'Bonjour Canada'], + ['entity_field' => 'msg_html', 'string' => 'Voila! Canada'], + ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], + ])->setDefaults([ + 'entity_table' => 'civicrm_msg_template', + 'entity_id' => $messageTemplateID, + 'status_id:name' => 'active', + 'language' => 'fr_CA', + ])->execute(); + + // But, prefer French Canadian where both exist. + $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ + 'workflow' => 'contribution_online_receipt', + 'tokenContext' => [ + 'contactId' => $this->ids['Contact']['individual_0'], + 'contributionId' => $contributionID, + ], + 'language' => 'fr_CA', + ]); + $this->assertEquals('Bonjour Canada', $rendered['subject']); + $this->assertEquals('Voila! Canada', $rendered['html']); + // Note that as there was a native-Canada format the money-formatting is + // also subtly different. + $this->assertEquals('100,00 $ US', $rendered['text']); + } + /** * @throws \API_Exception * @throws \CRM_Core_Exception From 6a02902c8373bff899054878a129780f94149272 Mon Sep 17 00:00:00 2001 From: Eileen McNaughton Date: Sun, 28 Aug 2022 16:26:50 -0700 Subject: [PATCH 06/10] Translation BAO - Minor strictness cleanups --- CRM/Core/BAO/Translation.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CRM/Core/BAO/Translation.php b/CRM/Core/BAO/Translation.php index f508281c9d02..f1876031188b 100644 --- a/CRM/Core/BAO/Translation.php +++ b/CRM/Core/BAO/Translation.php @@ -11,13 +11,14 @@ use Civi\Api4\Generic\AbstractAction; use Civi\Api4\Translation; +use Civi\Core\HookInterface; /** * * @package CRM * @copyright CiviCRM LLC https://civicrm.org/licensing */ -class CRM_Core_BAO_Translation extends CRM_Core_DAO_Translation implements \Civi\Core\HookInterface { +class CRM_Core_BAO_Translation extends CRM_Core_DAO_Translation implements HookInterface { use CRM_Core_DynamicFKAccessTrait; @@ -26,7 +27,7 @@ class CRM_Core_BAO_Translation extends CRM_Core_DAO_Translation implements \Civi * * @return array[] */ - public static function getStatuses() { + public static function getStatuses(): array { return [ ['id' => 1, 'name' => 'active', 'label' => ts('Active')], ['id' => 2, 'name' => 'draft', 'label' => ts('Draft')], From 01b6270ba465914d7673ef3778940f76ad048506 Mon Sep 17 00:00:00 2001 From: Eileen McNaughton Date: Wed, 22 Jun 2022 15:29:27 +1200 Subject: [PATCH 07/10] Pass language into msg_admin api calls --- Civi/Api4/Action/WorkflowMessage/Render.php | 15 +++++++++++++-- ext/message_admin/ang/crmMsgadm/Edit.js | 2 +- ext/message_admin/ang/crmMsgadm/Preview.js | 10 +++++++++- tests/phpunit/api/v4/Entity/ConformanceTest.php | 1 + 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Civi/Api4/Action/WorkflowMessage/Render.php b/Civi/Api4/Action/WorkflowMessage/Render.php index 86611072b9f7..c645374ba81f 100644 --- a/Civi/Api4/Action/WorkflowMessage/Render.php +++ b/Civi/Api4/Action/WorkflowMessage/Render.php @@ -75,13 +75,24 @@ class Render extends \Civi\Api4\Generic\AbstractAction { public function _run(\Civi\Api4\Generic\Result $result) { $this->validateValues(); - + global $moneyFormatLocale; + $separatorConfig = \CRM_Utils_Constant::value('IGNORE_SEPARATOR_CONFIG'); + $originalValue = $moneyFormatLocale; + + if ($this->getTranslationLanguage()) { + // Passing in translation language forces money formatting, useful when the + // template is previewed before being saved. + $moneyFormatLocale = $this->getTranslationLanguage(); + putenv('IGNORE_SEPARATOR_CONFIG=' . 1); + } $r = \CRM_Core_BAO_MessageTemplate::renderTemplate([ 'model' => $this->_model, 'messageTemplate' => $this->getMessageTemplate(), 'messageTemplateId' => $this->getMessageTemplateId(), + 'language' => $this->getPreferredLanguage(), ]); - + $moneyFormatLocale = $originalValue; + putenv('IGNORE_SEPARATOR_CONFIG=' . $separatorConfig); $result[] = \CRM_Utils_Array::subset($r, ['subject', 'html', 'text']); } diff --git a/ext/message_admin/ang/crmMsgadm/Edit.js b/ext/message_admin/ang/crmMsgadm/Edit.js index 6e01a76a7a4d..ccba2dfcac75 100644 --- a/ext/message_admin/ang/crmMsgadm/Edit.js +++ b/ext/message_admin/ang/crmMsgadm/Edit.js @@ -271,6 +271,7 @@ crmApi4({ examples: ['ExampleData', 'get', { // FIXME: workflow name + language: $ctrl.lang, where: [["tags", "CONTAINS", "preview"], ["name", "LIKE", "workflow/" + $ctrl.records.main.workflow_name + "/%"]], select: ['name', 'title', 'data'] }], @@ -279,7 +280,6 @@ format: 'example' }] }).then(function(resp) { - console.log('resp',resp); if ((!resp.examples || resp.examples.length === 0) && resp.adhoc) { // In the future, if Preview dialog allows editing adhoc examples, then we can show the dialog. But for now, it won't work without explicit examples. crmUiAlert({ diff --git a/ext/message_admin/ang/crmMsgadm/Preview.js b/ext/message_admin/ang/crmMsgadm/Preview.js index 82b994c27d1e..1b202c577d57 100644 --- a/ext/message_admin/ang/crmMsgadm/Preview.js +++ b/ext/message_admin/ang/crmMsgadm/Preview.js @@ -1,10 +1,14 @@ (function(angular, $, _) { - angular.module('crmMsgadm').controller('MsgtpluiPreviewCtrl', function($scope, crmUiHelp, crmStatus, crmApi4, crmUiAlert, $timeout, $q, dialogService) { + angular.module('crmMsgadm').controller('MsgtpluiPreviewCtrl', function($scope, crmUiHelp, crmStatus, crmApi4, crmUiAlert, $timeout, $q, dialogService, $location) { var ts = $scope.ts = CRM.ts('crmMsgadm'); var hs = $scope.hs = crmUiHelp({file: 'CRM/MessageAdmin/crmMsgadm'}); // See: templates/CRM/MessageAdmin/crmMsgadm.hlp var $ctrl = this, model = $scope.model; + var args = $location.search(); + if (args.lang) { + $ctrl.lang = args.lang; + } $ctrl.exampleId = parseInt(_.findKey(model.examples, {name: model.exampleName})); $ctrl.revisionId = parseInt(_.findKey(model.revisions, {name: model.revisionName})); @@ -35,6 +39,7 @@ dlgModel.refresh = function(){ return crmApi4('ExampleData', 'get', { where: [["name", "=", model.examples[$ctrl.exampleId].name]], + language: $ctrl.lang, select: ['name', 'file', 'title', 'data'] }).then(function(response){ dlgModel.title = ts('Example: %1', {1: response[0].title || response[0].name}); @@ -64,6 +69,7 @@ function requestStoredExample() { return crmApi4('ExampleData', 'get', { where: [["name", "=", model.examples[$ctrl.exampleId].name]], + language: $ctrl.lang, select: ['data'] }).then(function(response) { return response[0].data; @@ -82,6 +88,8 @@ rendering.then(function(exampleData) { var filteredData = model.filterData ? model.filterData(exampleData) : exampleData; return crmApi4('WorkflowMessage', 'render', { + language: $ctrl.lang, + translationLanguage: $ctrl.lang, workflow: filteredData.workflow, values: filteredData.modelProps, messageTemplate: model.revisions[$ctrl.revisionId].rec diff --git a/tests/phpunit/api/v4/Entity/ConformanceTest.php b/tests/phpunit/api/v4/Entity/ConformanceTest.php index f7b43a04cdf1..773012b4f577 100644 --- a/tests/phpunit/api/v4/Entity/ConformanceTest.php +++ b/tests/phpunit/api/v4/Entity/ConformanceTest.php @@ -67,6 +67,7 @@ public function tearDown(): void { 'civicrm_participant', 'civicrm_batch', 'civicrm_product', + 'civicrm_translation', ]; $this->cleanup(['tablesToTruncate' => $tablesToTruncate]); parent::tearDown(); From afedf2149c764e5f2aba5600734e64167be50424 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 8 Aug 2022 02:05:12 -0700 Subject: [PATCH 08/10] (Update) Translations - if a message_template has been translated then get/render the translated version * Updates for APIv4 calls * Set `$language` and `#ranslationMode()` instead of `$preferredLanguage` * Read 'actual_language' instead of `getTranslationLanguage()` * Updates for tracking global locale properties * Use `$loacleObj->moneyFormat` instead of `$GLOBALS['moneyFormatLocale']` and `IGNORE_SEPARATOR_CONFIG` * Use `$tokenContext['locale']` instead of `$GLOBALS['moneyFormatLocale']` and `IGNORE_SEPARATOR_CONFIG` * Split `testRenderTranslatedTemplate()` in two (for different configurations) --- CRM/Core/BAO/MessageTemplate.php | 14 +--- Civi/Api4/Action/WorkflowMessage/Render.php | 14 +--- .../CRM/Core/BAO/MessageTemplateTest.php | 66 ++++++++++++++++++- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/CRM/Core/BAO/MessageTemplate.php b/CRM/Core/BAO/MessageTemplate.php index 10d357e86b2c..cd260a29c9dd 100644 --- a/CRM/Core/BAO/MessageTemplate.php +++ b/CRM/Core/BAO/MessageTemplate.php @@ -344,15 +344,7 @@ protected static function renderTemplateRaw($params) { $language = $params['language'] ?? (!empty($params['contactId']) ? Civi\Api4\Contact::get(FALSE)->addWhere('id', '=', $params['contactId'])->addSelect('preferred_language')->execute()->first()['preferred_language'] : NULL); CRM_Utils_Hook::alterMailParams($params, 'messageTemplate'); [$mailContent, $translatedLanguage] = self::loadTemplate((string) $params['workflow'], $params['isTest'], $params['messageTemplateID'] ?? NULL, $params['groupName'] ?? '', $params['messageTemplate'], $params['subject'] ?? NULL, $language); - global $moneyFormatLocale; - $originalValue = $moneyFormatLocale; - if ($translatedLanguage) { - // If the template has been translated then set the moneyFormatLocale to match the translation. - // Note that in future if we do the same for dates we are likely to want to set it to match - // the preferred_language rather than the translation language - a long discussion is on the - // property in AbstractAction - $moneyFormatLocale = $translatedLanguage; - } + $params['tokenContext']['locale'] = $translatedLanguage ?? $params['language'] ?? NULL; self::synchronizeLegacyParameters($params); $rendered = CRM_Core_TokenSmarty::render(CRM_Utils_Array::subset($mailContent, ['text', 'html', 'subject']), $params['tokenContext'], $params['tplParams']); @@ -361,7 +353,6 @@ protected static function renderTemplateRaw($params) { } $nullSet = ['subject' => NULL, 'text' => NULL, 'html' => NULL]; $mailContent = array_merge($nullSet, $mailContent, $rendered); - $moneyFormatLocale = $originalValue; return [$mailContent, $params]; } @@ -483,6 +474,7 @@ protected static function loadTemplate(string $workflowName, bool $isTest, int $ $apiCall = MessageTemplate::get(FALSE) ->setLanguage($language) + ->setTranslationMode('fuzzy') ->addSelect('msg_subject', 'msg_text', 'msg_html', 'pdf_format_id', 'id') ->addWhere('is_default', '=', 1); @@ -540,7 +532,7 @@ protected static function loadTemplate(string $workflowName, bool $isTest, int $ $mailContent['subject'] = $subjectOverride; } - return [$mailContent, $apiCall->getTranslationLanguage()]; + return [$mailContent, $messageTemplate['actual_language'] ?? NULL]; } /** diff --git a/Civi/Api4/Action/WorkflowMessage/Render.php b/Civi/Api4/Action/WorkflowMessage/Render.php index c645374ba81f..6a5658cb6b8b 100644 --- a/Civi/Api4/Action/WorkflowMessage/Render.php +++ b/Civi/Api4/Action/WorkflowMessage/Render.php @@ -75,24 +75,12 @@ class Render extends \Civi\Api4\Generic\AbstractAction { public function _run(\Civi\Api4\Generic\Result $result) { $this->validateValues(); - global $moneyFormatLocale; - $separatorConfig = \CRM_Utils_Constant::value('IGNORE_SEPARATOR_CONFIG'); - $originalValue = $moneyFormatLocale; - - if ($this->getTranslationLanguage()) { - // Passing in translation language forces money formatting, useful when the - // template is previewed before being saved. - $moneyFormatLocale = $this->getTranslationLanguage(); - putenv('IGNORE_SEPARATOR_CONFIG=' . 1); - } $r = \CRM_Core_BAO_MessageTemplate::renderTemplate([ 'model' => $this->_model, 'messageTemplate' => $this->getMessageTemplate(), 'messageTemplateId' => $this->getMessageTemplateId(), - 'language' => $this->getPreferredLanguage(), + 'language' => $this->getLanguage(), ]); - $moneyFormatLocale = $originalValue; - putenv('IGNORE_SEPARATOR_CONFIG=' . $separatorConfig); $result[] = \CRM_Utils_Array::subset($r, ['subject', 'html', 'text']); } diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index 4172795d990c..4d6a1c3b9563 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -3,6 +3,7 @@ use Civi\Api4\Address; use Civi\Api4\Contact; use Civi\Api4\MessageTemplate; +use Civi\Api4\Translation; use Civi\Token\TokenProcessor; /** @@ -49,9 +50,16 @@ public function testRenderTemplate(): void { /** * Test that translated strings are rendered for templates where they exist. * + * This system has a relatively open localization policy where any translation can be used, + * even if the system doesn't allow it in the web UI. Ex: The sysadmin has configured 'fr_FR' + * strings. The user has requested 'fr_CA', and we'll fallback to 'fr_CA'. + * * @throws \API_Exception|\CRM_Core_Exception + * @group locale */ - public function testRenderTranslatedTemplate(): void { + public function testRenderTranslatedTemplate_AllowPartialLocales(): void { + $cleanup = \CRM_Utils_AutoClean::swapSettings(['partial_locales' => TRUE, 'uiLanguages' => ['en_US']]); + $this->individualCreate(['preferred_language' => 'fr_FR']); $contributionID = $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); $messageTemplateID = MessageTemplate::get() @@ -76,6 +84,7 @@ public function testRenderTranslatedTemplate(): void { ->addWhere('workflow_name', '=', 'contribution_online_receipt') ->addSelect('id', 'msg_subject', 'msg_html') ->setLanguage('fr_FR') + ->setTranslationMode('fuzzy') ->execute()->first(); $this->assertEquals('Bonjour', $messageTemplateFrench['msg_subject']); @@ -134,6 +143,61 @@ public function testRenderTranslatedTemplate(): void { $this->assertEquals('100,00 $ US', $rendered['text']); } + /** + * Test that translated strings are rendered for templates where they exist. + * + * This system has a relatively closed localization policy where translations will only be + * used if the locale is fully supported by the app. Ex: Even though there are some strings + * for 'fr_FR', the language in "Admin=>Localizaton", so we don't use it. + * + * @throws \API_Exception|\CRM_Core_Exception + * @group locale + */ + public function testRenderTranslatedTemplate_OnlyFullLocales(): void { + $cleanup = \CRM_Utils_AutoClean::swapSettings(['partial_locales' => FALSE, 'uiLanguages' => ['en_US']]); + + $this->individualCreate(['preferred_language' => 'fr_FR']); + $contributionID = $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); + $messageTemplateID = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', '=', 'contribution_online_receipt') + ->addSelect('id') + ->execute()->first()['id']; + + Translation::save()->setRecords([ + ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], + ['entity_field' => 'msg_html', 'string' => 'Voila!'], + ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], + ])->setDefaults([ + 'entity_table' => 'civicrm_msg_template', + 'entity_id' => $messageTemplateID, + 'status_id:name' => 'active', + 'language' => 'fr_FR', + ])->execute(); + + $messageTemplateFrench = MessageTemplate::get() + ->addWhere('is_default', '=', 1) + ->addWhere('workflow_name', '=', 'contribution_online_receipt') + ->addSelect('id', 'msg_subject', 'msg_html') + ->setLanguage('fr_FR') + ->setTranslationMode('fuzzy') + ->execute()->first(); + + $this->assertStringNotContainsString('Bonjour', $messageTemplateFrench['msg_subject']); + $this->assertStringNotContainsString('Voila!', $messageTemplateFrench['msg_html']); + + $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ + 'workflow' => 'contribution_online_receipt', + 'tokenContext' => [ + 'contactId' => $this->ids['Contact']['individual_0'], + 'contributionId' => $contributionID, + ], + ]); + $this->assertStringNotContainsString('Bonjour', $rendered['subject']); + $this->assertStringNotContainsString('Voila!', $rendered['html']); + $this->assertStringNotContainsString('100,00', $rendered['text']); + } + /** * @throws \API_Exception * @throws \CRM_Core_Exception From 0fa49e796a9ea6b21e3c5e4a6777eb3fbc45ba50 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 18 Aug 2022 03:29:01 -0700 Subject: [PATCH 09/10] MessageTemplateTest - Rearrange with data-provider. Add more tests. --- .../CRM/Core/BAO/MessageTemplateTest.php | 189 ++++++++---------- 1 file changed, 79 insertions(+), 110 deletions(-) diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php index 4d6a1c3b9563..b8d657498de5 100644 --- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php +++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php @@ -47,116 +47,68 @@ public function testRenderTemplate(): void { $this->assertStringContainsString('

Hello testRenderTemplate Abba Baab!

', $rendered['html']); } - /** - * Test that translated strings are rendered for templates where they exist. - * - * This system has a relatively open localization policy where any translation can be used, - * even if the system doesn't allow it in the web UI. Ex: The sysadmin has configured 'fr_FR' - * strings. The user has requested 'fr_CA', and we'll fallback to 'fr_CA'. - * - * @throws \API_Exception|\CRM_Core_Exception - * @group locale - */ - public function testRenderTranslatedTemplate_AllowPartialLocales(): void { - $cleanup = \CRM_Utils_AutoClean::swapSettings(['partial_locales' => TRUE, 'uiLanguages' => ['en_US']]); + public function getLocaleConfigurations(): array { + $yesPartials = ['partial_locales' => TRUE, 'uiLanguages' => ['en_US']]; + $noPartials = ['partial_locales' => FALSE, 'uiLanguages' => ['en_US'], 'format_locale' => 'en_US']; - $this->individualCreate(['preferred_language' => 'fr_FR']); - $contributionID = $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); - $messageTemplateID = MessageTemplate::get() - ->addWhere('is_default', '=', 1) - ->addWhere('workflow_name', '=', 'contribution_online_receipt') - ->addSelect('id') - ->execute()->first()['id']; + $allTemplates = []; + $allTemplates['*'] = ['subject' => 'Hello', 'html' => 'Looky there!', 'text' => '{contribution.total_amount}']; + $allTemplates['fr_FR'] = ['subject' => 'Bonjour', 'html' => 'Voila!', 'text' => '{contribution.total_amount}']; + $allTemplates['fr_CA'] = ['subject' => 'Bonjour Canada', 'html' => 'Voila! Canada', 'text' => '{contribution.total_amount}']; + $allTemplates['es_PR'] = ['subject' => 'Buenos dias', 'html' => 'Listo', 'text' => '{contribution.total_amount}']; + $allTemplates['th_TH'] = ['subject' => 'สวัสดี', 'html' => 'ดังนั้น', 'text' => '{contribution.total_amount}']; - Translation::save()->setRecords([ - ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], - ['entity_field' => 'msg_html', 'string' => 'Voila!'], - ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], - ])->setDefaults([ - 'entity_table' => 'civicrm_msg_template', - 'entity_id' => $messageTemplateID, - 'status_id:name' => 'active', - 'language' => 'fr_FR', - ])->execute(); + $onlyTemplates = function(array $locales) use ($allTemplates) { + return CRM_Utils_Array::subset($allTemplates, $locales); + }; - $messageTemplateFrench = MessageTemplate::get() - ->addWhere('is_default', '=', 1) - ->addWhere('workflow_name', '=', 'contribution_online_receipt') - ->addSelect('id', 'msg_subject', 'msg_html') - ->setLanguage('fr_FR') - ->setTranslationMode('fuzzy') - ->execute()->first(); + $rendered = []; + // $rendered['*'] = ['subject' => 'Hello', 'html' => 'Looky there!', 'text' => '$ 100.00']; + $rendered['*'] = ['subject' => 'Hello', 'html' => 'Looky there!', 'text' => '$100.00']; + $rendered['fr_FR'] = ['subject' => 'Bonjour', 'html' => 'Voila!', 'text' => '100,00 $US']; + $rendered['fr_CA'] = ['subject' => 'Bonjour Canada', 'html' => 'Voila! Canada', 'text' => '100,00 $ US']; + $rendered['es_PR'] = ['subject' => 'Buenos dias', 'html' => 'Listo', 'text' => '100.00 $US']; + $rendered['th_TH'] = ['subject' => 'สวัสดี', 'html' => 'ดังนั้น', 'text' => 'US$100.00']; - $this->assertEquals('Bonjour', $messageTemplateFrench['msg_subject']); - $this->assertEquals('Voila!', $messageTemplateFrench['msg_html']); + $result = [/* settings, templates, preferredLanguage, expectMessage */]; - $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ - 'workflow' => 'contribution_online_receipt', - 'tokenContext' => [ - 'contactId' => $this->ids['Contact']['individual_0'], - 'contributionId' => $contributionID, - ], - ]); - $this->assertEquals('Bonjour', $rendered['subject']); - $this->assertEquals('Voila!', $rendered['html']); - $this->assertEquals('100,00 $US', $rendered['text']); + $result['fr_FR matches fr_FR (all-tpls; yes-partials)'] = [$yesPartials, $allTemplates, 'fr_FR', $rendered['fr_FR']]; + $result['fr_FR matches fr_FR (all-tpls; no-partials)'] = [$noPartials, $allTemplates, 'fr_FR', $rendered['fr_FR']]; + $result['fr_FR falls back to fr_CA (ltd-tpls; yes-partials)'] = [$yesPartials, $onlyTemplates(['*', 'fr_CA']), 'fr_FR', $rendered['fr_CA']]; + $result['fr_FR falls back to fr_CA (ltd-tpls; no-partials)'] = [$noPartials, $onlyTemplates(['*', 'fr_CA']), 'fr_FR', $rendered['fr_CA']]; - // French Canadian should ALSO pick up French if there - //is no specific French Canadian. - $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ - 'workflow' => 'contribution_online_receipt', - 'tokenContext' => [ - 'contactId' => $this->ids['Contact']['individual_0'], - 'contributionId' => $contributionID, - ], - 'language' => 'fr_CA', - ]); - $this->assertEquals('Bonjour', $rendered['subject']); - $this->assertEquals('Voila!', $rendered['html']); - // Money is formatted per fr_FR locale as that is the found-template-locale. - $this->assertEquals('100,00 $US', $rendered['text']); + $result['fr_CA matches fr_CA (all-tpls; yes-partials)'] = [$yesPartials, $allTemplates, 'fr_CA', $rendered['fr_CA']]; + $result['fr_CA matches fr_CA (all-tpls; no-partials)'] = [$noPartials, $allTemplates, 'fr_CA', $rendered['fr_CA']]; + $result['fr_CA falls back to fr_FR (ltd-tpls; yes-partials)'] = [$yesPartials, $onlyTemplates(['*', 'fr_FR']), 'fr_CA', $rendered['fr_FR']]; + $result['fr_CA falls back to fr_FR (ltd-tpls; no-partials)'] = [$noPartials, $onlyTemplates(['*', 'fr_FR']), 'fr_CA', $rendered['fr_FR']]; - Translation::save()->setRecords([ - ['entity_field' => 'msg_subject', 'string' => 'Bonjour Canada'], - ['entity_field' => 'msg_html', 'string' => 'Voila! Canada'], - ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], - ])->setDefaults([ - 'entity_table' => 'civicrm_msg_template', - 'entity_id' => $messageTemplateID, - 'status_id:name' => 'active', - 'language' => 'fr_CA', - ])->execute(); + $result['th_TH matches th_TH (all-tpls; yes-partials)'] = [$yesPartials, $allTemplates, 'th_TH', $rendered['th_TH']]; + $result['th_TH falls back to system default (all-tpls; no-partials)'] = [$noPartials, $allTemplates, 'th_TH', $rendered['*']]; + // ^^ The essence of the `partial_locales` setting -- whether partially-supported locales (th_TH) use mixed-mode or fallback to completely diff locale. + $result['th_TH falls back to system default (ltd-tpls; yes-partials)'] = [$yesPartials, $onlyTemplates(['*']), 'th_TH', $rendered['*']]; + $result['th_TH falls back to system default (ltd-tpls; no-partials)'] = [$noPartials, $onlyTemplates(['*']), 'th_TH', $rendered['*']]; - // But, prefer French Canadian where both exist. - $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ - 'workflow' => 'contribution_online_receipt', - 'tokenContext' => [ - 'contactId' => $this->ids['Contact']['individual_0'], - 'contributionId' => $contributionID, - ], - 'language' => 'fr_CA', - ]); - $this->assertEquals('Bonjour Canada', $rendered['subject']); - $this->assertEquals('Voila! Canada', $rendered['html']); - // Note that as there was a native-Canada format the money-formatting is - // also subtly different. - $this->assertEquals('100,00 $ US', $rendered['text']); + return $result; } /** * Test that translated strings are rendered for templates where they exist. * - * This system has a relatively closed localization policy where translations will only be - * used if the locale is fully supported by the app. Ex: Even though there are some strings - * for 'fr_FR', the language in "Admin=>Localizaton", so we don't use it. + * This system has a relatively open localization policy where any translation can be used, + * even if the system doesn't allow it in the web UI. Ex: The sysadmin has configured 'fr_FR' + * strings. The user has requested 'fr_CA', and we'll fallback to 'fr_CA'. * * @throws \API_Exception|\CRM_Core_Exception * @group locale + * @dataProvider getLocaleConfigurations */ - public function testRenderTranslatedTemplate_OnlyFullLocales(): void { - $cleanup = \CRM_Utils_AutoClean::swapSettings(['partial_locales' => FALSE, 'uiLanguages' => ['en_US']]); + public function testRenderTranslatedTemplate($settings, $templates, $preferredLanguage, $expectRendered): void { + if (empty($settings['partial_locales']) && count(\CRM_Core_I18n::languages(FALSE)) <= 1) { + $this->markTestIncomplete('Full testing of localization requires l10n data.'); + } + $cleanup = \CRM_Utils_AutoClean::swapSettings($settings); - $this->individualCreate(['preferred_language' => 'fr_FR']); + $this->individualCreate(['preferred_language' => $preferredLanguage]); $contributionID = $this->contributionCreate(['contact_id' => $this->ids['Contact']['individual_0']]); $messageTemplateID = MessageTemplate::get() ->addWhere('is_default', '=', 1) @@ -164,27 +116,43 @@ public function testRenderTranslatedTemplate_OnlyFullLocales(): void { ->addSelect('id') ->execute()->first()['id']; - Translation::save()->setRecords([ - ['entity_field' => 'msg_subject', 'string' => 'Bonjour'], - ['entity_field' => 'msg_html', 'string' => 'Voila!'], - ['entity_field' => 'msg_text', 'string' => '{contribution.total_amount}'], - ])->setDefaults([ - 'entity_table' => 'civicrm_msg_template', - 'entity_id' => $messageTemplateID, - 'status_id:name' => 'active', - 'language' => 'fr_FR', - ])->execute(); + foreach ($templates as $tplLocale => $tplData) { + if ($tplLocale === '*') { + MessageTemplate::update() + ->addWhere('id', '=', $messageTemplateID) + ->setValues([ + 'msg_subject' => $tplData['subject'], + 'msg_html' => $tplData['html'], + 'msg_text' => $tplData['text'], + ]) + ->execute(); + } + else { + Translation::save()->setRecords([ + ['entity_field' => 'msg_subject', 'string' => $tplData['subject']], + ['entity_field' => 'msg_html', 'string' => $tplData['html']], + ['entity_field' => 'msg_text', 'string' => $tplData['text']], + ])->setDefaults([ + 'entity_table' => 'civicrm_msg_template', + 'entity_id' => $messageTemplateID, + 'status_id:name' => 'active', + 'language' => $tplLocale, + ])->execute(); + } + } - $messageTemplateFrench = MessageTemplate::get() + $myMessageTemplate = MessageTemplate::get() ->addWhere('is_default', '=', 1) ->addWhere('workflow_name', '=', 'contribution_online_receipt') - ->addSelect('id', 'msg_subject', 'msg_html') - ->setLanguage('fr_FR') + ->addSelect('id', 'msg_subject', 'msg_html', 'msg_text') + ->setLanguage($preferredLanguage) ->setTranslationMode('fuzzy') ->execute()->first(); - $this->assertStringNotContainsString('Bonjour', $messageTemplateFrench['msg_subject']); - $this->assertStringNotContainsString('Voila!', $messageTemplateFrench['msg_html']); + // In our examples, subject+html are constant values, but text has tokens. + $this->assertEquals($expectRendered['subject'], $myMessageTemplate['msg_subject']); + $this->assertEquals($expectRendered['html'], $myMessageTemplate['msg_html']); + $this->assertNotEquals($expectRendered['text'], $myMessageTemplate['msg_text']); $rendered = CRM_Core_BAO_MessageTemplate::renderTemplate([ 'workflow' => 'contribution_online_receipt', @@ -193,9 +161,10 @@ public function testRenderTranslatedTemplate_OnlyFullLocales(): void { 'contributionId' => $contributionID, ], ]); - $this->assertStringNotContainsString('Bonjour', $rendered['subject']); - $this->assertStringNotContainsString('Voila!', $rendered['html']); - $this->assertStringNotContainsString('100,00', $rendered['text']); + $this->assertEquals( + CRM_Utils_Array::subset($expectRendered, ['subject', 'html', 'text']), + CRM_Utils_Array::subset($rendered, ['subject', 'html', 'text']) + ); } /** From 669dcbfd48e25bbd59b4d83350abfef168f0273a Mon Sep 17 00:00:00 2001 From: Eileen McNaughton Date: Fri, 2 Sep 2022 09:32:00 +1200 Subject: [PATCH 10/10] Fix UI bug --- ext/message_admin/ang/crmMsgadm/Preview.js | 1 - 1 file changed, 1 deletion(-) diff --git a/ext/message_admin/ang/crmMsgadm/Preview.js b/ext/message_admin/ang/crmMsgadm/Preview.js index 1b202c577d57..5c39039c5c7d 100644 --- a/ext/message_admin/ang/crmMsgadm/Preview.js +++ b/ext/message_admin/ang/crmMsgadm/Preview.js @@ -89,7 +89,6 @@ var filteredData = model.filterData ? model.filterData(exampleData) : exampleData; return crmApi4('WorkflowMessage', 'render', { language: $ctrl.lang, - translationLanguage: $ctrl.lang, workflow: filteredData.workflow, values: filteredData.modelProps, messageTemplate: model.revisions[$ctrl.revisionId].rec