From 8996a8b6d7f61021373b3cb89c72dc6670cf202e Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 31 Aug 2021 21:21:41 -0700 Subject: [PATCH] TokenProcessor - Allow defining Smarty variables which are opulated from tokens Overview -------- This allow more interoperability between Smarty expressions and tokens. For example, suppose one had a contribution-related message that could use the Smarty variable `$theInvoiceId` and/or the token `{contribution.invoice_id}`. This revision allows the Smarty variable to function as an alias for the token. Before ------ The caller would need to precompute Smarty values, eg ```php $theInvoiceId = civicrm_api4('Contribution', 'get', [ 'select' => 'invoice_id', 'where' => [['id', '=', $contributionId]] ]); $p = new TokenProcessor($this->dispatcher, [ 'controller' => __CLASS__, 'schema' => ['contributionId'], 'smarty' => TRUE, ]); $p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain'); $p->addRow(['contributionId' => 123]); ``` After ----- The caller can declare a Smarty=>Token alias and leverage token data-loader. ```php $p = new TokenProcessor($this->dispatcher, [ 'controller' => __CLASS__, 'schema' => ['contributionId'], 'smarty' => TRUE, 'smartyTokenAlias' => [ 'theInvoiceId' => 'contribution.invoice_id', ], ]); $p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain'); $p->addRow(['contributionId' => 123]); ``` Comments -------- The target token must be populated via `civi.token.eval` (e.g `$e->token('foo', 'bar', 'value')`). This would work with `CRM_*_Tokens`. But if the token is evaluted by other means (eg `CRM_Utils_Token::replaceGreetingTokens()`), then it won't currently be resolved. --- Civi/Token/TokenCompatSubscriber.php | 42 +++++++++++- Civi/Token/TokenProcessor.php | 2 + .../phpunit/Civi/Token/TokenProcessorTest.php | 68 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/Civi/Token/TokenCompatSubscriber.php b/Civi/Token/TokenCompatSubscriber.php index 13a1136460..9d745c1489 100644 --- a/Civi/Token/TokenCompatSubscriber.php +++ b/Civi/Token/TokenCompatSubscriber.php @@ -25,11 +25,37 @@ class TokenCompatSubscriber implements EventSubscriberInterface { */ public static function getSubscribedEvents() { return [ - 'civi.token.eval' => 'onEvaluate', + 'civi.token.eval' => [ + ['setupSmartyAliases', 1000], + ['onEvaluate'], + ], 'civi.token.render' => 'onRender', ]; } + /** + * Interpret the variable `$context['smartyTokenAlias']` (e.g. `mySmartyField' => `tkn_entity.tkn_field`). + * + * We need to ensure that any tokens like `{tkn_entity.tkn_field}` are hydrated, so + * we pretend that they are in use. + * + * @param \Civi\Token\Event\TokenValueEvent $e + */ + public function setupSmartyAliases(TokenValueEvent $e) { + $aliasedTokens = []; + foreach ($e->getRows() as $row) { + $aliasedTokens = array_unique(array_merge($aliasedTokens, + array_values($row->context['smartyTokenAlias'] ?? []))); + } + + $fakeMessage = implode('', array_map(function ($f) { + return '{' . $f . '}'; + }, $aliasedTokens)); + + $proc = $e->getTokenProcessor(); + $proc->addMessage('TokenCompatSubscriber.aliases', $fakeMessage, 'text/plain'); + } + /** * Load token data. * @@ -130,7 +156,19 @@ class TokenCompatSubscriber implements EventSubscriberInterface { } if ($useSmarty) { - $e->string = \CRM_Utils_String::parseOneOffStringThroughSmarty($e->string); + $smartyVars = []; + foreach ($e->context['smartyTokenAlias'] ?? [] as $smartyName => $tokenName) { + // Note: $e->row->tokens resolves event-based tokens (eg CRM_*_Tokens). But if the target token relies on the + // above bits (replaceGreetingTokens=>replaceContactTokens=>replaceHookTokens) then this lookup isn't sufficient. + $smartyVars[$smartyName] = \CRM_Utils_Array::pathGet($e->row->tokens, explode('.', $tokenName)); + } + \CRM_Core_Smarty::singleton()->pushScope($smartyVars); + try { + $e->string = \CRM_Utils_String::parseOneOffStringThroughSmarty($e->string); + } + finally { + \CRM_Core_Smarty::singleton()->popScope(); + } } } diff --git a/Civi/Token/TokenProcessor.php b/Civi/Token/TokenProcessor.php index d434a1a139..61438d2092 100644 --- a/Civi/Token/TokenProcessor.php +++ b/Civi/Token/TokenProcessor.php @@ -49,6 +49,8 @@ class TokenProcessor { * * - controller: string, the class which is managing the mail-merge. * - smarty: bool, whether to enable smarty support. + * - smartyTokenAlias: array, Define Smarty variables that are populated + * based on token-content. Ex: ['theInvoiceId' => 'contribution.invoice_id'] * - contactId: int, the main person/org discussed in the message. * - contact: array, the main person/org discussed in the message. * (Optional for performance tweaking; if omitted, will load diff --git a/tests/phpunit/Civi/Token/TokenProcessorTest.php b/tests/phpunit/Civi/Token/TokenProcessorTest.php index 2a6d3f7e54..26e0da7e7f 100644 --- a/tests/phpunit/Civi/Token/TokenProcessorTest.php +++ b/tests/phpunit/Civi/Token/TokenProcessorTest.php @@ -415,4 +415,72 @@ class TokenProcessorTest extends \CiviUnitTestCase { $this->assertEquals(2, $loops); } + /** + * This defines a compatibility mechanism wherein an old Smarty expression can + * be evaluated based on a newer token expression. + * + * Ex: $tokenContext['oldSmartyVar'] = 'new_entity.new_field'; + */ + public function testSmartyTokenAlias_Contribution() { + $first = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2010-01-01', 'invoice_id' => 100, 'trxn_id' => 1000]); + $second = $this->contributionCreate(['contact_id' => $this->individualCreate(), 'receive_date' => '2011-02-02', 'invoice_id' => 200, 'trxn_id' => 1]); + $this->dispatcher->addSubscriber(new TokenCompatSubscriber()); + $this->dispatcher->addSubscriber(new \CRM_Contribute_Tokens()); + + $p = new TokenProcessor($this->dispatcher, [ + 'controller' => __CLASS__, + 'schema' => ['contributionId'], + 'smarty' => TRUE, + 'smartyTokenAlias' => [ + 'theInvoiceId' => 'contribution.invoice_id', + ], + ]); + $p->addMessage('example', 'Invoice #{$theInvoiceId}!', 'text/plain'); + $p->addRow(['contributionId' => $first]); + $p->addRow(['contributionId' => $second]); + $p->evaluate(); + + $outputs = []; + foreach ($p->getRows() as $row) { + $outputs[] = $row->render('example'); + } + $this->assertEquals('Invoice #100!', $outputs[0]); + $this->assertEquals('Invoice #200!', $outputs[1]); + } + + ///** + // * This defines a compatibility mechanism wherein an old Smarty expression can + // * be evaluated based on a newer token expression. + // * + // * The following example doesn't work because the handling of greeting+contact + // * tokens still use a special override (TokenCompatSubscriber::onRender). + // * + // * Ex: $tokenContext['oldSmartyVar'] = 'new_entity.new_field'; + // */ + // public function testSmartyTokenAlias_Contact() { + // $alice = $this->individualCreate(['first_name' => 'Alice']); + // $bob = $this->individualCreate(['first_name' => 'Bob']); + // $this->dispatcher->addSubscriber(new TokenCompatSubscriber()); + // + // $p = new TokenProcessor($this->dispatcher, [ + // 'controller' => __CLASS__, + // 'schema' => ['contactId'], + // 'smarty' => TRUE, + // 'smartyTokenAlias' => [ + // 'myFirstName' => 'contact.first_name', + // ], + // ]); + // $p->addMessage('example', 'Hello {$myFirstName}!', 'text/plain'); + // $p->addRow(['contactId' => $alice]); + // $p->addRow(['contactId' => $bob]); + // $p->evaluate(); + // + // $outputs = []; + // foreach ($p->getRows() as $row) { + // $outputs[] = $row->render('example'); + // } + // $this->assertEquals('Hello Alice!', $outputs[0]); + // $this->assertEquals('Hello Bob!', $outputs[1]); + // } + } -- 2.25.1