From 41dd50dfbdb688069133afe0cffc904a3d9e7ce8 Mon Sep 17 00:00:00 2001 From: Ravi-topchunks Date: Wed, 4 Mar 2026 17:08:10 +0000 Subject: [PATCH 1/4] added loop email tokens for CustomObjectsBundle which helps to send emails with the list of custom items from an Object --- DTO/LoopToken.php | 116 +++++++++++++++++++++++++++ EventListener/TokenSubscriber.php | 127 ++++++++++++++++++++++++++---- Helper/TokenParser.php | 103 +++++++++++++++++++++++- LoopEmailTokens.md | 86 ++++++++++++++++++++ 4 files changed, 411 insertions(+), 21 deletions(-) create mode 100644 DTO/LoopToken.php create mode 100644 LoopEmailTokens.md diff --git a/DTO/LoopToken.php b/DTO/LoopToken.php new file mode 100644 index 000000000..1c3a5c868 --- /dev/null +++ b/DTO/LoopToken.php @@ -0,0 +1,116 @@ +token = $token; + } + + public function getLoopContent(): string + { + return $this->loopContent; + } + + public function setLoopContent(string $loopContent): void + { + $this->loopContent = $loopContent; + } + + public function getLoopContentTokens(): array + { + return $this->loopContentTokens; + } + + public function addLoopContentToken($loopContentToken, $contentTokenParams): void + { + $this->loopContentTokens[$loopContentToken] = $contentTokenParams; + } + + public function setLoopContentTokens(array $loopContentTokens): void + { + $this->loopContentTokens = $loopContentTokens; + } + public function getOrder(): string + { + return $this->order; + } + + public function setOrder(string $order): void + { + $this->order = $order; + } + + public function getWhere(): string + { + return $this->where; + } + + public function setWhere(string $where): void + { + $this->where = $where; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function setLimit(int $limit): void + { + $this->limit = $limit; + } + + public function getToken(): string + { + return $this->token; + } + + public function getCustomObjectAlias(): string + { + return $this->customObjectAlias; + } + + public function setCustomObjectAlias(string $customObjectAlias): void + { + $this->customObjectAlias = $customObjectAlias; + } +} diff --git a/EventListener/TokenSubscriber.php b/EventListener/TokenSubscriber.php index 61b7cb8b6..c00d88563 100644 --- a/EventListener/TokenSubscriber.php +++ b/EventListener/TokenSubscriber.php @@ -17,6 +17,7 @@ use Mautic\LeadBundle\Segment\OperatorOptions; use MauticPlugin\CustomObjectsBundle\CustomItemEvents; use MauticPlugin\CustomObjectsBundle\CustomObjectEvents; +use MauticPlugin\CustomObjectsBundle\DTO\LoopToken; use MauticPlugin\CustomObjectsBundle\DTO\TableConfig; use MauticPlugin\CustomObjectsBundle\DTO\Token; use MauticPlugin\CustomObjectsBundle\Entity\CustomField; @@ -60,8 +61,7 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private TokenFormatter $tokenFormatter, private int $leadCustomItemFetchLimit - ) { - } + ) {} /** * {@inheritdoc} @@ -95,12 +95,28 @@ public function onBuilderBuild(BuilderEvent $event): void $this->tokenParser->buildTokenWithDefaultOptions($customObject->getAlias(), 'name'), $this->tokenParser->buildTokenLabel($customObject->getName(), 'Name') ); + + $event->addToken( + $this->tokenParser->buildTokenCustomObjectLoop($customObject->getAlias()), + $this->tokenParser->buildTokenCustomObjectLoopLabel($customObject->getName()) + ); + + $event->addToken( + $this->tokenParser->buildTokenCustomObjectFieldInLoop($customObject->getAlias(), 'name'), + $this->tokenParser->buildTokenCustomObjectFieldInLoopLabel($customObject->getName(), 'Name') + ); + /** @var CustomField $customField */ foreach ($customObject->getCustomFields() as $customField) { $event->addToken( $this->tokenParser->buildTokenWithDefaultOptions($customObject->getAlias(), $customField->getAlias()), $this->tokenParser->buildTokenLabel($customObject->getName(), $customField->getLabel()) ); + + $event->addToken( + $this->tokenParser->buildTokenCustomObjectFieldInLoop($customObject->getAlias(), $customField->getAlias()), + $this->tokenParser->buildTokenCustomObjectFieldInLoopLabel($customObject->getName(), $customField->getLabel()) + ); } } } @@ -111,6 +127,31 @@ public function decodeTokens(EmailSendEvent $event): void return; } + $this->addDefaultTokens($event); + $this->addCustomObjectLoopTokens($event); + } + + public function addCustomObjectLoopTokens(EmailSendEvent $event): void + { + $tokens = $this->tokenParser->findCustomObjectLoopTokens($event->getContent()); + + if (0 === $tokens->count()) { + return; + } + $tokens->map(function (LoopToken $token) use ($event): void { + try { + $customObject = $this->customObjectModel->fetchEntityByAlias($token->getCustomObjectAlias()); + $tokenContent = $this->getLoopTokenContent($customObject, $token, $event); + } catch (NotFoundException $e) { + $tokenContent = ''; + } + + $event->addToken($token->getToken(), $tokenContent); + }); + } + + public function addDefaultTokens(EmailSendEvent $event): void + { $tokens = $this->tokenParser->findTokens($event->getContent()); if (0 === $tokens->count()) { @@ -120,8 +161,8 @@ public function decodeTokens(EmailSendEvent $event): void $tokens->map(function (Token $token) use ($event): void { try { $customObject = $this->customObjectModel->fetchEntityByAlias($token->getCustomObjectAlias()); - $fieldValues = $this->getCustomFieldValues($customObject, $token, $event); - } catch (NotFoundException) { + $fieldValues = $this->getCustomFieldValues($customObject, $token, $event); + } catch (NotFoundException $e) { $fieldValues = null; } @@ -140,7 +181,7 @@ public function decodeTokens(EmailSendEvent $event): void $result = $formatEvent->hasBeenFormatted() ? $formatEvent->getFormattedString() : $this->tokenFormatter->format($fieldValues, TokenFormatter::DEFAULT_FORMAT); - } catch (InvalidCustomObjectFormatListException) { + } catch (InvalidCustomObjectFormatListException $e) { $result = $this->tokenFormatter->format($fieldValues, TokenFormatter::DEFAULT_FORMAT); } } else { @@ -152,6 +193,58 @@ public function decodeTokens(EmailSendEvent $event): void }); } + public function getLoopTokenContent(CustomObject $customObject, LoopToken $token, EmailSendEvent $event): string + { + + $loopTokenContent = ''; + $orderBy = CustomItem::TABLE_ALIAS . '.id'; + $orderDir = 'DESC'; + + if ('latest' === $token->getOrder()) { + // There is no other ordering option implemented at the moment. + // Use the default order and direction. + } + + $tableConfig = new TableConfig($token->getLimit(), 1, $orderBy, $orderDir); + $tableConfig->addParameter('customObjectId', $customObject->getId()); + $tableConfig->addParameter('filterEntityType', 'contact'); + $tableConfig->addParameter('filterEntityId', (int) $event->getLead()['id']); + $tableConfig->addParameter('token', $token); + $tableConfig->addParameter('email', $event->getEmail()); + $tableConfig->addParameter('source', $event->getSource()); + $customItems = $this->customItemModel->getArrayTableData($tableConfig); + + foreach ($customItems as $customItemData) { + $loopTokenContent .= $token->getLoopContent(); + $customItem = new CustomItem($customObject); + $customItem->populateFromArray($customItemData); + $customItem = $this->customItemModel->populateCustomFields($customItem); + + foreach ($token->getLoopContentTokens() as $loopContentToken => $loopContentTokenParams) { + $field = $loopContentTokenParams['field']; + + if ('name' === $field) { + $fieldValue = $customItemData['name']; + } else { + $customFieldValue = $customItem->findCustomFieldValueForFieldAlias($field); + if ($customFieldValue) { + $fieldValue = $customFieldValue->getValue(); + } + } + + if (empty($fieldValue)) { + $fieldValue = $loopContentTokenParams['default']; + } + + $fieldValue = (string) $fieldValue; + + $loopTokenContent = str_replace($loopContentToken, $fieldValue, $loopTokenContent); + } + } + + return $loopTokenContent; + } + /** * Add some where conditions to the query requesting the right custom items for the token replacement. * @@ -199,14 +292,14 @@ public function onListQuery(CustomItemListDbalQueryEvent $event): void foreach ($segmentFilters as $id => $filter) { try { - $queryAlias = 'filter_'.$id; + $queryAlias = 'filter_' . $id; $innerQueryBuilder = $this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($filter, $queryAlias); } catch (InvalidSegmentFilterException) { continue; } foreach ($innerQueryBuilder as $segmentQueryBuilder) { - $segmentQueryBuilder->select($queryAlias.'_value.custom_item_id'); + $segmentQueryBuilder->select($queryAlias . '_value.custom_item_id'); $this->queryFilterHelper->addContactIdRestriction($segmentQueryBuilder, $queryAlias, $contactId); $segmentQueryBuilder->andWhere("{$queryAlias}_contact.custom_item_id = {$queryAlias}_value.custom_item_id"); } @@ -215,7 +308,7 @@ public function onListQuery(CustomItemListDbalQueryEvent $event): void CustomItem::TABLE_ALIAS, "({$innerQueryBuilder->getSQL()})", $queryAlias, - CustomItem::TABLE_ALIAS.".id = {$queryAlias}.custom_item_id" + CustomItem::TABLE_ALIAS . ".id = {$queryAlias}.custom_item_id" ); $this->copyParams($innerQueryBuilder, $queryBuilder); @@ -231,7 +324,7 @@ public function onListQuery(CustomItemListDbalQueryEvent $event): void */ private function getCustomFieldValues(CustomObject $customObject, Token $token, EmailSendEvent $event): array { - $orderBy = CustomItem::TABLE_ALIAS.'.id'; + $orderBy = CustomItem::TABLE_ALIAS . '.id'; $orderDir = 'DESC'; if ('latest' === $token->getOrder()) { @@ -307,7 +400,7 @@ public function onTokenReplacement(TokenReplacementEvent $event): void } if ($isCustomObject) { - $event->addToken('{dynamiccontent="'.$data['tokenName'].'"}', $filterContent); + $event->addToken('{dynamiccontent="' . $data['tokenName'] . '"}', $filterContent); } } } @@ -342,7 +435,7 @@ private function getCustomFieldDataForLead(array $filters, string $leadId): arra continue; } - $key = $customObject->getId().'-'.$leadId; + $key = $customObject->getId() . '-' . $leadId; if (!isset($cachedCustomItems[$key])) { $cachedCustomItems[$key] = $this->getCustomItems($customObject, $leadId); } @@ -350,7 +443,7 @@ private function getCustomFieldDataForLead(array $filters, string $leadId): arra $result = $this->getCustomFieldValue($customObject, $fieldAlias, $cachedCustomItems[$key]); $customFieldValues[$condition['field']] = $result; - } catch (NotFoundException|InvalidCustomObjectFormatListException) { + } catch (NotFoundException | InvalidCustomObjectFormatListException) { continue; } } @@ -408,7 +501,7 @@ private function getCustomFieldValue( */ private function getCustomItems(CustomObject $customObject, string $leadId): array { - $orderBy = CustomItem::TABLE_ALIAS.'.id'; + $orderBy = CustomItem::TABLE_ALIAS . '.id'; $orderDir = 'DESC'; $tableConfig = new TableConfig($this->leadCustomItemFetchLimit, 1, $orderBy, $orderDir); @@ -553,12 +646,12 @@ protected function matchFilterForLeadInCustomObject(array $filter, array $lead): break; case 'like': $matchVal = str_replace(['.', '*', '%'], ['\.', '\*', '.*'], $filterVal); - $groups[$groupNum] = 1 === preg_match('/'.$matchVal.'/', $leadVal); + $groups[$groupNum] = 1 === preg_match('/' . $matchVal . '/', $leadVal); break; case '!like': $matchVal = str_replace(['.', '*'], ['\.', '\*'], $filterVal); $matchVal = str_replace('%', '.*', $matchVal); - $groups[$groupNum] = 1 !== preg_match('/'.$matchVal.'/', $leadVal); + $groups[$groupNum] = 1 !== preg_match('/' . $matchVal . '/', $leadVal); break; case OperatorOptions::IN: $groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, false); @@ -567,10 +660,10 @@ protected function matchFilterForLeadInCustomObject(array $filter, array $lead): $groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, true); break; case 'regexp': - $groups[$groupNum] = 1 === preg_match('/'.$filterVal.'/i', $leadVal); + $groups[$groupNum] = 1 === preg_match('/' . $filterVal . '/i', $leadVal); break; case '!regexp': - $groups[$groupNum] = 1 !== preg_match('/'.$filterVal.'/i', $leadVal); + $groups[$groupNum] = 1 !== preg_match('/' . $filterVal . '/i', $leadVal); break; case 'startsWith': $groups[$groupNum] = str_starts_with($leadVal, $filterVal); diff --git a/Helper/TokenParser.php b/Helper/TokenParser.php index f97d67f9c..94e1382aa 100644 --- a/Helper/TokenParser.php +++ b/Helper/TokenParser.php @@ -4,18 +4,22 @@ namespace MauticPlugin\CustomObjectsBundle\Helper; -use Doctrine\Common\Collections\ArrayCollection; use MauticPlugin\CustomObjectsBundle\DTO\Token; +use Doctrine\Common\Collections\ArrayCollection; +use MauticPlugin\CustomObjectsBundle\DTO\LoopToken; class TokenParser { public const TOKEN = '{custom-object=(.*?)}'; + public const TOKEN_CUSTOM_OBJECT_LOOP = '{custom-object-loop\s+([^}]*)\}([\s\S]*?)\{\/custom-object-loop\}'; + public const TOKEN_CUSTOM_OBJECT_LOOP_VALUE = '{custom-object-loop-value\s+([^}]*)\}'; + public function findTokens(string $content): ArrayCollection { $tokens = new ArrayCollection(); - preg_match_all('/'.self::TOKEN.'/', $content, $matches); + preg_match_all('/' . self::TOKEN . '/', $content, $matches); if (empty($matches[1])) { return $tokens; @@ -27,7 +31,7 @@ public function findTokens(string $content): ArrayCollection try { $this->extractAliases($parts[0], $token); - } catch (\LengthException) { + } catch (\LengthException $e) { // Invalid token, pretend like we did not see it. continue; } @@ -69,11 +73,102 @@ public function findTokens(string $content): ArrayCollection return $tokens; } + public function findCustomObjectLoopTokens(string $content): ArrayCollection + { + $tokens = new ArrayCollection(); + + preg_match_all('/' . self::TOKEN_CUSTOM_OBJECT_LOOP . '/', $content, $matches); + + if (empty($matches[1])) { + return $tokens; + } + + foreach ($matches[1] as $key => $loopParams) { + $loopToken = new LoopToken($matches[0][$key]); + $rawParams = $this->getPartsDividedByPipe($loopParams); + foreach ($rawParams as $rawParam) { + $options = $this->trimArrayElements(explode('=', $rawParam)); + $keyword = $options[0]; + $value = $options[1]; + + if ('object' === $keyword) { + $loopToken->setCustomObjectAlias($value); + } + + if ('where' === $keyword) { + $loopToken->setWhere($value); + } + + if ('order' === $keyword) { + $loopToken->setOrder($value); + } + + if ('limit' === $keyword) { + $loopToken->setLimit((int) $value); + } + } + + $loopContent = $matches[2][$key] ?? ''; + $loopToken->setLoopContent($loopContent); + preg_match_all('/' . self::TOKEN_CUSTOM_OBJECT_LOOP_VALUE . '/', $loopContent, $fieldMatches); + + foreach ($fieldMatches[1] as $key => $fieldMatch) { + $contentToken = $fieldMatches[0][$key]; + $rawFieldParams = $this->getPartsDividedByPipe($fieldMatch); + $contentTokenParams = []; + foreach ($rawFieldParams as $rawFieldParam) { + $options = $this->trimArrayElements(explode('=', $rawFieldParam)); + $keyword = $options[0]; + $value = $options[1]; + + if ('object' === $keyword && $loopToken->getCustomObjectAlias() !== $value) { + continue; + } + + if ('field' === $keyword) { + $contentTokenParams['field'] = $value; + } + + if ('default' === $keyword) { + $contentTokenParams['default'] = $value; + } + } + + $loopToken->addLoopContentToken($contentToken, $contentTokenParams); + } + + $tokens->set($loopToken->getToken(), $loopToken); + } + + return $tokens; + } + public function buildTokenWithDefaultOptions(string $customObjectAlias, string $customFieldAlias): string { return "{custom-object={$customObjectAlias}:{$customFieldAlias} | where=segment-filter | order=latest | limit=1 | default= | format=default}"; } + public function buildTokenCustomObjectLoop(string $customObjectAlias): string + { + return "{custom-object-loop object={$customObjectAlias} | where=segment-filter | order=latest | limit=1}\nThe content added here will be repeated for each item in the custom object.\n{/custom-object-loop}"; + } + + public function buildTokenCustomObjectFieldInLoop(string $customObjectAlias, string $customFieldAlias): string + { + return "{custom-object-loop-value object={$customObjectAlias} | field={$customFieldAlias} | default=}"; + } + + + public function buildTokenCustomObjectFieldInLoopLabel(string $customObjectLabel, string $customFieldLabel): string + { + return "{$customObjectLabel}: {$customFieldLabel} in Loop"; + } + + public function buildTokenCustomObjectLoopLabel(string $customObjectLabel): string + { + return "{$customObjectLabel}: For Loop"; + } + public function buildTokenLabel(string $customObjectName, string $customFieldLabel): string { return "{$customObjectName}: {$customFieldLabel}"; @@ -109,7 +204,7 @@ private function getPartsDividedByPipe(string $tokenDataRaw): array private function trimArrayElements(array $array): array { return array_map( - function ($part): string { + function ($part) { return trim($part); }, $array diff --git a/LoopEmailTokens.md b/LoopEmailTokens.md new file mode 100644 index 000000000..c39d4c472 --- /dev/null +++ b/LoopEmailTokens.md @@ -0,0 +1,86 @@ +## Custom object loop tokens + +Custom items can be rendered in emails (or other content) using loop tokens. + +### Basic syntax + +```text +{custom-object-loop object=product-views | where=segment-filter | order=latest | limit=10} + {custom-object-loop-value object=product-views | field=name | default=jhon} +{/custom-object-loop} +``` + +- **`{custom-object-loop ...}`**: Opens the loop and defines which custom items to load. +- **`{/custom-object-loop}`**: Closes the loop. Everything between the opening and closing tags is repeated for each matching custom item. +- **HTML inside the loop**: You can place any HTML (or other tokens) between the loop tags; that block will be repeated. + +### Loop parameters (`custom-object-loop`) + +- **object**: Custom object name (e.g. `product-views`). Required. +- **where**: + - Only `segment-filter` is implemented so far (and it is the default if omitted). + - It takes the first segment of a segment email, or the first campaign source segment of a campaign email. + - All custom-object-related filters from that segment are converted into `WHERE` conditions. +- **order**: + - Only `latest` is supported (and is the default if omitted). + - Items are ordered from newest to oldest by their creation date. +- **limit**: + - Default is `1` if not provided. + - If greater than `1`, multiple items are rendered. + +### Loop-value parameters (`custom-object-loop-value`) + +- **object**: Must match the `object` used in the surrounding `custom-object-loop`. If it does not match, it is ignored. +- **field**: Field alias of the custom object whose value should be rendered (for example `name`, `price`, `color`). +- **default**: Fallback value if the field is empty (for example `default=Unknown`). + +### Examples + +**List product names bought by the contact:** + +```text +{custom-object-loop object=product-views | where=segment-filter | order=latest | limit=3} + {custom-object-loop-value object=product-views | field=name | default=Unknown product}
+{/custom-object-loop} +``` + +Rendered example: + +```html +Mautic T-shirt
+Mautic Hoodie
+Mautic Cap
+``` + +**Render a small HTML block per product:** + +```html +{custom-object-loop object=product-views | where=segment-filter | order=latest | limit=2} +
+ {custom-object-loop-value object=product-views | field=name | default=Unknown}
+ Price: {custom-object-loop-value object=product-views | field=price | default=0}
+ Color: {custom-object-loop-value object=product-views | field=color | default=Unknown} +
+{/custom-object-loop} +``` + +Rendered HTML (for 2 products): + +```html +
+ Mautic T-shirt
+ Price: 123
+ Color: Blue +
+
+ Mautic Hoodie
+ Price: 153
+ Color: Red +
+``` + +### Warnings and limitations + +- **Multiple segments**: If multiple segments are used for segment emails or campaign sources, each segment must have identical custom object filters. Only the filters of the **first** segment are used during token replacement when sending emails. +- **No OR conditions**: Do not use `OR` conditions in the source segments for custom object filters. +- **No include/exclude membership filters**: Do not use include/exclude segment membership filters when relying on these tokens. Token replacement only considers filters on the root segment, not on all included/excluded segments. From 024d459f718d2d838ecd2f85cdb981508d03394b Mon Sep 17 00:00:00 2001 From: Ravi-topchunks Date: Mon, 16 Mar 2026 10:51:50 +0000 Subject: [PATCH 2/4] bug fixes --- DTO/LoopToken.php | 33 +++++------------- LoopEmailTokens.md | 86 ---------------------------------------------- 2 files changed, 9 insertions(+), 110 deletions(-) delete mode 100644 LoopEmailTokens.md diff --git a/DTO/LoopToken.php b/DTO/LoopToken.php index 1c3a5c868..6b7e511b0 100644 --- a/DTO/LoopToken.php +++ b/DTO/LoopToken.php @@ -9,36 +9,21 @@ * * {custom-object=product:sku | where=segment-filter |order=latest|limit=1 | default=Nothing to see here | format=or-list} */ -class LoopToken +final class LoopToken { - /** - * @var string - */ - private $token; + private string $token; - /** - * @var int - */ - private $limit = 1; + private int $limit = 1; - /** - * @var string - */ - private $where = ''; + private string $where = ''; - /** - * @var string - */ - private $order = 'latest'; + private string $order = 'latest'; - /** - * @var string - */ - private $customObjectAlias = ''; + private string $customObjectAlias = ''; - private $loopContentTokens = []; + private array $loopContentTokens = []; - private $loopContent = ''; + private string $loopContent = ''; public function __construct(string $token) { @@ -60,7 +45,7 @@ public function getLoopContentTokens(): array return $this->loopContentTokens; } - public function addLoopContentToken($loopContentToken, $contentTokenParams): void + public function addLoopContentToken(string $loopContentToken, array $contentTokenParams): void { $this->loopContentTokens[$loopContentToken] = $contentTokenParams; } diff --git a/LoopEmailTokens.md b/LoopEmailTokens.md deleted file mode 100644 index c39d4c472..000000000 --- a/LoopEmailTokens.md +++ /dev/null @@ -1,86 +0,0 @@ -## Custom object loop tokens - -Custom items can be rendered in emails (or other content) using loop tokens. - -### Basic syntax - -```text -{custom-object-loop object=product-views | where=segment-filter | order=latest | limit=10} - {custom-object-loop-value object=product-views | field=name | default=jhon} -{/custom-object-loop} -``` - -- **`{custom-object-loop ...}`**: Opens the loop and defines which custom items to load. -- **`{/custom-object-loop}`**: Closes the loop. Everything between the opening and closing tags is repeated for each matching custom item. -- **HTML inside the loop**: You can place any HTML (or other tokens) between the loop tags; that block will be repeated. - -### Loop parameters (`custom-object-loop`) - -- **object**: Custom object name (e.g. `product-views`). Required. -- **where**: - - Only `segment-filter` is implemented so far (and it is the default if omitted). - - It takes the first segment of a segment email, or the first campaign source segment of a campaign email. - - All custom-object-related filters from that segment are converted into `WHERE` conditions. -- **order**: - - Only `latest` is supported (and is the default if omitted). - - Items are ordered from newest to oldest by their creation date. -- **limit**: - - Default is `1` if not provided. - - If greater than `1`, multiple items are rendered. - -### Loop-value parameters (`custom-object-loop-value`) - -- **object**: Must match the `object` used in the surrounding `custom-object-loop`. If it does not match, it is ignored. -- **field**: Field alias of the custom object whose value should be rendered (for example `name`, `price`, `color`). -- **default**: Fallback value if the field is empty (for example `default=Unknown`). - -### Examples - -**List product names bought by the contact:** - -```text -{custom-object-loop object=product-views | where=segment-filter | order=latest | limit=3} - {custom-object-loop-value object=product-views | field=name | default=Unknown product}
-{/custom-object-loop} -``` - -Rendered example: - -```html -Mautic T-shirt
-Mautic Hoodie
-Mautic Cap
-``` - -**Render a small HTML block per product:** - -```html -{custom-object-loop object=product-views | where=segment-filter | order=latest | limit=2} -
- {custom-object-loop-value object=product-views | field=name | default=Unknown}
- Price: {custom-object-loop-value object=product-views | field=price | default=0}
- Color: {custom-object-loop-value object=product-views | field=color | default=Unknown} -
-{/custom-object-loop} -``` - -Rendered HTML (for 2 products): - -```html -
- Mautic T-shirt
- Price: 123
- Color: Blue -
-
- Mautic Hoodie
- Price: 153
- Color: Red -
-``` - -### Warnings and limitations - -- **Multiple segments**: If multiple segments are used for segment emails or campaign sources, each segment must have identical custom object filters. Only the filters of the **first** segment are used during token replacement when sending emails. -- **No OR conditions**: Do not use `OR` conditions in the source segments for custom object filters. -- **No include/exclude membership filters**: Do not use include/exclude segment membership filters when relying on these tokens. Token replacement only considers filters on the root segment, not on all included/excluded segments. From fbdccd74a14adb796cc546c8f18230d86675f887 Mon Sep 17 00:00:00 2001 From: Ravi-topchunks Date: Tue, 31 Mar 2026 13:19:53 +0000 Subject: [PATCH 3/4] fixed coding style violations --- DTO/LoopToken.php | 1 + EventListener/TokenSubscriber.php | 34 +++++++++++++++---------------- Helper/TokenParser.php | 15 +++++++------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/DTO/LoopToken.php b/DTO/LoopToken.php index 6b7e511b0..da5fef9a7 100644 --- a/DTO/LoopToken.php +++ b/DTO/LoopToken.php @@ -54,6 +54,7 @@ public function setLoopContentTokens(array $loopContentTokens): void { $this->loopContentTokens = $loopContentTokens; } + public function getOrder(): string { return $this->order; diff --git a/EventListener/TokenSubscriber.php b/EventListener/TokenSubscriber.php index c00d88563..8a5c1fe2e 100644 --- a/EventListener/TokenSubscriber.php +++ b/EventListener/TokenSubscriber.php @@ -61,7 +61,8 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private TokenFormatter $tokenFormatter, private int $leadCustomItemFetchLimit - ) {} + ) { + } /** * {@inheritdoc} @@ -161,7 +162,7 @@ public function addDefaultTokens(EmailSendEvent $event): void $tokens->map(function (Token $token) use ($event): void { try { $customObject = $this->customObjectModel->fetchEntityByAlias($token->getCustomObjectAlias()); - $fieldValues = $this->getCustomFieldValues($customObject, $token, $event); + $fieldValues = $this->getCustomFieldValues($customObject, $token, $event); } catch (NotFoundException $e) { $fieldValues = null; } @@ -195,10 +196,9 @@ public function addDefaultTokens(EmailSendEvent $event): void public function getLoopTokenContent(CustomObject $customObject, LoopToken $token, EmailSendEvent $event): string { - $loopTokenContent = ''; - $orderBy = CustomItem::TABLE_ALIAS . '.id'; - $orderDir = 'DESC'; + $orderBy = CustomItem::TABLE_ALIAS.'.id'; + $orderDir = 'DESC'; if ('latest' === $token->getOrder()) { // There is no other ordering option implemented at the moment. @@ -292,14 +292,14 @@ public function onListQuery(CustomItemListDbalQueryEvent $event): void foreach ($segmentFilters as $id => $filter) { try { - $queryAlias = 'filter_' . $id; + $queryAlias = 'filter_'.$id; $innerQueryBuilder = $this->queryFilterFactory->configureQueryBuilderFromSegmentFilter($filter, $queryAlias); } catch (InvalidSegmentFilterException) { continue; } foreach ($innerQueryBuilder as $segmentQueryBuilder) { - $segmentQueryBuilder->select($queryAlias . '_value.custom_item_id'); + $segmentQueryBuilder->select($queryAlias.'_value.custom_item_id'); $this->queryFilterHelper->addContactIdRestriction($segmentQueryBuilder, $queryAlias, $contactId); $segmentQueryBuilder->andWhere("{$queryAlias}_contact.custom_item_id = {$queryAlias}_value.custom_item_id"); } @@ -308,7 +308,7 @@ public function onListQuery(CustomItemListDbalQueryEvent $event): void CustomItem::TABLE_ALIAS, "({$innerQueryBuilder->getSQL()})", $queryAlias, - CustomItem::TABLE_ALIAS . ".id = {$queryAlias}.custom_item_id" + CustomItem::TABLE_ALIAS.".id = {$queryAlias}.custom_item_id" ); $this->copyParams($innerQueryBuilder, $queryBuilder); @@ -324,7 +324,7 @@ public function onListQuery(CustomItemListDbalQueryEvent $event): void */ private function getCustomFieldValues(CustomObject $customObject, Token $token, EmailSendEvent $event): array { - $orderBy = CustomItem::TABLE_ALIAS . '.id'; + $orderBy = CustomItem::TABLE_ALIAS.'.id'; $orderDir = 'DESC'; if ('latest' === $token->getOrder()) { @@ -400,7 +400,7 @@ public function onTokenReplacement(TokenReplacementEvent $event): void } if ($isCustomObject) { - $event->addToken('{dynamiccontent="' . $data['tokenName'] . '"}', $filterContent); + $event->addToken('{dynamiccontent="'.$data['tokenName'].'"}', $filterContent); } } } @@ -435,7 +435,7 @@ private function getCustomFieldDataForLead(array $filters, string $leadId): arra continue; } - $key = $customObject->getId() . '-' . $leadId; + $key = $customObject->getId().'-'.$leadId; if (!isset($cachedCustomItems[$key])) { $cachedCustomItems[$key] = $this->getCustomItems($customObject, $leadId); } @@ -443,7 +443,7 @@ private function getCustomFieldDataForLead(array $filters, string $leadId): arra $result = $this->getCustomFieldValue($customObject, $fieldAlias, $cachedCustomItems[$key]); $customFieldValues[$condition['field']] = $result; - } catch (NotFoundException | InvalidCustomObjectFormatListException) { + } catch (NotFoundException|InvalidCustomObjectFormatListException) { continue; } } @@ -501,7 +501,7 @@ private function getCustomFieldValue( */ private function getCustomItems(CustomObject $customObject, string $leadId): array { - $orderBy = CustomItem::TABLE_ALIAS . '.id'; + $orderBy = CustomItem::TABLE_ALIAS.'.id'; $orderDir = 'DESC'; $tableConfig = new TableConfig($this->leadCustomItemFetchLimit, 1, $orderBy, $orderDir); @@ -646,12 +646,12 @@ protected function matchFilterForLeadInCustomObject(array $filter, array $lead): break; case 'like': $matchVal = str_replace(['.', '*', '%'], ['\.', '\*', '.*'], $filterVal); - $groups[$groupNum] = 1 === preg_match('/' . $matchVal . '/', $leadVal); + $groups[$groupNum] = 1 === preg_match('/'.$matchVal.'/', $leadVal); break; case '!like': $matchVal = str_replace(['.', '*'], ['\.', '\*'], $filterVal); $matchVal = str_replace('%', '.*', $matchVal); - $groups[$groupNum] = 1 !== preg_match('/' . $matchVal . '/', $leadVal); + $groups[$groupNum] = 1 !== preg_match('/'.$matchVal.'/', $leadVal); break; case OperatorOptions::IN: $groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, false); @@ -660,10 +660,10 @@ protected function matchFilterForLeadInCustomObject(array $filter, array $lead): $groups[$groupNum] = $this->checkLeadValueIsInFilter($leadVal, $filterVal, true); break; case 'regexp': - $groups[$groupNum] = 1 === preg_match('/' . $filterVal . '/i', $leadVal); + $groups[$groupNum] = 1 === preg_match('/'.$filterVal.'/i', $leadVal); break; case '!regexp': - $groups[$groupNum] = 1 !== preg_match('/' . $filterVal . '/i', $leadVal); + $groups[$groupNum] = 1 !== preg_match('/'.$filterVal.'/i', $leadVal); break; case 'startsWith': $groups[$groupNum] = str_starts_with($leadVal, $filterVal); diff --git a/Helper/TokenParser.php b/Helper/TokenParser.php index 94e1382aa..cb25dbf89 100644 --- a/Helper/TokenParser.php +++ b/Helper/TokenParser.php @@ -4,22 +4,22 @@ namespace MauticPlugin\CustomObjectsBundle\Helper; -use MauticPlugin\CustomObjectsBundle\DTO\Token; use Doctrine\Common\Collections\ArrayCollection; use MauticPlugin\CustomObjectsBundle\DTO\LoopToken; +use MauticPlugin\CustomObjectsBundle\DTO\Token; class TokenParser { public const TOKEN = '{custom-object=(.*?)}'; - public const TOKEN_CUSTOM_OBJECT_LOOP = '{custom-object-loop\s+([^}]*)\}([\s\S]*?)\{\/custom-object-loop\}'; + public const TOKEN_CUSTOM_OBJECT_LOOP = '{custom-object-loop\s+([^}]*)\}([\s\S]*?)\{\/custom-object-loop\}'; public const TOKEN_CUSTOM_OBJECT_LOOP_VALUE = '{custom-object-loop-value\s+([^}]*)\}'; public function findTokens(string $content): ArrayCollection { $tokens = new ArrayCollection(); - preg_match_all('/' . self::TOKEN . '/', $content, $matches); + preg_match_all('/'.self::TOKEN.'/', $content, $matches); if (empty($matches[1])) { return $tokens; @@ -77,7 +77,7 @@ public function findCustomObjectLoopTokens(string $content): ArrayCollection { $tokens = new ArrayCollection(); - preg_match_all('/' . self::TOKEN_CUSTOM_OBJECT_LOOP . '/', $content, $matches); + preg_match_all('/'.self::TOKEN_CUSTOM_OBJECT_LOOP.'/', $content, $matches); if (empty($matches[1])) { return $tokens; @@ -110,11 +110,11 @@ public function findCustomObjectLoopTokens(string $content): ArrayCollection $loopContent = $matches[2][$key] ?? ''; $loopToken->setLoopContent($loopContent); - preg_match_all('/' . self::TOKEN_CUSTOM_OBJECT_LOOP_VALUE . '/', $loopContent, $fieldMatches); + preg_match_all('/'.self::TOKEN_CUSTOM_OBJECT_LOOP_VALUE.'/', $loopContent, $fieldMatches); foreach ($fieldMatches[1] as $key => $fieldMatch) { - $contentToken = $fieldMatches[0][$key]; - $rawFieldParams = $this->getPartsDividedByPipe($fieldMatch); + $contentToken = $fieldMatches[0][$key]; + $rawFieldParams = $this->getPartsDividedByPipe($fieldMatch); $contentTokenParams = []; foreach ($rawFieldParams as $rawFieldParam) { $options = $this->trimArrayElements(explode('=', $rawFieldParam)); @@ -158,7 +158,6 @@ public function buildTokenCustomObjectFieldInLoop(string $customObjectAlias, str return "{custom-object-loop-value object={$customObjectAlias} | field={$customFieldAlias} | default=}"; } - public function buildTokenCustomObjectFieldInLoopLabel(string $customObjectLabel, string $customFieldLabel): string { return "{$customObjectLabel}: {$customFieldLabel} in Loop"; From 6c31ad4f6307682dd5297e7218a8fc928950c5e3 Mon Sep 17 00:00:00 2001 From: Ravi-topchunks Date: Tue, 31 Mar 2026 14:25:15 +0000 Subject: [PATCH 4/4] php stan issues fixed --- DTO/LoopToken.php | 12 ++++++++++++ EventListener/TokenSubscriber.php | 8 +++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/DTO/LoopToken.php b/DTO/LoopToken.php index da5fef9a7..464ab6563 100644 --- a/DTO/LoopToken.php +++ b/DTO/LoopToken.php @@ -21,6 +21,9 @@ final class LoopToken private string $customObjectAlias = ''; + /** + * @var array> + */ private array $loopContentTokens = []; private string $loopContent = ''; @@ -40,16 +43,25 @@ public function setLoopContent(string $loopContent): void $this->loopContent = $loopContent; } + /** + * @return array> + */ public function getLoopContentTokens(): array { return $this->loopContentTokens; } + /** + * @param array $contentTokenParams + */ public function addLoopContentToken(string $loopContentToken, array $contentTokenParams): void { $this->loopContentTokens[$loopContentToken] = $contentTokenParams; } + /** + * @param array> $loopContentTokens + */ public function setLoopContentTokens(array $loopContentTokens): void { $this->loopContentTokens = $loopContentTokens; diff --git a/EventListener/TokenSubscriber.php b/EventListener/TokenSubscriber.php index 8a5c1fe2e..80706b02e 100644 --- a/EventListener/TokenSubscriber.php +++ b/EventListener/TokenSubscriber.php @@ -226,9 +226,11 @@ public function getLoopTokenContent(CustomObject $customObject, LoopToken $token if ('name' === $field) { $fieldValue = $customItemData['name']; } else { - $customFieldValue = $customItem->findCustomFieldValueForFieldAlias($field); - if ($customFieldValue) { - $fieldValue = $customFieldValue->getValue(); + try { + $customFieldValue = $customItem->findCustomFieldValueForFieldAlias($field); + $fieldValue = $customFieldValue->getValue(); + } catch (NotFoundException) { + $fieldValue = null; } }