Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
.dwc--generator-content-input {
border: none;
width: 100%;
}
.dwc--generator-toolbar {
background-color: var(--layer);
border-radius: var(--border-radius-sm);
}
.dwc--generator-content-editable {
background-color: var(--layer);
padding: 3px 14px;
border-radius: 28px;
}
.dwc--generator-wrapper {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.dwc--generator-copy {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.dwc--generator-content-code {
white-space: pre-wrap;
}

View File

@@ -0,0 +1,548 @@
Mautic.toggleDwcFilters = function () {
mQuery("#dwcFiltersTab, #slotNameDiv").toggleClass("hide");
if (mQuery("#dwcFiltersTab").hasClass('hide')) {
mQuery('.nav-tabs a[href="#details"]').click();
} else {
Mautic.dynamicContentOnLoad();
}
};
Mautic.dwcGenerator = (function() {
// Selectors
const copyBtnSelector = '#generator-copy-dynamic-content-slot';
const pluginTabSelector = '#dwc--generator-plugins';
const htmlTabSelector = '#dwc--generator-html';
const codeContainerSelector = '.dwc--generator-content-code';
const inputSelector = '.dwc--generator-content-input';
const switchCodeWrapperBtnSelector = '#generator-switch-code-wrapper';
const switchHtmlTagBtnSelector = '#generator-switch-html-tag';
// State variables
let isPluginBracketMode = true; // True means {mautic ...}, false means [mautic ...]
let isUsingDiv = true; // True means using <div> for HTML snippet, false means using <span>
// Determine the active tab (plugin or HTML)
const getActiveTabType = () => {
const activePane = document.querySelector('.tab-pane.active.in') || document.querySelector('.tab-pane.active');
if (!activePane) return 'plugin'; // Default to 'plugin' if none found
return activePane.id === 'dwc--generator-plugins' ? 'plugin' : 'html';
};
const toggleState = (currentState) => !currentState;
// Restore input value after content changes
const updateInputValue = (container, inputValue) => {
const input = container.querySelector(inputSelector);
if (input && inputValue !== null) input.value = inputValue;
};
// Toggle between {mautic ...} and [mautic ...] in the plugin tab
const switchCodeWrapper = () => {
const pluginTab = document.querySelector(pluginTabSelector);
if (!pluginTab) return;
const pre = pluginTab.querySelector(codeContainerSelector);
if (!pre) return;
const input = pluginTab.querySelector(inputSelector);
const inputValue = input ? input.value : '';
let code = pre.innerHTML;
if (isPluginBracketMode) {
// Convert {mautic ...} to [mautic ...]
code = code
.replace(/\{mautic/g, '[mautic')
.replace(/slot=".*?"\}/g, match => match.replace('}', ']')) // Replace } with ] in the first part
.replace(/\{\/mautic\}/g, '[/mautic]');
} else {
// Convert [mautic ...] to {mautic ...}
code = code
.replace(/\[mautic/g, '{mautic')
.replace(/slot=".*?"\]/g, match => match.replace(']', '}')) // Replace ] with } in the first part
.replace(/\[\/mautic\]/g, '{/mautic}');
}
pre.innerHTML = code;
updateInputValue(pluginTab, inputValue);
isPluginBracketMode = toggleState(isPluginBracketMode);
};
// Toggle between <div> and <span> in the HTML tab
const switchHtmlTag = () => {
const htmlTab = document.querySelector(htmlTabSelector);
if (!htmlTab) return;
const pre = htmlTab.querySelector(codeContainerSelector);
if (!pre) return;
const input = htmlTab.querySelector(inputSelector);
const inputValue = input ? input.value : '';
let code = pre.innerHTML;
// Replace HTML tags based on the current state
code = isUsingDiv
? code.replace(/&lt;div/g, '&lt;span').replace(/&lt;\/div&gt;/g, '&lt;/span&gt;')
: code.replace(/&lt;span/g, '&lt;div').replace(/&lt;\/span&gt;/g, '&lt;/div&gt;');
pre.innerHTML = code;
updateInputValue(htmlTab, inputValue);
isUsingDiv = toggleState(isUsingDiv);
};
// Copy the current code snippet to the clipboard, customized for the active tab
const copyCode = () => {
const activeTab = getActiveTabType();
const container = document.querySelector(
`${activeTab === 'plugin' ? pluginTabSelector : htmlTabSelector} ${codeContainerSelector}`
);
if (!container) return;
const input = container.querySelector(inputSelector);
const userValue = (input ? input.value : '').trim();
let code;
if (activeTab === 'plugin') {
const slotMatch = container.textContent.match(/slot="([^"]+)"/);
const slotName = slotMatch ? slotMatch[1] : '';
const wrapper = isPluginBracketMode
? { open: `{mautic type="content" slot="${slotName}"}`, close: '{/mautic}' }
: { open: `[mautic type="content" slot="${slotName}"]`, close: '[/mautic]' };
code = `${wrapper.open}${userValue}${wrapper.close}`.trim();
} else {
const tag = isUsingDiv ? 'div' : 'span';
const slotMatch = container.innerHTML.match(/data-param-slot-name="([^"]+)"/);
const slotName = slotMatch ? slotMatch[1] : '';
code = `<${tag} data-slot="dwc" data-param-slot-name="${slotName}">${userValue}</${tag}>`;
}
navigator.clipboard.writeText(code).then(() => {
const flashMessage = Mautic.addInfoFlashMessage(Mautic.translate('mautic.core.copied'));
Mautic.setFlashes(flashMessage);
});
};
// Initialize event listeners for all buttons
const init = () => {
const copyBtn = document.querySelector(copyBtnSelector);
if (copyBtn) copyBtn.addEventListener('click', copyCode);
const switchCodeBtn = document.querySelector(switchCodeWrapperBtnSelector);
if (switchCodeBtn) switchCodeBtn.addEventListener('click', switchCodeWrapper);
const switchTagBtn = document.querySelector(switchHtmlTagBtnSelector);
if (switchTagBtn) switchTagBtn.addEventListener('click', switchHtmlTag);
};
return { init };
})();
Mautic.toggleContentEditor = function () {
const typeField = mQuery('#dwc_type').val();
const contentField = mQuery('#dwc_content');
if (typeField === 'html') {
Mautic.ConvertFieldToCkeditor(contentField, MauticVars.maxButtons);
} else if (typeField === 'text') {
if (ckEditors.size > 0) {
ckEditors.forEach(function(value, key, map){
map.get(key).destroy()
})
ckEditors.clear();
}
}
};
Mautic.dynamicContentOnLoad = function (container, response) {
if (typeof container !== 'object') {
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'dynamicContent');
}
}
var availableFilters = mQuery('div.dwc-filter').find('select[data-mautic="available_filters"]');
Mautic.activateChosenSelect(availableFilters, false);
Mautic.dynamicFiltersOnLoad('div.dwc-filter');
Mautic.dwcGenerator.init();
};
Mautic.dynamicFiltersOnLoad = function(container, response) {
mQuery('#campaign-share-tab').hover(function () {
if (Mautic.shareTableLoaded != true) {
Mautic.loadAjaxColumn('campaign-share-stat', 'lead:getCampaignShareStats', 'afterStatsLoad');
Mautic.shareTableLoaded = true;
}
})
Mautic.afterStatsLoad = function () {
Mautic.sortTableByColumn('#campaign-share-table', '.campaign-share-stat', true)
}
if (mQuery(container + ' #list-search').length) {
Mautic.activateSearchAutocomplete('list-search', 'lead.list');
}
var prefix = 'leadlist';
var parent = mQuery('.dynamic-content-filter, .dwc-filter');
if (parent.length) {
prefix = parent.attr('id');
}
if (mQuery('#' + prefix + '_filters').length) {
mQuery('#available_filters').on('change', function() {
if (mQuery(this).val()) {
Mautic.addDwcFilter(mQuery(this).val(),mQuery('option:selected',this).data('field-object'));
mQuery(this).val('');
mQuery(this).trigger('chosen:updated');
}
});
mQuery('#' + prefix + '_filters .remove-selected').each( function (index, el) {
mQuery(el).on('click', function () {
mQuery(this).closest('.panel').animate(
{'opacity': 0},
'fast',
function () {
mQuery(this).remove();
Mautic.reorderSegmentFilters();
}
);
if (!mQuery('#' + prefix + '_filters li:not(.placeholder)').length) {
mQuery('#' + prefix + '_filters li.placeholder').removeClass('hide');
} else {
mQuery('#' + prefix + '_filters li.placeholder').addClass('hide');
}
});
});
var bodyOverflow = {};
mQuery('#' + prefix + '_filters').sortable({
items: '.panel',
helper: function(e, ui) {
ui.children().each(function() {
if (mQuery(this).is(":visible")) {
mQuery(this).width(mQuery(this).width());
}
});
// Fix body overflow that messes sortable up
bodyOverflow.overflowX = mQuery('body').css('overflow-x');
bodyOverflow.overflowY = mQuery('body').css('overflow-y');
mQuery('body').css({
overflowX: 'visible',
overflowY: 'visible'
});
return ui;
},
scroll: true,
axis: 'y',
stop: function(e, ui) {
// Restore original overflow
mQuery('body').css(bodyOverflow);
// First in the list should be an "and"
ui.item.find('select.glue-select').first().val('and');
Mautic.reorderSegmentFilters();
}
});
}
// segment contact filters
var segmentContactForm = mQuery('#segment-contact-filters');
if (segmentContactForm.length) {
segmentContactForm.on('change', function() {
segmentContactForm.submit();
}).on('keyup', function() {
segmentContactForm.delay(200).submit();
}).on('submit', function(e) {
e.preventDefault();
Mautic.refreshSegmentContacts(segmentContactForm);
});
}
};
Mautic.addDwcFilter = function (elId, elObj) {
var filterId = '#available_' + elObj + '_' + elId;
var filterOption = mQuery(filterId);
var label = filterOption.text();
var alias = filterOption.val();
// Create a new filter
var filterNum = parseInt(mQuery('.available-filters').data('index'));
mQuery('.available-filters').data('index', filterNum + 1);
var prototypeStr = mQuery('.available-filters').data('prototype');
var fieldType = filterOption.data('field-type');
var fieldObject = filterOption.data('field-object');
var isSpecial = (mQuery.inArray(fieldType, ['leadlist', 'campaign', 'device_type', 'device_brand', 'device_os', 'lead_email_received', 'lead_email_sent', 'tags', 'multiselect', 'boolean', 'select', 'country', 'timezone', 'region', 'stage', 'locale', 'globalcategory']) != -1);
prototypeStr = prototypeStr.replace(/__name__/g, filterNum);
prototypeStr = prototypeStr.replace(/__label__/g, label);
// Convert to DOM
prototype = mQuery(prototypeStr);
var prefix = 'leadlist';
var parent = mQuery(filterId).parents('.dynamic-content-filter, .dwc-filter');
if (parent.length) {
prefix = parent.attr('id');
}
var filterBase = prefix + "[filters][" + filterNum + "]";
var filterIdBase = prefix + "_filters_" + filterNum + "_";
if (isSpecial) {
var templateField = fieldType;
if (fieldType == 'boolean' || fieldType == 'multiselect') {
templateField = 'select';
}
var template = mQuery('#templates .' + templateField + '-template').clone();
template.attr('name', mQuery(template).attr('name').replace(/__name__/g, filterNum));
template.attr('id', mQuery(template).attr('id').replace(/__name__/g, filterNum));
prototype.find('input[name="' + filterBase + '[filter]"]').replaceWith(template);
}
if (mQuery('#' + prefix + '_filters div.panel').length == 0) {
// First filter so hide the glue footer
prototype.find(".panel-heading").addClass('hide');
}
if (fieldObject == 'company') {
prototype.find(".object-icon").removeClass('ri-user-6-fill').addClass('ri-building-2-line');
} else {
prototype.find(".object-icon").removeClass('ri-building-2-line').addClass('ri-user-6-fill');
}
prototype.find(".inline-spacer").append(fieldObject);
prototype.find("a.remove-selected").on('click', function() {
mQuery(this).closest('.panel').animate(
{'opacity': 0},
'fast',
function () {
mQuery(this).remove();
Mautic.reorderSegmentFilters();
}
);
});
prototype.find("input[name='" + filterBase + "[field]']").val(elId);
prototype.find("input[name='" + filterBase + "[type]']").val(fieldType);
prototype.find("input[name='" + filterBase + "[object]']").val(fieldObject);
var filterEl = (isSpecial) ? "select[name='" + filterBase + "[filter]']" : "input[name='" + filterBase + "[filter]']";
prototype.appendTo('#' + prefix + '_filters');
var filter = mQuery('#' + filterIdBase + 'filter');
//activate fields
if (isSpecial) {
if (fieldType == 'select' || fieldType == 'multiselect' || fieldType == 'boolean' || fieldType == 'leadlist') {
// Generate the options
var fieldOptions = filterOption.data("field-list");
mQuery.each(fieldOptions, function(val, index) {
if (mQuery.isPlainObject(index)) {
var optGroup = index;
mQuery.each(optGroup, function(value, index) {
mQuery('<option class="' + optGroup + '">').val(index).text(value).appendTo(filterEl);
});
mQuery('.' + index).wrapAll("<optgroup label='"+index+"' />");
} else {
mQuery('<option>').val(index).text(val).appendTo(filterEl);
}
});
}
} else if (fieldType == 'lookup') {
var fieldCallback = filterOption.data("field-callback");
if (fieldCallback && typeof Mautic[fieldCallback] == 'function') {
var fieldOptions = filterOption.data("field-list");
Mautic[fieldCallback](filterIdBase + 'filter', elId, fieldOptions);
} else {
filter.attr('data-target', alias);
Mautic.activateLookupTypeahead(filter.parent());
}
} else if (fieldType == 'datetime') {
filter.datetimepicker({
format: 'Y-m-d H:i',
lazyInit: true,
validateOnBlur: false,
allowBlank: true,
scrollMonth: false,
scrollInput: false
});
} else if (fieldType == 'date') {
filter.datetimepicker({
timepicker: false,
format: 'Y-m-d',
lazyInit: true,
validateOnBlur: false,
allowBlank: true,
scrollMonth: false,
scrollInput: false,
closeOnDateSelect: true
});
} else if (fieldType == 'time') {
filter.datetimepicker({
datepicker: false,
format: 'H:i',
lazyInit: true,
validateOnBlur: false,
allowBlank: true,
scrollMonth: false,
scrollInput: false
});
} else if (fieldType == 'lookup_id') {
//switch the filter and display elements
var oldFilter = mQuery(filterEl);
var newDisplay = oldFilter.clone();
newDisplay.attr('name', filterBase + '[display]')
.attr('id', filterIdBase + 'display');
var oldDisplay = prototype.find("input[name='" + filterBase + "[display]']");
var newFilter = mQuery(oldDisplay).clone();
newFilter.attr('name', filterBase + '[filter]');
newFilter.attr('id', filterIdBase + 'filter');
oldFilter.replaceWith(newFilter);
oldDisplay.replaceWith(newDisplay);
var fieldCallback = filterOption.data("field-callback");
if (fieldCallback && typeof Mautic[fieldCallback] == 'function') {
var fieldOptions = filterOption.data("field-list");
Mautic[fieldCallback](filterIdBase + 'display', elId, fieldOptions);
}
} else {
filter.attr('type', fieldType);
}
var operators = filterOption.data('field-operators');
mQuery('#' + filterIdBase + 'operator').html('');
mQuery.each(operators, function (label, value) {
var newOption = mQuery('<option/>').val(value).text(label);
newOption.appendTo(mQuery('#' + filterIdBase + 'operator'));
});
// Convert based on first option in list
Mautic.convertDwcFilterInput('#' + filterIdBase + 'operator');
// Reposition if applicable
Mautic.updateFilterPositioning(mQuery('#' + filterIdBase + 'glue'));
};
Mautic.convertDwcFilterInput = function(el) {
var prefix = 'leadlist';
var parent = mQuery(el).parents('.dynamic-content-filter, .dwc-filter');
if (parent.length) {
prefix = parent.attr('id');
}
var operator = mQuery(el).val();
// Extract the filter number
var regExp = /_filters_(\d+)_operator/;
var matches = regExp.exec(mQuery(el).attr('id'));
var filterNum = matches[1];
var filterId = '#' + prefix + '_filters_' + filterNum + '_filter';
// Reset has-error
if (mQuery(filterId).parent().hasClass('has-error')) {
mQuery(filterId).parent().find('div.help-block').hide();
mQuery(filterId).parent().removeClass('has-error');
}
var disabled = (operator == 'empty' || operator == '!empty');
mQuery(filterId+', #' + prefix + '_filters_' + filterNum + '_display').prop('disabled', disabled);
if (disabled) {
mQuery(filterId).val('');
}
var newName = '';
var lastPos;
if (mQuery(filterId).is('select')) {
var isMultiple = mQuery(filterId).attr('multiple');
var multiple = (operator == 'in' || operator == '!in');
var placeholder = mQuery(filterId).attr('data-placeholder');
if (multiple && !isMultiple) {
mQuery(filterId).attr('multiple', 'multiple');
// Update the name
newName = mQuery(filterId).attr('name') + '[]';
mQuery(filterId).attr('name', newName);
placeholder = mauticLang['chosenChooseMore'];
} else if (!multiple && isMultiple) {
mQuery(filterId).removeAttr('multiple');
// Update the name
newName = mQuery(filterId).attr('name');
lastPos = newName.lastIndexOf('[]');
newName = newName.substring(0, lastPos);
mQuery(filterId).attr('name', newName);
placeholder = mauticLang['chosenChooseOne'];
}
if (multiple) {
// Remove empty option
mQuery(filterId).find('option[value=""]').remove();
// Make sure none are selected
mQuery(filterId + ' option:selected').removeAttr('selected');
} else {
// Add empty option
mQuery(filterId).prepend("<option value='' selected></option>");
}
// Destroy the chosen and recreate
Mautic.destroyChosen(mQuery(filterId));
mQuery(filterId).attr('data-placeholder', placeholder);
Mautic.activateChosenSelect(mQuery(filterId));
}
};
Mautic.standardDynamicContentUrl = function(options) {
if (!options) {
return;
}
var url = options.windowUrl;
if (url) {
var editDynamicContentKey = '/dwc/edit/dynamicContentId';
var previewDynamicContentKey = '/dwc/preview/dynamicContentId';
if (url.indexOf(editDynamicContentKey) > -1 ||
url.indexOf(previewDynamicContentKey) > -1) {
options.windowUrl = url.replace('dynamicContentId', mQuery('#campaignevent_properties_dynamicContent').val());
}
}
return options;
};
Mautic.disabledDynamicContentAction = function(opener) {
if (typeof opener == 'undefined') {
opener = window;
}
var dynamicContent = opener.mQuery('#campaignevent_properties_dynamicContent').val();
var disabled = dynamicContent === '' || dynamicContent === null;
opener.mQuery('#campaignevent_properties_editDynamicContentButton').prop('disabled', disabled);
};

View File

@@ -0,0 +1,73 @@
<?php
return [
'menu' => [
'main' => [
'items' => [
'mautic.dynamicContent.dynamicContent' => [
'route' => 'mautic_dynamicContent_index',
'access' => ['dynamiccontent:dynamiccontents:viewown', 'dynamiccontent:dynamiccontents:viewother'],
'parent' => 'mautic.core.components',
'priority' => 90,
],
],
],
],
'routes' => [
'main' => [
'mautic_dynamicContent_index' => [
'path' => '/dwc/{page}',
'controller' => 'Mautic\DynamicContentBundle\Controller\DynamicContentController::indexAction',
],
'mautic_dynamicContent_action' => [
'path' => '/dwc/{objectAction}/{objectId}',
'controller' => 'Mautic\DynamicContentBundle\Controller\DynamicContentController::executeAction',
],
],
'public' => [
'mautic_api_dynamicContent_index' => [
'path' => '/dwc',
'controller' => 'Mautic\DynamicContentBundle\Controller\DynamicContentApiController::getAction',
],
'mautic_api_dynamicContent_action' => [
'path' => '/dwc/{objectAlias}',
'controller' => 'Mautic\DynamicContentBundle\Controller\DynamicContentApiController::processAction',
],
],
'api' => [
'mautic_api_dynamicContent_standard' => [
'standard_entity' => true,
'name' => 'dynamicContents',
'path' => '/dynamiccontents',
'controller' => Mautic\DynamicContentBundle\Controller\Api\DynamicContentApiController::class,
],
],
],
'services' => [
'forms' => [
'mautic.form.type.dwc_entry_filters' => [
'class' => Mautic\DynamicContentBundle\Form\Type\DwcEntryFiltersType::class,
'arguments' => [
'translator',
'mautic.lead.model.list',
],
'methodCalls' => [
'setConnection' => [
'database_connection',
],
],
],
],
'other' => [
'mautic.helper.dynamicContent' => [
'class' => Mautic\DynamicContentBundle\Helper\DynamicContentHelper::class,
'arguments' => [
'mautic.dynamicContent.model.dynamicContent',
'mautic.campaign.executioner.realtime',
'event_dispatcher',
'mautic.lead.model.lead',
],
],
],
],
];

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
];
$services->load('Mautic\\DynamicContentBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\DynamicContentBundle\\Entity\\', '../Entity/*Repository.php')
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
$services->alias('mautic.dynamicContent.model.dynamicContent', Mautic\DynamicContentBundle\Model\DynamicContentModel::class);
$services->alias('mautic.dynamicContent.repository.stat', Mautic\DynamicContentBundle\Entity\StatRepository::class);
};

View File

@@ -0,0 +1,11 @@
<?php
namespace Mautic\DynamicContentBundle\Controller;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\CoreBundle\Controller\AjaxLookupControllerTrait;
class AjaxController extends CommonAjaxController
{
use AjaxLookupControllerTrait;
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Mautic\DynamicContentBundle\Controller\Api;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\ApiBundle\Controller\CommonApiController;
use Mautic\ApiBundle\Helper\EntityResultHelper;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\AppVersion;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<DynamicContent>
*/
class DynamicContentApiController extends CommonApiController
{
public function __construct(CorePermissions $security, Translator $translator, EntityResultHelper $entityResultHelper, RouterInterface $router, FormFactoryInterface $formFactory, AppVersion $appVersion, RequestStack $requestStack, ManagerRegistry $doctrine, ModelFactory $modelFactory, EventDispatcherInterface $dispatcher, CoreParametersHelper $coreParametersHelper)
{
$dynamicContentModel = $modelFactory->getModel('dynamicContent');
\assert($dynamicContentModel instanceof DynamicContentModel);
$this->model = $dynamicContentModel;
$this->entityClass = DynamicContent::class;
$this->entityNameOne = 'dynamicContent';
$this->entityNameMulti = 'dynamicContents';
$this->serializerGroups = ['dwcDetails', 'categoryList'];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Mautic\DynamicContentBundle\Controller;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\DynamicContentBundle\Helper\DynamicContentHelper;
use Mautic\LeadBundle\Helper\ContactRequestHelper;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use Mautic\PageBundle\Model\PageModel;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class DynamicContentApiController extends CommonController
{
/**
* @return mixed
*/
public function processAction(Request $request, $objectAlias)
{
// Don't store a visitor with this request
defined('MAUTIC_NON_TRACKABLE_REQUEST') || define('MAUTIC_NON_TRACKABLE_REQUEST', 1);
$method = strtolower($request->getMethod());
if (method_exists($this, $method.'Action')) {
return $this->forwardWithPost(
static::class.'::'.$method.'Action',
$request->request->all(),
[
'objectAlias' => $objectAlias,
],
$request->query->all()
);
} else {
throw new HttpException(Response::HTTP_FORBIDDEN, 'This endpoint is not able to process '.strtoupper($method).' requests.');
}
}
public function getAction(
Request $request,
DynamicContentHelper $helper,
DeviceTrackingServiceInterface $deviceTrackingService,
ContactRequestHelper $contactRequestHelper,
$objectAlias,
): Response {
/** @var PageModel $pageModel */
$pageModel = $this->getModel('page');
$lead = $contactRequestHelper->getContactFromQuery($pageModel->getHitQuery($request));
$content = $helper->getDynamicContentForLead($objectAlias, $lead);
$trackedDevice = $deviceTrackingService->getTrackedDevice();
$deviceId = (null === $trackedDevice ? null : $trackedDevice->getTrackingId());
return empty($content)
? new Response('', Response::HTTP_NO_CONTENT)
: new JsonResponse(
[
'content' => $content,
'id' => $lead->getId(),
'sid' => $deviceId,
'device_id' => $deviceId,
]
);
}
}

View File

@@ -0,0 +1,579 @@
<?php
namespace Mautic\DynamicContentBundle\Controller;
use Mautic\CoreBundle\Controller\FormController;
use Mautic\CoreBundle\Form\Type\DateRangeType;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\TrackableModel;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class DynamicContentController extends FormController
{
protected function getPermissions(): array
{
return (array) $this->security->isGranted(
[
'dynamiccontent:dynamiccontents:viewown',
'dynamiccontent:dynamiccontents:viewother',
'dynamiccontent:dynamiccontents:create',
'dynamiccontent:dynamiccontents:editown',
'dynamiccontent:dynamiccontents:editother',
'dynamiccontent:dynamiccontents:deleteown',
'dynamiccontent:dynamiccontents:deleteother',
'dynamiccontent:dynamiccontents:publishown',
'dynamiccontent:dynamiccontents:publishother',
],
'RETURN_ARRAY'
);
}
public function indexAction(Request $request, $page = 1)
{
$model = $this->getModel('dynamicContent');
$permissions = $this->getPermissions();
if (!$permissions['dynamiccontent:dynamiccontents:viewown'] && !$permissions['dynamiccontent:dynamiccontents:viewother']) {
return $this->accessDenied();
}
$this->setListFilters();
$limit = $request->getSession()->get('mautic.dynamicContent.limit', $this->coreParametersHelper->get('default_pagelimit'));
$start = (1 === $page) ? 0 : (($page - 1) * $limit);
if ($start < 0) {
$start = 0;
}
// fetch
$search = $request->get('search', $request->getSession()->get('mautic.dynamicContent.filter', ''));
$request->getSession()->set('mautic.dynamicContent.filter', $search);
$filter = [
'string' => $search,
'force' => [
['column' => 'e.variantParent', 'expr' => 'isNull'],
['column' => 'e.translationParent', 'expr' => 'isNull'],
],
];
$orderBy = $request->getSession()->get('mautic.dynamicContent.orderby', 'e.name');
$orderByDir = $request->getSession()->get('mautic.dynamicContent.orderbydir', 'DESC');
$entities = $model->getEntities(
[
'start' => $start,
'limit' => $limit,
'filter' => $filter,
'orderBy' => $orderBy,
'orderByDir' => $orderByDir,
]
);
// set what page currently on so that we can return here after form submission/cancellation
$request->getSession()->set('mautic.dynamicContent.page', $page);
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
// retrieve a list of categories
$pageModel = $this->getModel('page');
\assert($pageModel instanceof PageModel);
$categories = $pageModel->getLookupResults('category', '', 0);
return $this->delegateView(
[
'contentTemplate' => '@MauticDynamicContent/DynamicContent/list.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_dynamicContent_index',
'mauticContent' => 'dynamicContent',
'route' => $this->generateUrl('mautic_dynamicContent_index', ['page' => $page]),
],
'viewParameters' => [
'searchValue' => $search,
'items' => $entities,
'categories' => $categories,
'page' => $page,
'limit' => $limit,
'permissions' => $permissions,
'model' => $model,
'tmpl' => $tmpl,
],
]
);
}
public function newAction(Request $request, $entity = null)
{
if (!$this->security->isGranted('dynamiccontent:dynamiccontents:create')) {
return $this->accessDenied();
}
if (!$entity instanceof DynamicContent) {
$entity = new DynamicContent();
}
$model = $this->getModel('dynamicContent');
\assert($model instanceof DynamicContentModel);
$method = $request->getMethod();
$page = $request->getSession()->get('mautic.dynamicContent.page', 1);
$retUrl = $this->generateUrl('mautic_dynamicContent_index', ['page' => $page]);
$action = $this->generateUrl('mautic_dynamicContent_action', ['objectAction' => 'new']);
$dwc = $request->request->all()['dwc'] ?? [];
$updateSelect = 'POST' === $method
? ($dwc['updateSelect'] ?? false)
: $request->get('updateSelect', false);
$form = $model->createForm($entity, $this->formFactory, $action, ['update_select' => $updateSelect]);
if (Request::METHOD_POST === $method) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$model->saveEntity($entity);
$this->addFlashMessage(
'mautic.core.notice.created',
[
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_dynamicContent_index',
'%url%' => $this->generateUrl(
'mautic_dynamicContent_action',
[
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]
),
]
);
if ($this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
$viewParameters = [
'objectAction' => 'view',
'objectId' => $entity->getId(),
];
$retUrl = $this->generateUrl('mautic_dynamicContent_action', $viewParameters);
$template = 'Mautic\DynamicContentBundle\Controller\DynamicContentController::viewAction';
} else {
// return edit view so that all the session stuff is loaded
return $this->editAction($request, $entity->getId(), true);
}
}
} else {
$viewParameters = ['page' => $page];
$retUrl = $this->generateUrl('mautic_dynamicContent_index', $viewParameters);
$template = 'Mautic\DynamicContentBundle\Controller\DynamicContentController::indexAction';
}
$passthrough = [
'activeLink' => '#mautic_dynamicContent_index',
'mauticContent' => 'dynamicContent',
];
// Check to see if this is a popup
if (isset($form['updateSelect'])) {
$template = false;
$passthrough = array_merge(
$passthrough,
[
'updateSelect' => $form['updateSelect']->getData(),
'id' => $entity->getId(),
'name' => $entity->getName(),
'group' => $entity->getLanguage(),
]
);
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->postActionRedirect(
[
'returnUrl' => $retUrl,
'viewParameters' => $viewParameters,
'contentTemplate' => $template,
'passthroughVars' => $passthrough,
]
);
} elseif ($valid && !$cancelled) {
return $this->editAction($request, $entity->getId(), true);
}
}
$passthrough['route'] = $action;
return $this->delegateView(
[
'viewParameters' => [
'form' => $form->createView(),
],
'contentTemplate' => '@MauticDynamicContent/DynamicContent/form.html.twig',
'passthroughVars' => $passthrough,
]
);
}
/**
* Generate's edit form and processes post data.
*
* @param bool|false $ignorePost
*
* @return array|JsonResponse|RedirectResponse|Response
*/
public function editAction(Request $request, $objectId, $ignorePost = false)
{
/** @var DynamicContentModel $model */
$model = $this->getModel('dynamicContent');
$entity = $model->getEntity($objectId);
$page = $request->getSession()->get('mautic.dynamicContent.page', 1);
$retUrl = $this->generateUrl('mautic_dynamicContent_index', ['page' => $page]);
$postActionVars = [
'returnUrl' => $retUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\DynamicContentBundle\Controller\DynamicContentController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_dynamicContent_index',
'mauticContent' => 'dynamicContent',
],
];
if (null === $entity) {
return $this->postActionRedirect(
array_merge(
$postActionVars,
[
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.dynamicContent.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]
)
);
} elseif (!$this->security->hasEntityAccess(true, 'dynamiccontent:dynamiccontents:editother', $entity->getCreatedBy())) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
// deny access if the entity is locked
return $this->isLocked($postActionVars, $entity, 'dynamicContent');
}
$action = $this->generateUrl('mautic_dynamicContent_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
$method = $request->getMethod();
$dwc = $request->request->all()['dwc'] ?? [];
$updateSelect = 'POST' === $method
? ($dwc['updateSelect'] ?? false)
: $request->get('updateSelect', false);
$form = $model->createForm($entity, $this->formFactory, $action, ['update_select' => $updateSelect]);
// /Check for a submitted form and process it
if (!$ignorePost && 'POST' === $method) {
$valid = false;
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
// form is valid so process the data
$model->saveEntity($entity, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
$this->addFlashMessage(
'mautic.core.notice.updated',
[
'%name%' => $entity->getName(),
'%menu_link%' => 'mautic_dynamicContent_index',
'%url%' => $this->generateUrl(
'mautic_dynamicContent_action',
[
'objectAction' => 'edit',
'objectId' => $entity->getId(),
]
),
]
);
}
} else {
// unlock the entity
$model->unlockEntity($entity);
}
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
return $this->viewAction($request, $entity->getId());
}
} else {
// lock the entity
$model->lockEntity($entity);
}
return $this->delegateView(
[
'viewParameters' => [
'form' => $form->createView(),
'currentListId' => $objectId,
],
'contentTemplate' => '@MauticDynamicContent/DynamicContent/form.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_dynamicContent_index',
'route' => $action,
'mauticContent' => 'dynamicContent',
],
]
);
}
/**
* Loads a specific form into the detailed panel.
*
* @param int $objectId
*
* @return JsonResponse|Response
*/
public function viewAction(Request $request, $objectId)
{
$model = $this->getModel('dynamicContent');
\assert($model instanceof DynamicContentModel);
$security = $this->security;
$entity = $model->getEntity($objectId);
// set the page we came from
$page = $request->getSession()->get('mautic.dynamicContent.page', 1);
if (null === $entity) {
// set the return URL
$returnUrl = $this->generateUrl('mautic_dynamicContent_index', ['page' => $page]);
return $this->postActionRedirect(
[
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\DynamicContentBundle\Controller\DynamicContentController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_dynamicContent_index',
'mauticContent' => 'dynamicContent',
],
'flashes' => [
[
'type' => 'error',
'msg' => 'mautic.dynamicContent.error.notfound',
'msgVars' => ['%id%' => $objectId],
],
],
]
);
} elseif (!$security->hasEntityAccess(
'dynamiccontent:dynamiccontents:viewown',
'dynamiccontent:dynamiccontents:viewother',
$entity->getCreatedBy()
)
) {
return $this->accessDenied();
}
/* @var DynamicContent $parent */
/* @var DynamicContent[] $children */
[$translationParent, $translationChildren] = $entity->getTranslations();
// Audit Log
$auditLogModel = $this->getModel('core.auditlog');
\assert($auditLogModel instanceof AuditLogModel);
$logs = $auditLogModel->getLogForObject('dynamicContent', $entity->getId(), $entity->getDateAdded());
// Init the date range filter form
$dateRangeValues = $request->query->all()['daterange'] ?? $request->request->all()['daterange'] ?? [];
$action = $this->generateUrl('mautic_dynamicContent_action', ['objectAction' => 'view', 'objectId' => $objectId]);
$dateRangeForm = $this->formFactory->create(DateRangeType::class, $dateRangeValues, ['action' => $action]);
$entityViews = $model->getHitsLineChartData(
null,
new \DateTime($dateRangeForm->get('date_from')->getData()),
new \DateTime($dateRangeForm->get('date_to')->getData()),
null,
['dynamic_content_id' => $entity->getId(), 'flag' => 'total_and_unique']
);
$trackableModel = $this->getModel('page.trackable');
\assert($trackableModel instanceof TrackableModel);
$trackables = $trackableModel->getTrackableList('dynamicContent', $entity->getId());
return $this->delegateView(
[
'returnUrl' => $action,
'contentTemplate' => '@MauticDynamicContent/DynamicContent/details.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_dynamicContent_index',
'mauticContent' => 'dynamicContent',
],
'viewParameters' => [
'entity' => $entity,
'permissions' => $this->getPermissions(),
'logs' => $logs,
'isEmbedded' => $request->get('isEmbedded') ?: false,
'translations' => [
'parent' => $translationParent,
'children' => $translationChildren,
],
'trackables' => $trackables,
'entityViews' => $entityViews,
'dateRangeForm' => $dateRangeForm->createView(),
],
]
);
}
/**
* Clone an entity.
*
* @return JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse|Response
*/
public function cloneAction(Request $request, $objectId)
{
$model = $this->getModel('dynamicContent');
$entity = $model->getEntity($objectId);
if (null != $entity) {
if (!$this->security->isGranted('dynamiccontent:dynamiccontents:create')
|| !$this->security->hasEntityAccess(
'dynamiccontent:dynamiccontents:viewown',
'dynamiccontent:dynamiccontents:viewother',
$entity->getCreatedBy()
)
) {
return $this->accessDenied();
}
$entity = clone $entity;
}
return $this->newAction($request, $entity);
}
/**
* Deletes the entity.
*
* @return Response
*/
public function deleteAction(Request $request, $objectId)
{
$page = $request->getSession()->get('mautic.dynamicContent.page', 1);
$returnUrl = $this->generateUrl('mautic_dynamicContent_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\DynamicContentBundle\Controller\DynamicContentController::indexAction',
'passthroughVars' => [
'activeLink' => 'mautic_dynamicContent_index',
'mauticContent' => 'dynamicContent',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$model = $this->getModel('dynamicContent');
\assert($model instanceof DynamicContentModel);
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.dynamicContent.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
} elseif (!$this->security->hasEntityAccess(
'dynamiccontent:dynamiccontents:deleteown',
'dynamiccontent:dynamiccontents:deleteother',
$entity->getCreatedBy()
)
) {
return $this->accessDenied();
} elseif ($model->isLocked($entity)) {
return $this->isLocked($postActionVars, $entity, 'notification');
}
$model->deleteEntity($entity);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $entity->getName(),
'%id%' => $objectId,
],
];
} // else don't do anything
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
/**
* Deletes a group of entities.
*/
public function batchDeleteAction(Request $request): Response
{
$page = $request->getSession()->get('mautic.dynamicContent.page', 1);
$returnUrl = $this->generateUrl('mautic_dynamicContent_index', ['page' => $page]);
$flashes = [];
$postActionVars = [
'returnUrl' => $returnUrl,
'viewParameters' => ['page' => $page],
'contentTemplate' => 'Mautic\DynamicContentBundle\Controller\DynamicContentController::indexAction',
'passthroughVars' => [
'activeLink' => '#mautic_dynamicContent_index',
'mauticContent' => 'dynamicContent',
],
];
if (Request::METHOD_POST === $request->getMethod()) {
$model = $this->getModel('dynamicContent');
\assert($model instanceof DynamicContentModel);
$ids = json_decode($request->query->get('ids', '{}'));
$deleteIds = [];
// Loop over the IDs to perform access checks pre-delete
foreach ($ids as $objectId) {
$entity = $model->getEntity($objectId);
if (null === $entity) {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.dynamicContent.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
} elseif (!$this->security->hasEntityAccess(
'dynamiccontent:dynamiccontents:viewown',
'dynamiccontent:dynamiccontents:viewother',
$entity->getCreatedBy()
)
) {
$flashes[] = $this->accessDenied(true);
} elseif ($model->isLocked($entity)) {
$flashes[] = $this->isLocked($postActionVars, $entity, 'dynamicContent', true);
} else {
$deleteIds[] = $objectId;
}
}
// Delete everything we are able to
if (!empty($deleteIds)) {
$entities = $model->deleteEntities($deleteIds);
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.dynamicContent.notice.batch_deleted',
'msgVars' => [
'%count%' => count($entities),
],
];
}
} // else don't do anything
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticDynamicContentExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\DynamicContent;
final class TypeList
{
public const HTML = 'html';
public const TEXT = 'text';
/**
* @return string[]
*/
public function getChoices(): array
{
return [
'mautic.dynamic.content.type.html' => self::HTML,
'mautic.dynamic.content.type.text' => self::TEXT,
];
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Mautic\DynamicContentBundle;
/**
* Events available for DynamicContentBundle.
*/
final class DynamicContentEvents
{
/**
* The mautic.dwc_token_replacement event is thrown right before the content is returned.
*
* The event listener receives a
* Mautic\CoreBundle\Event\TokenReplacementEvent instance.
*
* @var string
*/
public const TOKEN_REPLACEMENT = 'mautic.dwc_token_replacement';
/**
* The mautic.dwc_pre_save event is thrown right before a asset is persisted.
*
* The event listener receives a
* Mautic\DynamicContentBundle\Event\DynamicContentEvent instance.
*
* @var string
*/
public const PRE_SAVE = 'mautic.dwc_pre_save';
/**
* The mautic.dwc_post_save event is thrown right after a asset is persisted.
*
* The event listener receives a
* Mautic\DynamicContentBundle\Event\DynamicContentEvent instance.
*
* @var string
*/
public const POST_SAVE = 'mautic.dwc_post_save';
/**
* The mautic.dwc_pre_delete event is thrown prior to when a asset is deleted.
*
* The event listener receives a
* Mautic\DynamicContentBundle\Event\DynamicContentEvent instance.
*
* @var string
*/
public const PRE_DELETE = 'mautic.dwc_pre_delete';
/**
* The mautic.dwc_post_delete event is thrown after a asset is deleted.
*
* The event listener receives a
* Mautic\DynamicContentBundle\Event\DynamicContentEvent instance.
*
* @var string
*/
public const POST_DELETE = 'mautic.dwc_post_delete';
/**
* The mautic.category_pre_save event is thrown right before a category is persisted.
*
* The event listener receives a
* Mautic\CategoryBundle\Event\CategoryEvent instance.
*
* @var string
*/
public const CATEGORY_PRE_SAVE = 'mautic.category_pre_save';
/**
* The mautic.category_post_save event is thrown right after a category is persisted.
*
* The event listener receives a
* Mautic\CategoryBundle\Event\CategoryEvent instance.
*
* @var string
*/
public const CATEGORY_POST_SAVE = 'mautic.category_post_save';
/**
* The mautic.category_pre_delete event is thrown prior to when a category is deleted.
*
* The event listener receives a
* Mautic\CategoryBundle\Event\CategoryEvent instance.
*
* @var string
*/
public const CATEGORY_PRE_DELETE = 'mautic.category_pre_delete';
/**
* The mautic.category_post_delete event is thrown after a category is deleted.
*
* The event listener receives a
* Mautic\CategoryBundle\Event\CategoryEvent instance.
*
* @var string
*/
public const CATEGORY_POST_DELETE = 'mautic.category_post_delete';
/**
* The mautic.asset.on_campaign_trigger_decision event is fired when the campaign decision triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_DECISION = 'mautic.dwc.on_campaign_trigger_decision';
/**
* The mautic.asset.on_campaign_trigger_action event is fired when the campaign action triggers.
*
* The event listener receives a
* Mautic\CampaignBundle\Event\CampaignExecutionEvent
*
* @var string
*/
public const ON_CAMPAIGN_TRIGGER_ACTION = 'mautic.dwc.on_campaign_trigger_action';
/**
* The mautic.dwc.on_contact_filters_evaluate event is fired when dynamic content's decision's
* filters need to be evaluated.
*
* The event listener receives a
* Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent
*
* @var string
*/
public const ON_CONTACTS_FILTER_EVALUATE = 'mautic.dwc.on_contact_filters_evaluate';
}

View File

@@ -0,0 +1,569 @@
<?php
namespace Mautic\DynamicContentBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CategoryBundle\Entity\Category;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FiltersEntityTrait;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Entity\TranslationEntityInterface;
use Mautic\CoreBundle\Entity\TranslationEntityTrait;
use Mautic\CoreBundle\Entity\UuidInterface;
use Mautic\CoreBundle\Entity\UuidTrait;
use Mautic\CoreBundle\Entity\VariantEntityInterface;
use Mautic\CoreBundle\Entity\VariantEntityTrait;
use Mautic\DynamicContentBundle\DynamicContent\TypeList;
use Mautic\DynamicContentBundle\Validator\Constraints\NoNesting;
use Mautic\DynamicContentBundle\Validator\Constraints\SlotNameType;
use Mautic\ProjectBundle\Entity\ProjectTrait;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Mapping\ClassMetadata;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('dynamiccontent:dynamiccontents:viewown')"),
new Post(security: "is_granted('dynamiccontent:dynamiccontents:create')"),
new Get(security: "is_granted('dynamiccontent:dynamiccontents:viewown')"),
new Put(security: "is_granted('dynamiccontent:dynamiccontents:editown')"),
new Patch(security: "is_granted('dynamiccontent:dynamiccontents:editother')"),
new Delete(security: "is_granted('dynamiccontent:dynamiccontents:deleteown')"),
],
normalizationContext: [
'groups' => ['dynamicContent:read'],
'swagger_definition_name' => 'Read',
'api_included' => ['category', 'translationChildren'],
],
denormalizationContext: [
'groups' => ['dynamicContent:write'],
'swagger_definition_name' => 'Write',
]
)]
/**
* @use TranslationEntityTrait<DynamicContent>
* @use VariantEntityTrait<DynamicContent>
*/
class DynamicContent extends FormEntity implements VariantEntityInterface, TranslationEntityInterface, UuidInterface
{
use TranslationEntityTrait;
use VariantEntityTrait;
use FiltersEntityTrait;
use UuidTrait;
use ProjectTrait;
public const ENTITY_NAME = 'dynamic_content';
/**
* @var int
*/
#[Groups(['dynamicContent:read'])]
private $id;
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private ?string $name = null;
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private string $type = TypeList::HTML;
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private ?string $description = null;
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private ?Category $category = null;
/**
* @var \DateTimeInterface
*/
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private $publishUp;
/**
* @var \DateTimeInterface
*/
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private $publishDown;
/**
* @var string|null
*/
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private $content;
/**
* @var array|null
*/
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private $utmTags = [];
/**
* @var int
*/
#[Groups(['dynamicContent:read'])]
private $sentCount = 0;
/**
* @var ArrayCollection<Stat>
*/
#[Groups(['dynamicContent:read'])]
private $stats;
/**
* @var bool
*/
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private $isCampaignBased = true;
/**
* @var string|null
*/
#[Groups(['dynamicContent:read', 'dynamicContent:write'])]
private $slotName;
public function __construct()
{
$this->stats = new ArrayCollection();
$this->translationChildren = new ArrayCollection();
$this->variantChildren = new ArrayCollection();
$this->initializeProjects();
}
public function __clone()
{
$this->id = null;
$this->sentCount = 0;
$this->stats = new ArrayCollection();
$this->translationChildren = new ArrayCollection();
$this->variantChildren = new ArrayCollection();
parent::__clone();
}
public function clearStats(): void
{
$this->stats = new ArrayCollection();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('dynamic_content')
->addIndex(['is_campaign_based'], 'is_campaign_based_index')
->addIndex(['slot_name'], 'slot_name_index')
->setCustomRepositoryClass(DynamicContentRepository::class)
->addLifecycleEvent('cleanSlotName', Events::prePersist)
->addLifecycleEvent('cleanSlotName', Events::preUpdate);
$builder->addIdColumns();
$builder->addCategory();
$builder->addField(
'type',
Types::STRING,
[
'length' => 10,
'default' => TypeList::HTML,
]
);
$builder->addPublishDates();
$builder->createField('sentCount', 'integer')
->columnName('sent_count')
->build();
$builder->createField('content', 'text')
->columnName('content')
->nullable()
->build();
$builder->createField('utmTags', Types::JSON)
->columnName('utm_tags')
->nullable()
->build();
$builder->createOneToMany('stats', 'Stat')
->setIndexBy('id')
->mappedBy('dynamicContent')
->cascadePersist()
->fetchExtraLazy()
->build();
self::addTranslationMetadata($builder, self::class);
self::addVariantMetadata($builder, self::class);
self::addFiltersMetadata($builder);
$builder->createField('isCampaignBased', 'boolean')
->columnName('is_campaign_based')
->option('default', 1)
->build();
$builder->createField('slotName', 'string')
->columnName('slot_name')
->nullable()
->build();
static::addUuidField($builder);
self::addProjectsField($builder, 'dynamic_content_projects_xref', 'dynamic_content_id');
}
/**
* @throws \Symfony\Component\Validator\Exception\ConstraintDefinitionException
* @throws \Symfony\Component\Validator\Exception\InvalidOptionsException
* @throws \Symfony\Component\Validator\Exception\MissingOptionsException
*/
public static function loadValidatorMetaData(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('name', new NotBlank(['message' => 'mautic.core.name.required']));
$metadata->addPropertyConstraint('content', new NoNesting());
$metadata->addPropertyConstraint('type', new NotBlank(['message' => 'mautic.core.type.required']));
$metadata->addPropertyConstraint('type', new Choice(['choices' => (new TypeList())->getChoices()]));
$metadata->addConstraint(new SlotNameType());
$metadata->addConstraint(new Callback(
function (self $dwc, ExecutionContextInterface $context): void {
if (!$dwc->getIsCampaignBased()) {
$validator = $context->getValidator();
$violations = $validator->validate(
$dwc->getSlotName(),
[
new NotBlank(
[
'message' => 'mautic.dynamicContent.slot_name.notblank',
]
),
]
);
foreach ($violations as $violation) {
$context->buildViolation($violation->getMessage())
->atPath('slotName')
->addViolation();
}
$violations = $validator->validate(
$dwc->getFilters(),
[
new Count(
[
'minMessage' => 'mautic.dynamicContent.filter.options.empty',
'min' => 1,
]
),
]
);
foreach ($violations as $violation) {
$context->buildViolation($violation->getMessage())
->atPath('filters')
->addViolation();
}
}
},
));
}
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('dwc')
->addListProperties([
'id',
'name',
'category',
'type',
])
->addProperties([
'publishUp',
'publishDown',
'sentCount',
'variantParent',
'variantChildren',
'content',
'utmTags',
'filters',
'isCampaignBased',
'slotName',
])
->setMaxDepth(1, 'variantParent')
->setMaxDepth(1, 'variantChildren')
->build();
self::addProjectsInLoadApiMetadata($metadata, 'dwc');
}
protected function isChanged($prop, $val)
{
$getter = 'get'.ucfirst($prop);
$current = $this->$getter();
if ('variantParent' == $prop || 'translationParent' == $prop || 'category' == $prop) {
$currentId = ($current) ? $current->getId() : '';
$newId = ($val) ? $val->getId() : null;
if ($currentId != $newId) {
$this->changes[$prop] = [$currentId, $newId];
}
} else {
parent::isChanged($prop, $val);
}
}
/**
* @return int|null
*/
public function getId()
{
return $this->id;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param string $name
*
* @return $this
*/
public function setName($name)
{
$this->isChanged('name', $name);
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getDescription()
{
return $this->description;
}
/**
* @param string $description
*
* @return $this
*/
public function setDescription($description)
{
$this->description = $description;
return $this;
}
public function setType(string $type): void
{
$type = strtolower($type);
$this->isChanged('type', $type);
$this->type = $type;
}
public function getType(): string
{
return $this->type;
}
/**
* @return Category
*/
public function getCategory()
{
return $this->category;
}
/**
* @param Category $category
*
* @return $this
*/
public function setCategory($category)
{
$this->isChanged('category', $category);
$this->category = $category;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getPublishUp()
{
return $this->publishUp;
}
/**
* @param \DateTime $publishUp
*
* @return $this
*/
public function setPublishUp($publishUp)
{
$this->isChanged('publishUp', $publishUp);
$this->publishUp = $publishUp;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getPublishDown()
{
return $this->publishDown;
}
/**
* @param \DateTime $publishDown
*
* @return $this
*/
public function setPublishDown($publishDown)
{
$this->isChanged('publishDown', $publishDown);
$this->publishDown = $publishDown;
return $this;
}
/**
* @return string
*/
public function getContent()
{
return $this->content;
}
/**
* @param string $content
*
* @return $this
*/
public function setContent($content)
{
$this->isChanged('content', $content);
$this->content = $content;
return $this;
}
/**
* @param bool $includeVariants
*
* @return mixed
*/
public function getSentCount($includeVariants = false)
{
return $includeVariants ? $this->getAccumulativeTranslationCount('getSentCount') : $this->sentCount;
}
/**
* @return $this
*/
public function setSentCount($sentCount)
{
$this->sentCount = $sentCount;
return $this;
}
/**
* @return ArrayCollection
*/
public function getStats()
{
return $this->stats;
}
/**
* @return bool
*/
public function getIsCampaignBased()
{
return $this->isCampaignBased;
}
/**
* @param bool $isCampaignBased
*
* @return $this
*/
public function setIsCampaignBased($isCampaignBased)
{
$this->isChanged('isCampaignBased', $isCampaignBased);
$this->isCampaignBased = $isCampaignBased;
return $this;
}
/**
* @return string
*/
public function getSlotName()
{
return $this->slotName;
}
/**
* @param string $slotName
*
* @return $this
*/
public function setSlotName($slotName)
{
$this->isChanged('slotName', $slotName);
$this->slotName = $slotName;
return $this;
}
/**
* Lifecycle callback to clear the slot name if is_campaign is true.
*/
public function cleanSlotName(): void
{
if ($this->getIsCampaignBased()) {
$this->setSlotName('');
}
}
/**
* @return DynamicContent
*/
public function setUtmTags(array $utmTags)
{
$this->isChanged('utmTags', $utmTags);
$this->utmTags = $utmTags;
return $this;
}
/**
* @return array
*/
public function getUtmTags()
{
return $this->utmTags;
}
}

View File

@@ -0,0 +1,172 @@
<?php
namespace Mautic\DynamicContentBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\CommonEntity;
use Mautic\LeadBundle\Entity\Lead;
class DynamicContentLeadData extends CommonEntity
{
/**
* @var int
*/
private $id;
/**
* @var \DateTimeInterface
*/
private $dateAdded;
/**
* @var DynamicContent|null
*/
private $dynamicContent;
/**
* @var Lead
*/
private $lead;
/**
* @var \DateTimeInterface
*/
private $dataAdded;
/**
* @var string
*/
private $slot;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('dynamic_content_lead_data')
->setCustomRepositoryClass(DynamicContentLeadDataRepository::class);
$builder->addIdColumns(false, false);
$builder->addDateAdded(true);
$builder->addLead();
$builder->createManyToOne('dynamicContent', 'DynamicContent')
->inversedBy('id')
->addJoinColumn('dynamic_content_id', 'id', true, false, 'CASCADE')
->build();
$builder->createField('slot', 'text')
->columnName('slot')
->build();
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return \DateTimeInterface
*/
public function getDateAdded()
{
return $this->dateAdded;
}
/**
* @param \DateTime $dateAdded
*
* @return DynamicContentLeadData
*/
public function setDateAdded($dateAdded)
{
$this->dateAdded = $dateAdded;
return $this;
}
/**
* @return DynamicContent
*/
public function getDynamicContent()
{
return $this->dynamicContent;
}
/**
* @param DynamicContent $dynamicContent
*
* @return DynamicContentLeadData
*/
public function setDynamicContent($dynamicContent)
{
$this->dynamicContent = $dynamicContent;
return $this;
}
/**
* @return Lead
*/
public function getLead()
{
return $this->lead;
}
/**
* @param Lead $lead
*
* @return DynamicContentLeadData
*/
public function setLead($lead)
{
$this->lead = $lead;
return $this;
}
/**
* @return \DateTimeInterface
*/
public function getDataAdded()
{
return $this->dataAdded;
}
/**
* @param \DateTime $dataAdded
*
* @return DynamicContentLeadData
*/
public function setDataAdded($dataAdded)
{
$this->dataAdded = $dataAdded;
return $this;
}
/**
* @return string
*/
public function getSlot()
{
return $this->slot;
}
/**
* @param string $slot
*
* @return DynamicContentLeadData
*/
public function setSlot($slot)
{
$this->slot = $slot;
return $this;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Mautic\DynamicContentBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<DynamicContentLeadData>
*/
class DynamicContentLeadDataRepository extends CommonRepository
{
public function getTableAlias(): string
{
return 'dcld';
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace Mautic\DynamicContentBundle\Entity;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\Serializer;
use Mautic\ProjectBundle\Entity\ProjectRepositoryTrait;
/**
* @extends CommonRepository<DynamicContent>
*/
class DynamicContentRepository extends CommonRepository
{
use ProjectRepositoryTrait;
/**
* Get a list of entities.
*
* @return Paginator
*/
public function getEntities(array $args = [])
{
$q = $this->_em
->createQueryBuilder()
->select('e')
->from(DynamicContent::class, 'e', 'e.id');
if (empty($args['iterable_mode'])) {
$q->leftJoin('e.category', 'c');
}
$args['qb'] = $q;
return parent::getEntities($args);
}
/**
* @param \Doctrine\ORM\QueryBuilder|\Doctrine\DBAL\Query\QueryBuilder $q
*/
protected function addSearchCommandWhereClause($q, $filter): array
{
[$expr, $parameters] = $this->addStandardSearchCommandWhereClause($q, $filter);
if ($expr) {
return [$expr, $parameters];
}
[$expr, $parameters] = parent::addSearchCommandWhereClause($q, $filter);
if ($expr) {
return [$expr, $parameters];
}
$command = $filter->command;
$unique = $this->generateRandomParameterName();
$returnParameter = false; // returning a parameter that is not used will lead to a Doctrine error
switch ($command) {
case $this->translator->trans('mautic.core.searchcommand.lang'):
$langUnique = $this->generateRandomParameterName();
$langValue = $filter->string.'_%';
$forceParameters = [
$langUnique => $langValue,
$unique => $filter->string,
];
$expr = $q->expr()->or(
$q->expr()->eq('e.language', ":$unique"),
$q->expr()->like('e.language', ":$langUnique")
);
break;
case $this->translator->trans('mautic.project.searchcommand.name'):
case $this->translator->trans('mautic.project.searchcommand.name', [], null, 'en_US'):
return $this->handleProjectFilter(
$this->_em->getConnection()->createQueryBuilder(),
'dynamic_content_id',
'dynamic_content_projects_xref',
$this->getTableAlias(),
$filter->string,
$filter->not
);
}
if ($expr && $filter->not) {
$expr = $q->expr()->not($expr);
}
if (!empty($forceParameters)) {
$parameters = $forceParameters;
} elseif ($returnParameter) {
$string = ($filter->strict) ? $filter->string : "%{$filter->string}%";
$parameters = ["$unique" => $string];
}
return [$expr, $parameters];
}
/**
* @return string[]
*/
public function getSearchCommands(): array
{
$commands = [
'mautic.core.searchcommand.ispublished',
'mautic.core.searchcommand.isunpublished',
'mautic.core.searchcommand.isuncategorized',
'mautic.core.searchcommand.ismine',
'mautic.core.searchcommand.category',
'mautic.core.searchcommand.lang',
'mautic.project.searchcommand.name',
];
return array_merge($commands, parent::getSearchCommands());
}
/**
* @return array<array<string>>
*/
protected function getDefaultOrder(): array
{
return [
['e.name', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'e';
}
/**
* Up the sent counts.
*
* @param int $increaseBy
*/
public function upSentCount($id, $increaseBy = 1): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'dynamic_content')
->set('sent_count', 'sent_count + '.(int) $increaseBy)
->where('id = '.(int) $id);
$q->executeStatement();
}
/**
* @param string $search
* @param int $limit
* @param int $start
* @param bool $viewOther
* @param bool $topLevel
* @param array $ignoreIds
* @param string $where
*
* @return array
*/
public function getDynamicContentList($search = '', $limit = 10, $start = 0, $viewOther = false, $topLevel = false, $ignoreIds = [], $where = null)
{
$q = $this->createQueryBuilder('e');
$q->select('partial e.{id, name, language}');
if (!empty($search)) {
if (is_array($search)) {
$search = array_map('intval', $search);
$q->andWhere($q->expr()->in('e.id', ':search'))
->setParameter('search', $search);
} else {
$q->andWhere($q->expr()->like('e.name', ':search'))
->setParameter('search', "%{$search}%");
}
}
if (!$viewOther) {
$q->andWhere($q->expr()->eq('e.createdBy', ':id'))
->setParameter('id', $this->currentUser->getId());
}
if ('translation' == $topLevel) {
// only get top level pages
$q->andWhere($q->expr()->isNull('e.translationParent'));
} elseif ('variant' == $topLevel) {
$q->andWhere($q->expr()->isNull('e.variantParent'));
}
if (!empty($ignoreIds)) {
$q->andWhere($q->expr()->notIn('e.id', ':dwc_ids'))
->setParameter('dwc_ids', $ignoreIds);
}
if ($where) {
$q->andWhere($where);
}
$q->orderBy('e.name');
if (!empty($limit)) {
$q->setFirstResult($start)
->setMaxResults($limit);
}
return $q->getQuery()->getArrayResult();
}
/**
* @return bool|object|null
*/
public function getDynamicContentForSlotFromCampaign($slot)
{
$qb = $this->_em->getConnection()->createQueryBuilder();
$qb->select('ce.properties')
->from(MAUTIC_TABLE_PREFIX.'campaign_events', 'ce')
->leftJoin('ce', MAUTIC_TABLE_PREFIX.'campaigns', 'c', 'c.id = ce.campaign_id')
->andWhere($qb->expr()->eq('ce.type', $qb->expr()->literal('dwc.decision')))
->andWhere($qb->expr()->like('ce.properties', ':slot'))
->setParameter('slot', '%'.$slot.'%')
->orderBy('c.is_published');
$result = $qb->executeQuery()->fetchAllAssociative();
foreach ($result as $item) {
$properties = Serializer::decode($item['properties']);
if (isset($properties['dynamicContent'])) {
$dwc = $this->getEntity($properties['dynamicContent']);
if ($dwc instanceof DynamicContent) {
return $dwc;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,299 @@
<?php
namespace Mautic\DynamicContentBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\LeadBundle\Entity\Lead;
class Stat
{
/**
* @var string
*/
private $id;
/**
* @var DynamicContent|null
*/
private $dynamicContent;
/**
* @var Lead|null
*/
private $lead;
/**
* @var \DateTimeInterface
*/
private $dateSent;
/**
* @var int|null
*/
private $sentCount;
/**
* @var int
*/
private $lastSent;
/**
* @var array
*/
private $sentDetails = [];
/**
* @var string|null
*/
private $source;
/**
* @var int|null
*/
private $sourceId;
/**
* @var array
*/
private $tokens = [];
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('dynamic_content_stats')
->setCustomRepositoryClass(StatRepository::class)
->addIndex(['dynamic_content_id', 'lead_id'], 'stat_dynamic_content_search')
->addIndex(['source', 'source_id'], 'stat_dynamic_content_source_search')
->addIndex(['date_sent'], 'stat_dynamic_content_date_sent');
$builder->addBigIntIdField();
$builder->createManyToOne('dynamicContent', 'DynamicContent')
->inversedBy('stats')
->addJoinColumn('dynamic_content_id', 'id', true, false, 'SET NULL')
->build();
$builder->addLead(true, 'SET NULL');
$builder->createField('dateSent', 'datetime')
->columnName('date_sent')
->build();
$builder->createField('source', 'string')
->nullable()
->build();
$builder->createField('sourceId', 'integer')
->columnName('source_id')
->nullable()
->build();
$builder->createField('tokens', 'array')
->nullable()
->build();
$builder->addNullableField('sentCount', 'integer', 'sent_count');
$builder->addNullableField('lastSent', 'datetime', 'last_sent');
$builder->addNullableField('sentDetails', 'array', 'sent_details');
}
/**
* Prepares the metadata for API usage.
*/
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
{
$metadata->setGroupPrefix('stat')
->addProperties(
[
'id',
'dateSent',
'source',
'sentCount',
'lastSent',
'sourceId',
'lead',
'dynamicContent',
]
)
->build();
}
public function addSentDetails($details): void
{
$this->sentDetails[] = $details;
++$this->sentCount;
}
/**
* Up the sent count.
*
* @return Stat
*/
public function upSentCount()
{
$count = (int) $this->sentCount + 1;
$this->sentCount = $count;
return $this;
}
public function getId(): int
{
return (int) $this->id;
}
/**
* @param int $id
*/
public function setId($id): void
{
$this->id = (string) $id;
}
/**
* @return DynamicContent
*/
public function getDynamicContent()
{
return $this->dynamicContent;
}
public function setDynamicContent(DynamicContent $dynamicContent): void
{
$this->dynamicContent = $dynamicContent;
}
/**
* @return Lead
*/
public function getLead()
{
return $this->lead;
}
/**
* @param Lead $lead
*/
public function setLead($lead): void
{
$this->lead = $lead;
}
/**
* @return \DateTimeInterface
*/
public function getDateSent()
{
return $this->dateSent;
}
/**
* @param \DateTime $dateSent
*/
public function setDateSent($dateSent): void
{
$this->dateSent = $dateSent;
}
/**
* @return int
*/
public function getSentCount()
{
return $this->sentCount;
}
/**
* @param int $sentCount
*/
public function setSentCount($sentCount): void
{
$this->sentCount = $sentCount;
}
/**
* @return int
*/
public function getLastSent()
{
return $this->lastSent;
}
/**
* @param int $lastSent
*/
public function setLastSent($lastSent): void
{
$this->lastSent = $lastSent;
}
/**
* @return array
*/
public function getSentDetails()
{
return $this->sentDetails;
}
/**
* @param array $sentDetails
*/
public function setSentDetails($sentDetails): void
{
$this->sentDetails = $sentDetails;
}
/**
* @return string
*/
public function getSource()
{
return $this->source;
}
/**
* @param string $source
*/
public function setSource($source): void
{
$this->source = $source;
}
/**
* @return int
*/
public function getSourceId()
{
return $this->sourceId;
}
/**
* @param int $sourceId
*/
public function setSourceId($sourceId): void
{
$this->sourceId = $sourceId;
}
/**
* @return array
*/
public function getTokens()
{
return $this->tokens;
}
/**
* @param array $tokens
*/
public function setTokens($tokens): void
{
$this->tokens = $tokens;
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace Mautic\DynamicContentBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\LeadBundle\Entity\TimelineTrait;
/**
* @extends CommonRepository<Stat>
*/
class StatRepository extends CommonRepository
{
use TimelineTrait;
public function getSentStats($dynamicContentId): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('s.lead_id')
->from(MAUTIC_TABLE_PREFIX.'dynamic_content_stats', 's')
->where('s.dynamic_content_id = :dynamic_content')
->setParameter('dynamic_content', $dynamicContentId);
$result = $q->executeQuery()->fetchAllAssociative();
// index by lead
$stats = [];
foreach ($result as $r) {
$stats[$r['lead_id']] = $r['lead_id'];
}
unset($result);
return $stats;
}
/**
* @param int|array $dynamicContentIds
*
* @return int
*/
public function getSentCount($dynamicContentIds = null)
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('count(s.id) as sent_count')
->from(MAUTIC_TABLE_PREFIX.'dynamic_content_stats', 's');
if ($dynamicContentIds) {
if (!is_array($dynamicContentIds)) {
$dynamicContentIds = [(int) $dynamicContentIds];
}
$q->where(
$q->expr()->in('s.dynamic_content_id', $dynamicContentIds)
);
}
$results = $q->executeQuery()->fetchAllAssociative();
return (isset($results[0])) ? $results[0]['sent_count'] : 0;
}
/**
* Get sent counts based grouped by dynamic content Id.
*
* @param array $dynamicContentIds
*/
public function getSentCounts($dynamicContentIds = [], ?\DateTime $fromDate = null): array
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->select('s.dynamic_content_id, count(s.id) as sent_count')
->from(MAUTIC_TABLE_PREFIX.'dynamic_content_stats', 's')
->andWhere(
$q->expr()->in('e.dynamic_content_id', $dynamicContentIds)
);
if (null !== $fromDate) {
// make sure the date is UTC
$dt = new DateTimeHelper($fromDate);
$q->andWhere(
$q->expr()->gte('e.date_sent', $q->expr()->literal($dt->toUtcString()))
);
}
$q->groupBy('e.dynamic_content_id');
// get a total number of sent DC stats first
$results = $q->executeQuery()->fetchAllAssociative();
$counts = [];
foreach ($results as $r) {
$counts[$r['dynamic_content_id']] = $r['sent_count'];
}
return $counts;
}
/**
* Get a lead's dynamic content stat.
*
* @param int|null $leadId
*
* @return array
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getLeadStats($leadId = null, array $options = [])
{
$query = $this->getEntityManager()->getConnection()->createQueryBuilder();
$query->select('dc.id AS dynamic_content_id, s.id, s.date_sent as dateSent, dc.name, s.sent_details as sentDetails, s.lead_id')
->from(MAUTIC_TABLE_PREFIX.'dynamic_content_stats', 's')
->leftJoin('s', MAUTIC_TABLE_PREFIX.'dynamic_content', 'dc', 'dc.id = s.dynamic_content_id');
if ($leadId) {
$query->where('s.lead_id = :leadId')
->setParameter('leadId', $leadId);
}
if (isset($options['search']) && $options['search']) {
$query->andWhere('dc.name LIKE :search')
->setParameter('search', '%'.$options['search'].'%');
}
return $this->getTimelineResults($query, $options, 'dc.name', 's.date_sent', ['sentDetails'], ['dateSent'], null, 's.id');
}
/**
* Updates lead ID (e.g. after a lead merge).
*/
public function updateLead($fromLeadId, $toLeadId): void
{
$q = $this->_em->getConnection()->createQueryBuilder();
$q->update(MAUTIC_TABLE_PREFIX.'dynamic_content_stats')
->set('lead_id', (int) $toLeadId)
->where('lead_id = '.(int) $fromLeadId)
->executeStatement();
}
/**
* Delete a stat.
*/
public function deleteStat($id): void
{
$this->_em->getConnection()->delete(MAUTIC_TABLE_PREFIX.'dynamic_content_stats', ['id' => (int) $id]);
}
public function getTableAlias(): string
{
return 's';
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Event;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Contracts\EventDispatcher\Event;
final class ContactFiltersEvaluateEvent extends Event
{
private bool $isEvaluated = false;
private bool $isMatched = false;
/**
* @param mixed[] $filters
*/
public function __construct(
private array $filters,
private Lead $contact,
) {
}
public function isMatch(): bool
{
return $this->isEvaluated() && $this->isMatched;
}
public function isEvaluated(): bool
{
return $this->isEvaluated;
}
public function setIsEvaluated(bool $evaluated): ContactFiltersEvaluateEvent
{
$this->isEvaluated = $evaluated;
return $this;
}
public function getContact(): Lead
{
return $this->contact;
}
public function setContact(Lead $contact): ContactFiltersEvaluateEvent
{
$this->contact = $contact;
return $this;
}
/**
* @return mixed[]
*/
public function getFilters(): array
{
return $this->filters;
}
public function isMatched(): bool
{
return $this->isMatched;
}
public function setIsMatched(bool $isMatched): ContactFiltersEvaluateEvent
{
$this->isMatched = $isMatched;
return $this;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Mautic\DynamicContentBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
class DynamicContentEvent extends CommonEvent
{
/**
* @param bool $isNew
*/
public function __construct(DynamicContent $entity, $isNew = false)
{
$this->entity = $entity;
$this->isNew = $isNew;
}
/**
* @return DynamicContent
*/
public function getDynamicContent()
{
return $this->entity;
}
public function setDynamicContent(DynamicContent $entity): void
{
$this->entity = $entity;
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Mautic\DynamicContentBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\BuildJsEvent;
use Mautic\CoreBundle\Twig\Helper\AssetsHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class BuildJsSubscriber implements EventSubscriberInterface
{
public function __construct(
private AssetsHelper $assetsHelper,
private TranslatorInterface $translator,
private RequestStack $requestStack,
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::BUILD_MAUTIC_JS => ['onBuildJs', 200],
];
}
/**
* Adds the MauticJS definition and core
* JS functions for use in Bundles. This
* must retain top priority of 1000.
*/
public function onBuildJs(BuildJsEvent $event): void
{
$dwcUrl = $this->router->generate('mautic_api_dynamicContent_action', ['objectAlias' => 'slotNamePlaceholder'], UrlGeneratorInterface::ABSOLUTE_URL);
$js = <<<JS
// call variable if doesnt exist
if (typeof MauticDomain == 'undefined') {
var MauticDomain = '{$this->requestStack->getCurrentRequest()->getSchemeAndHttpHost()}';
}
if (typeof MauticLang == 'undefined') {
var MauticLang = {
'submittingMessage': "{$this->translator->trans('mautic.form.submission.pleasewait')}"
};
}
MauticJS.replaceDynamicContent = function (params) {
params = params || {};
var dynamicContentSlots = document.querySelectorAll('.mautic-slot, [data-slot="dwc"]');
if (dynamicContentSlots.length) {
MauticJS.iterateCollection(dynamicContentSlots)(function(node, i) {
var slotName = node.dataset['slotName'];
if ('undefined' === typeof slotName) {
slotName = node.dataset['paramSlotName'];
}
if ('undefined' === typeof slotName) {
node.innerHTML = '';
return;
}
var url = '{$dwcUrl}'.replace('slotNamePlaceholder', slotName);
MauticJS.makeCORSRequest('GET', url, params, function(response, xhr) {
if (response.content) {
var dwcContent = response.content;
node.innerHTML = dwcContent;
if (response.id && response.sid) {
MauticJS.setTrackedContact(response);
}
// form load library
if (dwcContent.search("mauticform_wrapper") > 0) {
// if doesn't exist
if (typeof MauticSDK == 'undefined') {
MauticJS.insertScript('{$this->assetsHelper->getUrl('media/js/mautic-form.js', null, null, true)}');
// check initialize form library
var fileInterval = setInterval(function() {
if (typeof MauticSDK != 'undefined') {
MauticSDK.onLoad();
clearInterval(fileInterval); // clear interval
}
}, 100); // check every 100ms
} else {
MauticSDK.onLoad();
}
}
var m;
var regEx = /<script[^>]+src="?([^"\s]+)"?\s/g;
while (m = regEx.exec(dwcContent)) {
if ((m[1]).search("/focus/") > 0) {
MauticJS.insertScript(m[1]);
}
}
}
});
});
}
};
MauticJS.beforeFirstEventDelivery(MauticJS.replaceDynamicContent);
JS;
$event->appendJs($js, 'Mautic Dynamic Content');
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Mautic\DynamicContentBundle\EventListener;
use Mautic\CacheBundle\Cache\CacheProvider;
use Mautic\CampaignBundle\CampaignEvents;
use Mautic\CampaignBundle\Event\CampaignBuilderEvent;
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\DynamicContentBundle\DynamicContentEvents;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Form\Type\DynamicContentDecisionType;
use Mautic\DynamicContentBundle\Form\Type\DynamicContentSendType;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CampaignSubscriber implements EventSubscriberInterface
{
public function __construct(
private DynamicContentModel $dynamicContentModel,
protected CacheProvider $cache,
private EventDispatcherInterface $dispatcher,
) {
}
public static function getSubscribedEvents(): array
{
return [
CampaignEvents::CAMPAIGN_ON_BUILD => ['onCampaignBuild', 0],
DynamicContentEvents::ON_CAMPAIGN_TRIGGER_DECISION => ['onCampaignTriggerDecision', 0],
DynamicContentEvents::ON_CAMPAIGN_TRIGGER_ACTION => ['onCampaignTriggerAction', 0],
];
}
public function onCampaignBuild(CampaignBuilderEvent $event): void
{
$event->addAction(
'dwc.push_content',
[
'label' => 'mautic.dynamicContent.campaign.send_dwc',
'description' => 'mautic.dynamicContent.campaign.send_dwc.tooltip',
'eventName' => DynamicContentEvents::ON_CAMPAIGN_TRIGGER_ACTION,
'formType' => DynamicContentSendType::class,
'formTypeOptions' => ['update_select' => 'campaignevent_properties_dynamicContent'],
'formTheme' => '@MauticDynamicContent/FormTheme/DynamicContentPushList/_dynamiccontentpush_list_row.html.twig',
'timelineTemplate' => '@MauticDynamicContent/SubscribedEvents/Timeline/index.html.twig',
'hideTriggerMode' => true,
'connectionRestrictions' => [
'anchor' => [
'decision.inaction',
],
'source' => [
'decision' => [
'dwc.decision',
],
],
],
'channel' => 'dynamicContent',
'channelIdField' => 'dwc_slot_name',
]
);
$event->addDecision(
'dwc.decision',
[
'label' => 'mautic.dynamicContent.campaign.decision_dwc',
'description' => 'mautic.dynamicContent.campaign.decision_dwc.tooltip',
'eventName' => DynamicContentEvents::ON_CAMPAIGN_TRIGGER_DECISION,
'formType' => DynamicContentDecisionType::class,
'formTypeOptions' => ['update_select' => 'campaignevent_properties_dynamicContent'],
'formTheme' => '@MauticDynamicContent/FormTheme/DynamicContentDecisionList/_dynamiccontentdecision_list_row.html.twig',
'channel' => 'dynamicContent',
'channelIdField' => 'dynamicContent',
]
);
}
/**
* @return false|CampaignExecutionEvent
*
* @throws InvalidArgumentException
*/
public function onCampaignTriggerDecision(CampaignExecutionEvent $event)
{
$eventConfig = $event->getConfig();
$eventDetails = $event->getEventDetails();
$lead = $event->getLead();
// stop
if ($eventConfig['dwc_slot_name'] !== $eventDetails) {
$event->setResult(false);
return false;
}
$defaultDwc = $this->dynamicContentModel->getRepository()->getEntity($eventConfig['dynamicContent']);
if ($defaultDwc instanceof DynamicContent) {
// Set the default content in case none of the actions return data
$this->dynamicContentModel->setSlotContentForLead($defaultDwc, $lead, $eventDetails);
}
$item = $this->cache->getItem('dwc.slot_name.lead.'.$lead->getId());
$item->set($eventDetails);
$item->expiresAfter(86400); // one day in seconds
$this->cache->save($item);
$event->stopPropagation();
return $event->setResult(true);
}
public function onCampaignTriggerAction(CampaignExecutionEvent $event)
{
$eventConfig = $event->getConfig();
$lead = $event->getLead();
/* @var CacheItemInterface $item */
$item = $this->cache->getItem('dwc.slot_name.lead.'.$lead->getId());
$slot = $item->get();
$dwc = $this->dynamicContentModel->getRepository()->getEntity($eventConfig['dynamicContent']);
if ($dwc instanceof DynamicContent) {
// Use translation if available
list($ignore, $dwc) = $this->dynamicContentModel->getTranslatedEntity($dwc, $lead);
\assert($dwc instanceof DynamicContent);
if ($slot) {
$this->dynamicContentModel->setSlotContentForLead($dwc, $lead, $slot);
}
$stat = $this->dynamicContentModel->createStatEntry($dwc, $lead, $slot);
$tokenEvent = new TokenReplacementEvent($dwc->getContent(), $lead, ['slot' => $slot, 'dynamic_content_id' => $dwc->getId()]);
$tokenEvent->setStat($stat);
$this->dispatcher->dispatch($tokenEvent, DynamicContentEvents::TOKEN_REPLACEMENT);
$content = $tokenEvent->getContent();
$content = preg_replace('#<script(.*?)>(.*?)</script>#is', '', $content);
$event->stopPropagation();
$result = $event->setResult($content);
$event->setChannel('dynamicContent', $dwc->getId());
return $result;
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Mautic\DynamicContentBundle\EventListener;
use Mautic\ChannelBundle\ChannelEvents;
use Mautic\ChannelBundle\Event\ChannelEvent;
use Mautic\ReportBundle\Model\ReportModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ChannelSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ChannelEvents::ADD_CHANNEL => ['onAddChannel', 0],
];
}
public function onAddChannel(ChannelEvent $event): void
{
$event->addChannel(
'dynamicContent',
[
ReportModel::CHANNEL_FEATURE => [
'table' => 'dynamic_content',
],
]
);
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\EventListener;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Event\EntityExportEvent;
use Mautic\CoreBundle\Event\EntityImportAnalyzeEvent;
use Mautic\CoreBundle\Event\EntityImportEvent;
use Mautic\CoreBundle\Event\EntityImportUndoEvent;
use Mautic\CoreBundle\EventListener\ImportExportTrait;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class DynamicContentImportExportSubscriber implements EventSubscriberInterface
{
use ImportExportTrait;
public function __construct(
private DynamicContentModel $dynamicContentModel,
private EntityManagerInterface $entityManager,
private AuditLogModel $auditLogModel,
private IpLookupHelper $ipLookupHelper,
private DenormalizerInterface $serializer,
) {
}
public static function getSubscribedEvents(): array
{
return [
EntityExportEvent::class => ['onExport', 0],
EntityImportEvent::class => ['onImport', 0],
EntityImportUndoEvent::class => ['onUndoImport', 0],
EntityImportAnalyzeEvent::class => ['onDuplicationCheck', 0],
];
}
public function onExport(EntityExportEvent $event): void
{
if (DynamicContent::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$object = $this->dynamicContentModel->getEntity($event->getEntityId());
if (!$object) {
return;
}
$data = [
'id' => $object->getId(),
'translation_parent_id' => $object->getTranslationParent(),
'variant_parent_id' => $object->getVariantParent(),
'is_published' => $object->getIsPublished(),
'name' => $object->getName(),
'description' => $object->getDescription(),
'publish_up' => $object->getPublishUp(),
'publish_down' => $object->getPublishDown(),
'content' => $object->getContent(),
'utm_tags' => $object->getUtmTags(),
'lang' => $object->getLanguage(),
'variant_settings' => $object->getVariantSettings(),
'variant_start_date' => $object->getVariantStartDate(),
'filters' => $object->getFilters(),
'is_campaign_based' => $object->getIsCampaignBased(),
'slot_name' => $object->getSlotName(),
'uuid' => $object->getUuid(),
];
$event->addEntity(DynamicContent::ENTITY_NAME, $data);
$this->logAction('export', $object->getId(), $data);
}
public function onImport(EntityImportEvent $event): void
{
if (DynamicContent::ENTITY_NAME !== $event->getEntityName() || !$event->getEntityData()) {
return;
}
$stats = [
EntityImportEvent::NEW => ['names' => [], 'ids' => [], 'count' => 0],
EntityImportEvent::UPDATE => ['names' => [], 'ids' => [], 'count' => 0],
];
foreach ($event->getEntityData() as $element) {
$object = $this->entityManager->getRepository(DynamicContent::class)->findOneBy(['uuid' => $element['uuid']]);
$isNew = !$object;
$object ??= new DynamicContent();
$this->serializer->denormalize(
$element,
DynamicContent::class,
null,
['object_to_populate' => $object]
);
$this->dynamicContentModel->saveEntity($object);
$event->addEntityIdMap((int) $element['id'], $object->getId());
$status = $isNew ? EntityImportEvent::NEW : EntityImportEvent::UPDATE;
$stats[$status]['names'][] = $object->getName();
$stats[$status]['ids'][] = $object->getId();
++$stats[$status]['count'];
$this->logAction('import', $object->getId(), $element);
}
foreach ($stats as $status => $info) {
if ($info['count'] > 0) {
$event->setStatus($status, [DynamicContent::ENTITY_NAME => $info]);
}
}
}
public function onUndoImport(EntityImportUndoEvent $event): void
{
if (DynamicContent::ENTITY_NAME !== $event->getEntityName()) {
return;
}
$summary = $event->getSummary();
if (!isset($summary['ids']) || empty($summary['ids'])) {
return;
}
foreach ($summary['ids'] as $id) {
$entity = $this->entityManager->getRepository(DynamicContent::class)->find($id);
if ($entity) {
$this->entityManager->remove($entity);
$this->logAction('undo_import', $id, ['deletedEntity' => DynamicContent::class]);
}
}
$this->entityManager->flush();
}
public function onDuplicationCheck(EntityImportAnalyzeEvent $event): void
{
$this->performDuplicationCheck(
$event,
DynamicContent::ENTITY_NAME,
DynamicContent::class,
'name',
$this->entityManager
);
}
/**
* @param array<string, mixed> $details
*/
private function logAction(string $action, int $objectId, array $details): void
{
$this->auditLogModel->writeToLog([
'bundle' => 'dynamicContent',
'object' => 'dynamicContent',
'objectId' => $objectId,
'action' => $action,
'details' => $details,
'ipAddress' => $this->ipLookupHelper->getIpAddressFromRequest(),
]);
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace Mautic\DynamicContentBundle\EventListener;
use Mautic\AssetBundle\Helper\TokenHelper as AssetTokenHelper;
use Mautic\CoreBundle\Event as MauticEvents;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DynamicContentBundle\DynamicContentEvents;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Event as Events;
use Mautic\DynamicContentBundle\Helper\DynamicContentHelper;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait;
use Mautic\FormBundle\Helper\TokenHelper as FormTokenHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Exception\PrimaryCompanyNotFoundException;
use Mautic\LeadBundle\Helper\TokenHelper;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Entity\Trackable;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\Helper\TokenHelper as PageTokenHelper;
use Mautic\PageBundle\Model\TrackableModel;
use Mautic\PageBundle\PageEvents;
use MauticPlugin\MauticFocusBundle\Helper\TokenHelper as FocusTokenHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DynamicContentSubscriber implements EventSubscriberInterface
{
use MatchFilterForLeadTrait;
public function __construct(
private TrackableModel $trackableModel,
private PageTokenHelper $pageTokenHelper,
private AssetTokenHelper $assetTokenHelper,
private FormTokenHelper $formTokenHelper,
private FocusTokenHelper $focusTokenHelper,
private AuditLogModel $auditLogModel,
private DynamicContentHelper $dynamicContentHelper,
private DynamicContentModel $dynamicContentModel,
private CorePermissions $security,
private ContactTracker $contactTracker,
private CompanyModel $companyModel,
) {
}
public static function getSubscribedEvents(): array
{
return [
DynamicContentEvents::POST_SAVE => ['onPostSave', 0],
DynamicContentEvents::POST_DELETE => ['onDelete', 0],
DynamicContentEvents::TOKEN_REPLACEMENT => ['onTokenReplacement', 0],
PageEvents::PAGE_ON_DISPLAY => ['decodeTokens', 254],
];
}
/**
* Add an entry to the audit log.
*/
public function onPostSave(Events\DynamicContentEvent $event): void
{
$entity = $event->getDynamicContent();
if ($details = $event->getChanges()) {
$log = [
'bundle' => 'dynamicContent',
'object' => 'dynamicContent',
'objectId' => $entity->getId(),
'action' => ($event->isNew()) ? 'create' : 'update',
'details' => $details,
];
$this->auditLogModel->writeToLog($log);
}
}
/**
* Add a delete entry to the audit log.
*/
public function onDelete(Events\DynamicContentEvent $event): void
{
$entity = $event->getDynamicContent();
$log = [
'bundle' => 'dynamicContent',
'object' => 'dynamicContent',
'objectId' => $entity->deletedId,
'action' => 'delete',
'details' => ['name' => $entity->getName()],
];
$this->auditLogModel->writeToLog($log);
}
public function onTokenReplacement(MauticEvents\TokenReplacementEvent $event): void
{
/** @var Lead $lead */
$lead = $event->getLead();
$content = $event->getContent();
$clickthrough = $event->getClickthrough();
if ($lead instanceof Lead && $content) {
$leadArray = $lead->getProfileFields();
try {
$primaryCompany = $this->companyModel->getCompanyLeadRepository()->getPrimaryCompanyByLeadId($lead->getId());
$leadArray['companies'] = [$primaryCompany];
} catch (PrimaryCompanyNotFoundException) {
}
$tokens = array_merge(
TokenHelper::findLeadTokens($content, $leadArray),
$this->pageTokenHelper->findPageTokens($content, $clickthrough),
$this->assetTokenHelper->findAssetTokens($content, $clickthrough),
$this->formTokenHelper->findFormTokens($content),
$this->focusTokenHelper->findFocusTokens($content)
);
[$content, $trackables] = $this->trackableModel->parseContentForTrackables(
$content,
$tokens,
'dynamicContent',
$clickthrough['dynamic_content_id']
);
$dwc = $this->dynamicContentModel->getEntity($clickthrough['dynamic_content_id']);
$utmTags = [];
if ($dwc && $dwc instanceof DynamicContent) {
$utmTags = $dwc->getUtmTags();
}
/**
* @var string $token
* @var Trackable $trackable
*/
foreach ($trackables as $token => $trackable) {
$tokens[$token] = $this->trackableModel->generateTrackableUrl($trackable, $clickthrough, false, $utmTags);
}
$content = str_replace(array_keys($tokens), array_values($tokens), $content);
$event->setContent($content);
}
}
public function decodeTokens(PageDisplayEvent $event): void
{
if (!$lead = $event->getLead()) {
$lead = $this->security->isAnonymous() ? $this->contactTracker->getContact() : null;
}
if (!$lead) {
return;
}
$content = $event->getContent();
if (empty($content)) {
return;
}
$tokens = $this->dynamicContentHelper->findDwcTokens($content, $lead);
// replace slots
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->loadHTML(mb_encode_numericentity($content, [0x80, 0x10FFFF, 0, 0xFFFFF], 'UTF-8'), LIBXML_NOERROR);
$xpath = new \DOMXPath($dom);
$contentSlots = $xpath->query('//*[@data-slot="dwc"]');
for ($i = 0; $i < $contentSlots->length; ++$i) {
$slot = $contentSlots->item($i);
if (!$slotName = $slot->getAttribute('data-param-slot-name')) {
continue;
}
if (!$slotContent = $this->dynamicContentHelper->getDynamicContentForLead($slotName, $lead)) {
continue;
}
$newnode = $dom->createDocumentFragment();
$newnode->appendXML('<![CDATA['.mb_encode_numericentity($slotContent, [0x80, 0x10FFFF, 0, 0xFFFFF], 'UTF-8').']]>');
$slot->parentNode->replaceChild($newnode, $slot);
}
$content = $dom->saveHTML();
// These tokens need to be replaced after the content, because otherwise the replaced tokens will have encoded
// HTML entities, which do not conform the tests.
$result = [];
foreach ($tokens as $token => $dwc) {
$result[$token] = $dwc['content'];
}
$content = str_replace(array_keys($result), array_values($result), $content);
$event->setContent($content);
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Mautic\DynamicContentBundle\EventListener;
use Mautic\DynamicContentBundle\Entity\StatRepository;
use Mautic\LeadBundle\Event\LeadMergeEvent;
use Mautic\LeadBundle\Event\LeadTimelineEvent;
use Mautic\LeadBundle\LeadEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class LeadSubscriber implements EventSubscriberInterface
{
public function __construct(
private TranslatorInterface $translator,
private RouterInterface $router,
private StatRepository $statRepository,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::TIMELINE_ON_GENERATE => ['onTimelineGenerate', 0],
LeadEvents::LEAD_POST_MERGE => ['onLeadMerge', 0],
];
}
/**
* Compile events for the lead timeline.
*/
public function onTimelineGenerate(LeadTimelineEvent $event): void
{
// Set available event types
$eventTypeKey = 'dynamic.content.sent';
$eventTypeNameSent = $this->translator->trans('mautic.dynamic.content.triggered');
$event->addEventType($eventTypeKey, $eventTypeNameSent);
$event->addSerializerGroup('dwcList');
if (!$event->isApplicable($eventTypeKey)) {
return;
}
$stats = $this->statRepository->getLeadStats($event->getLeadId(), $event->getQueryOptions());
// Add total number to counter
$event->addToCounter($eventTypeKey, $stats);
if (!$event->isEngagementCount()) {
// Add the events to the event array
foreach ($stats['results'] as $stat) {
$contactId = $stat['lead_id'];
unset($stat['lead_id']);
if ($stat['dateSent']) {
$event->addEvent(
[
'event' => $eventTypeKey,
'eventId' => $eventTypeKey.$stat['id'],
'eventLabel' => [
'label' => $stat['name'],
'href' => $this->router->generate(
'mautic_dynamicContent_action',
['objectId' => $stat['dynamic_content_id'], 'objectAction' => 'view']
),
],
'eventType' => $eventTypeNameSent,
'timestamp' => $stat['dateSent'],
'extra' => [
'stat' => $stat,
'type' => 'sent',
],
'contentTemplate' => '@MauticDynamicContent/SubscribedEvents/Timeline/index.html.twig',
'icon' => 'ri-puzzle-2-line',
'contactId' => $contactId,
]
);
}
}
}
}
public function onLeadMerge(LeadMergeEvent $event): void
{
$this->statRepository->updateLead(
$event->getLoser()->getId(),
$event->getVictor()->getId()
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\DTO\GlobalSearchFilterDTO;
use Mautic\CoreBundle\Event\GlobalSearchEvent;
use Mautic\CoreBundle\Service\GlobalSearch;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SearchSubscriber implements EventSubscriberInterface
{
public function __construct(
private DynamicContentModel $dynamicContentModel,
private GlobalSearch $globalSearch,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::GLOBAL_SEARCH => ['onGlobalSearch', 0],
];
}
public function onGlobalSearch(GlobalSearchEvent $event): void
{
$filterDTO = new GlobalSearchFilterDTO($event->getSearchString());
$results = $this->globalSearch->performSearch(
$filterDTO,
$this->dynamicContentModel,
'@MauticDynamicContent/SubscribedEvents/Search/global.html.twig'
);
if (!empty($results)) {
$event->addResults('mautic.dynamicContent.dynamicContent', $results);
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Mautic\DynamicContentBundle\EventListener;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\EventListener\CommonStatsSubscriber;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DynamicContentBundle\Entity\DynamicContentLeadData;
use Mautic\DynamicContentBundle\Entity\Stat;
class StatsSubscriber extends CommonStatsSubscriber
{
public function __construct(CorePermissions $security, EntityManager $entityManager)
{
parent::__construct($security, $entityManager);
$this->addContactRestrictedRepositories(
[
Stat::class,
DynamicContentLeadData::class,
]
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Mautic\DynamicContentBundle\Form\Type;
use Mautic\LeadBundle\Form\Type\FilterTrait;
use Mautic\LeadBundle\Model\ListModel;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Exception\AccessException;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<mixed>
*/
class DwcEntryFiltersType extends AbstractType
{
use FilterTrait;
public function __construct(
private TranslatorInterface $translator,
private ListModel $listModel,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'glue',
ChoiceType::class,
[
'label' => false,
'choices' => [
'mautic.lead.list.form.glue.and' => 'and',
'mautic.lead.list.form.glue.or' => 'or',
],
'attr' => [
'class' => 'form-control not-chosen glue-select',
'onchange' => 'Mautic.updateFilterPositioning(this)',
],
]
);
$formModifier = function (FormEvent $event, $eventName): void {
$this->buildFiltersForm($eventName, $event, $this->translator);
};
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formModifier): void {
$formModifier($event, FormEvents::PRE_SET_DATA);
}
);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($formModifier): void {
$formModifier($event, FormEvents::PRE_SUBMIT);
}
);
$builder->add('field', HiddenType::class);
$builder->add('object', HiddenType::class);
$builder->add('type', HiddenType::class);
}
/**
* @throws AccessException
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired(
[
'countries',
'regions',
'timezones',
'locales',
'fields',
'deviceTypes',
'deviceBrands',
'deviceOs',
'tags',
'lists',
]
);
$resolver->setDefaults(
[
'label' => false,
'error_bubbling' => false,
// @see \Mautic\LeadBundle\Controller\AjaxController::loadSegmentFilterFormAction()
'lists' => $this->listModel->getChoiceFields()['lead']['leadlist']['properties']['list'],
]
);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['fields'] = $options['fields'];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Mautic\DynamicContentBundle\Form\Type;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
class DynamicContentDecisionType extends DynamicContentSendType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'dwc_slot_name',
TextType::class,
[
'label' => 'mautic.dynamicContent.send.slot_name',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.dynamicContent.send.slot_name.tooltip',
],
'required' => true,
'constraints' => [
new NotBlank(['message' => 'mautic.core.value.required']),
],
]
);
parent::buildForm($builder, $options);
$builder->add(
'dynamicContent',
DynamicContentListType::class,
[
'label' => 'mautic.dynamicContent.send.selectDynamicContents.default',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.dynamicContent.choose.dynamicContents',
'onchange' => 'Mautic.disabledDynamicContentAction()',
],
'where' => 'e.isCampaignBased = 1', // do not show dwc with filters
'multiple' => false,
'required' => true,
'constraints' => [
new NotBlank(['message' => 'mautic.core.value.required']),
],
]
);
}
public function getBlockPrefix(): string
{
return 'dwcdecision_list';
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Mautic\DynamicContentBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\EntityLookupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<mixed>
*/
class DynamicContentListType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(
[
'modal_route' => 'mautic_dynamicContent_action',
'modal_header' => 'mautic.dynamicContent.header.new',
'model' => 'dynamicContent',
'model_lookup_method' => 'getLookupResults',
'lookup_arguments' => fn (Options $options): array => [
'type' => 'dynamicContent',
'filter' => '$data',
'limit' => 0,
'start' => 0,
'options' => [
'top_level' => $options['top_level'],
'ignore_ids' => $options['ignore_ids'],
'where' => $options['where'],
],
],
'ajax_lookup_action' => function (Options $options): string {
$query = [
'top_level' => $options['top_level'],
'ignore_ids' => $options['ignore_ids'],
'where' => $options['where'],
];
return 'dynamicContent:getLookupChoiceList&'.http_build_query($query);
},
'multiple' => false,
'required' => false,
'top_level' => 'translation',
'ignore_ids' => [],
'where' => '',
]
);
}
public function getBlockPrefix(): string
{
return 'dwc_list';
}
public function getParent(): ?string
{
return EntityLookupType::class;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Mautic\DynamicContentBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ButtonType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<mixed>
*/
class DynamicContentSendType extends AbstractType
{
public function __construct(
protected RouterInterface $router,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'dynamicContent',
DynamicContentListType::class,
[
'label' => 'mautic.dynamicContent.send.selectDynamicContents',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.dynamicContent.choose.dynamicContents',
'onchange' => 'Mautic.disabledDynamicContentAction()',
],
'where' => 'e.isCampaignBased = 1', // do not show dwc with filters
'multiple' => false,
'required' => true,
'constraints' => [
new NotBlank(['message' => 'mautic.core.value.required']),
],
]
);
if (!empty($options['update_select'])) {
$windowUrl = $this->router->generate(
'mautic_dynamicContent_action',
[
'objectAction' => 'new',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'newDynamicContentButton',
ButtonType::class,
[
'label' => 'mautic.dynamicContent.send.new.dynamicContent',
'attr' => [
'class' => 'btn btn-primary btn-nospin',
'onclick' => 'Mautic.loadNewWindow({
"windowUrl": "'.$windowUrl.'"
})',
'icon' => 'ri-add-line',
],
]
);
$dynamicContent = is_array($options['data']) && array_key_exists('dynamicContent', $options['data']) ? $options['data']['dynamicContent']
: null;
// create button edit notification
$windowUrlEdit = $this->router->generate(
'mautic_dynamicContent_action',
[
'objectAction' => 'edit',
'objectId' => 'dynamicContentId',
'contentOnly' => 1,
'updateSelect' => $options['update_select'],
]
);
$builder->add(
'editDynamicContentButton',
ButtonType::class,
[
'label' => 'mautic.dynamicContent.send.edit.dynamicContent',
'attr' => [
'class' => 'btn btn-primary btn-nospin',
'onclick' => 'Mautic.loadNewWindow(Mautic.standardDynamicContentUrl({"windowUrl": "'.$windowUrlEdit.'"}))',
'disabled' => !isset($dynamicContent),
'icon' => 'ri-edit-line',
],
]
);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined(['update_select']);
}
public function getBlockPrefix(): string
{
return 'dwcsend_list';
}
}

View File

@@ -0,0 +1,388 @@
<?php
namespace Mautic\DynamicContentBundle\Form\Type;
use DeviceDetector\Parser\Device\AbstractDeviceParser as DeviceParser;
use DeviceDetector\Parser\OperatingSystem;
use Doctrine\ORM\EntityManager;
use Mautic\CategoryBundle\Form\Type\CategoryListType;
use Mautic\CoreBundle\Form\DataTransformer\IdToEntityModelTransformer;
use Mautic\CoreBundle\Form\EventListener\CleanFormSubscriber;
use Mautic\CoreBundle\Form\EventListener\FormExitSubscriber;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\PublishDownDateType;
use Mautic\CoreBundle\Form\Type\PublishUpDateType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\DynamicContentBundle\DynamicContent\TypeList;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\EmailBundle\Form\Type\EmailUtmTagsType;
use Mautic\LeadBundle\Form\DataTransformer\FieldFilterTransformer;
use Mautic\LeadBundle\Helper\FormFieldHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Model\ListModel;
use Mautic\ProjectBundle\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @extends AbstractType<DynamicContent>
*/
class DynamicContentType extends AbstractType
{
/**
* @var mixed[]
*/
private array $fieldChoices;
/**
* @var mixed[]
*/
private array $countryChoices;
/**
* @var mixed[]
*/
private array $regionChoices;
private $timezoneChoices;
/**
* @var mixed[]
*/
private array $localeChoices;
/**
* @var mixed[]
*/
private array $deviceTypesChoices;
private $deviceBrandsChoices;
/**
* @var mixed[]
*/
private array $deviceOsChoices;
/**
* @var array<string, string>
*/
private array $tagChoices = [];
/**
* @throws \InvalidArgumentException
*/
public function __construct(
private EntityManager $em,
ListModel $listModel,
private TranslatorInterface $translator,
private LeadModel $leadModel,
private TypeList $typeList,
) {
$this->fieldChoices = $listModel->getChoiceFields();
$this->timezoneChoices = FormFieldHelper::getTimezonesChoices();
$this->countryChoices = FormFieldHelper::getCountryChoices();
$this->regionChoices = FormFieldHelper::getRegionChoices();
$this->localeChoices = FormFieldHelper::getLocaleChoices();
$this->typeList = $typeList;
$this->filterFieldChoices();
$tags = $leadModel->getTagList();
foreach ($tags as $tag) {
$this->tagChoices[$tag['value']] = $tag['label'];
}
$this->deviceTypesChoices = array_combine(DeviceParser::getAvailableDeviceTypeNames(), DeviceParser::getAvailableDeviceTypeNames());
$this->deviceBrandsChoices = DeviceParser::$deviceBrands;
$this->deviceOsChoices = array_combine(
array_keys(OperatingSystem::getAvailableOperatingSystemFamilies()),
array_keys(OperatingSystem::getAvailableOperatingSystemFamilies())
);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventSubscriber(new CleanFormSubscriber(['content' => 'html']));
$builder->addEventSubscriber(new FormExitSubscriber('dynamicContent.dynamicContent', $options));
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.dynamicContent.form.internal.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
]
);
$builder->add(
'slotName',
TextType::class,
[
'label' => 'mautic.dynamicContent.send.slot_name',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.dynamicContent.send.slot_name.tooltip',
],
]
);
$builder->add(
'description',
TextareaType::class,
[
'label' => 'mautic.dynamicContent.description',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control'],
'required' => false,
]
);
$builder->add('type', ChoiceType::class, [
'label' => 'mautic.dynamicContent.type.label',
'choices' => $this->typeList->getChoices(),
'attr' => [
'class' => 'form-control',
'onchange' => 'Mautic.toggleContentEditor()',
],
]);
$builder->add('isPublished', YesNoButtonGroupType::class, [
'label' => 'mautic.core.form.available',
]);
$builder->add(
'isCampaignBased',
YesNoButtonGroupType::class,
[
'label' => 'mautic.dwc.form.is_campaign_based',
'data' => (bool) $options['data']->isCampaignBased(),
'attr' => [
'tooltip' => 'mautic.dwc.form.is_campaign_based.tooltip',
'onchange' => 'Mautic.toggleDwcFilters()',
],
]
);
$builder->add(
'language',
LocaleType::class,
[
'label' => 'mautic.core.language',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'required' => false,
]
);
$builder->add('publishUp', PublishUpDateType::class);
$builder->add('publishDown', PublishDownDateType::class);
$builder->add(
'utmTags',
EmailUtmTagsType::class,
[
'label' => 'mautic.email.utm_tags',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.email.utm_tags.tooltip',
],
'required' => false,
]
);
$transformer = new IdToEntityModelTransformer($this->em, DynamicContent::class);
$builder->add(
$builder->create(
'translationParent',
DynamicContentListType::class,
[
'label' => 'mautic.core.form.translation_parent',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.form.translation_parent.help',
],
'required' => false,
'multiple' => false,
'placeholder' => 'mautic.core.form.translation_parent.empty',
'top_level' => 'translation',
'ignore_ids' => [(int) $options['data']->getId()],
]
)->addModelTransformer($transformer)
);
$builder->add(
'category',
CategoryListType::class,
['bundle' => 'dynamicContent']
);
$builder->add('projects', ProjectType::class);
if (!empty($options['update_select'])) {
$builder->add(
'buttons',
FormButtonsType::class,
['apply_text' => false]
);
$builder->add(
'updateSelect',
HiddenType::class,
[
'data' => $options['update_select'],
'mapped' => false,
]
);
} else {
$builder->add(
'buttons',
FormButtonsType::class
);
}
$filterModalTransformer = new FieldFilterTransformer($this->translator);
$builder->add(
$builder->create(
'filters',
CollectionType::class,
[
'entry_type' => DwcEntryFiltersType::class,
'entry_options' => [
'countries' => $this->countryChoices,
'regions' => $this->regionChoices,
'timezones' => $this->timezoneChoices,
'locales' => $this->localeChoices,
'fields' => $this->fieldChoices,
'deviceTypes' => $this->deviceTypesChoices,
'deviceBrands' => $this->deviceBrandsChoices,
'deviceOs' => $this->deviceOsChoices,
'tags' => $this->tagChoices,
],
'error_bubbling' => false,
'mapped' => true,
'allow_add' => true,
'allow_delete' => true,
]
)->addModelTransformer($filterModalTransformer)
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event): void {
// delete default prototype values
$data = $event->getData();
unset($data['filters']['__name__']);
$event->setData($data);
}
);
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
/** @var DynamicContent|null $dynamicContent */
$dynamicContent = $event->getData();
$this->addContentField($event->getForm(), $dynamicContent?->getType());
});
$builder->get('type')->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
$form = $event->getForm();
$this->addContentField($form->getParent(), $form->getData());
});
}
/**
* @throws \Symfony\Component\OptionsResolver\Exception\AccessException
*/
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => DynamicContent::class,
'label' => false,
'error_bubbling' => false,
]);
$resolver->setDefined(['update_select']);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['fields'] = $this->fieldChoices;
$view->vars['countries'] = $this->countryChoices;
$view->vars['regions'] = $this->regionChoices;
$view->vars['timezones'] = $this->timezoneChoices;
$view->vars['deviceTypes'] = $this->deviceTypesChoices;
$view->vars['deviceBrands'] = $this->deviceBrandsChoices;
$view->vars['deviceOs'] = $this->deviceOsChoices;
$view->vars['tags'] = $this->tagChoices;
$view->vars['locales'] = $this->localeChoices;
}
private function filterFieldChoices(): void
{
unset($this->fieldChoices['company']);
$customFields = $this->leadModel->getRepository()->getCustomFieldList('lead');
$this->fieldChoices['lead'] = array_filter(
$this->fieldChoices['lead'],
fn ($key): bool => in_array(
$key,
array_merge(
array_keys($customFields[0]),
['date_added', 'date_modified', 'device_brand', 'device_model', 'device_os', 'device_type', 'tags', 'leadlist']
),
true
),
ARRAY_FILTER_USE_KEY
);
}
/**
* @param FormInterface<FormInterface> $form
*/
private function addContentField(FormInterface $form, ?string $type): void
{
$enableEditor = TypeList::HTML === $type;
$editorClass = 'editor editor-advanced editor-builder-tokens';
$form->add('content', TextareaType::class, [
'label' => 'mautic.dynamicContent.form.content',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'tooltip' => 'mautic.dynamicContent.form.content.help',
'class' => 'form-control'.($enableEditor ? ' '.$editorClass : ''),
'rows' => 15,
'data-editor-enable' => $enableEditor,
'data-editor-class' => $editorClass,
'data-token-callback' => 'email:getBuilderTokens',
'data-token-activator' => '{',
],
'required' => false,
]);
}
public function getBlockPrefix(): string
{
return 'dwc';
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace Mautic\DynamicContentBundle\Helper;
use Mautic\CampaignBundle\Executioner\RealTimeExecutioner;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\DynamicContentBundle\DynamicContentEvents;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\EmailBundle\EventListener\MatchFilterForLeadTrait;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\Tag;
use Mautic\LeadBundle\Model\LeadModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class DynamicContentHelper
{
use MatchFilterForLeadTrait;
/**
* @const DYNAMIC_CONTENT_REGEX
*/
public const DYNAMIC_CONTENT_REGEX = '/{(dynamiccontent)=(\w+)(?:\/}|}(?:([^{]*(?:{(?!\/\1})[^{]*)*){\/\1})?)/is';
/**
* @const DYNAMIC_WEB_CONTENT_REGEX
*/
public const DYNAMIC_WEB_CONTENT_REGEX = '/{dwc=(.*?)}/';
public function __construct(
protected DynamicContentModel $dynamicContentModel,
protected RealTimeExecutioner $realTimeExecutioner,
protected EventDispatcherInterface $dispatcher,
protected LeadModel $leadModel,
) {
}
/**
* @param string $slot
* @param Lead|array $lead
*
* @return string
*/
public function getDynamicContentForLead($slot, $lead)
{
// Attempt campaign slots first
$dwcActionResponse = $this->realTimeExecutioner->execute('dwc.decision', $slot, 'dynamicContent')->getActionResponses('dwc.push_content');
if (!empty($dwcActionResponse)) {
return array_shift($dwcActionResponse);
}
// Attempt stored content second
$data = $this->dynamicContentModel->getSlotContentForLead($slot, $lead);
if (!empty($data)) {
$content = $data['content'];
$dwc = $this->dynamicContentModel->getEntity($data['id']);
if ($dwc instanceof DynamicContent) {
$content = $this->getRealDynamicContent($slot, $lead, $dwc);
}
return $content;
}
// Finally attempt standalone DWC
return $this->getDynamicContentSlotForLead($slot, $lead);
}
/**
* @param string $slotName
* @param Lead|array $lead
*
* @return string
*/
public function getDynamicContentSlotForLead($slotName, $lead)
{
$leadArray = [];
if ($lead instanceof Lead) {
$leadArray = $this->convertLeadToArray($lead);
}
$dwcs = $this->getDwcsBySlotName($slotName, true);
/** @var DynamicContent $dwc */
foreach ($dwcs as $dwc) {
if ($dwc->getIsCampaignBased()) {
continue;
}
if ($lead && $this->filtersMatchContact($dwc->getFilters(), $leadArray)) {
return $lead ? $this->getRealDynamicContent($dwc->getSlotName(), $lead, $dwc) : '';
}
}
return '';
}
/**
* @param string $content
* @param Lead|array $lead
*
* @return string Content with the {content} tokens replaced with dynamic content
*/
public function replaceTokensInContent($content, $lead)
{
// Find all dynamic content tags
preg_match_all(self::DYNAMIC_CONTENT_REGEX, $content, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$slot = $match[2];
$defaultContent = $match[3];
$dwcContent = $this->getDynamicContentForLead($slot, $lead);
if (!$dwcContent) {
$dwcContent = $defaultContent;
}
$content = str_replace($matches[0], $dwcContent, $content);
}
return $content;
}
/**
* @param string $content
* @param Lead|null $lead
*/
public function findDwcTokens($content, $lead): array
{
preg_match_all(self::DYNAMIC_WEB_CONTENT_REGEX, $content, $matches);
$tokens = [];
if (!empty($matches[1])) {
foreach ($matches[1] as $key => $slotName) {
$token = $matches[0][$key];
if (!empty($tokens[$token])) {
continue;
}
$dwcs = $this->getDwcsBySlotName($slotName);
/** @var DynamicContent $dwc */
foreach ($dwcs as $dwc) {
if ($dwc->getIsCampaignBased()) {
continue;
}
$content = $lead ? $this->getDynamicContentSlotForLead($dwc->getSlotName(), $lead) : '';
$tokens[$token]['content'] = $content;
$tokens[$token]['filters'] = $dwc->getFilters();
}
}
unset($matches);
}
return $tokens;
}
/**
* @param string $slot
* @param Lead|mixed[] $lead
*
* @return string
*/
public function getRealDynamicContent($slot, $lead, DynamicContent $dwc)
{
$content = $dwc->getContent();
// Determine a translation based on contact's preferred locale
/** @var DynamicContent $translation */
list($ignore, $translation) = $this->dynamicContentModel->getTranslatedEntity($dwc, $lead);
if ($translation !== $dwc) {
// Use translated version of content
$dwc = $translation;
$content = $dwc->getContent();
}
$stat = $this->dynamicContentModel->createStatEntry($dwc, $lead, $slot);
$tokenEvent = new TokenReplacementEvent($content, $lead, ['slot' => $slot, 'dynamic_content_id' => $dwc->getId()]);
$tokenEvent->setStat($stat);
$this->dispatcher->dispatch($tokenEvent, DynamicContentEvents::TOKEN_REPLACEMENT);
return $tokenEvent->getContent();
}
/**
* @param string $slotName
* @param bool $publishedOnly
*
* @return array|\Doctrine\ORM\Tools\Pagination\Paginator
*/
public function getDwcsBySlotName($slotName, $publishedOnly = false)
{
$filter = [
'where' => [
[
'col' => 'e.slotName',
'expr' => 'eq',
'val' => $slotName,
],
],
];
if ($publishedOnly) {
$filter['where'][] = [
'col' => 'e.isPublished',
'expr' => 'eq',
'val' => 1,
];
}
return $this->dynamicContentModel->getEntities(
[
'filter' => $filter,
'ignore_paginator' => true,
]
);
}
/**
* @param Lead $lead
*/
public function convertLeadToArray($lead): array
{
return array_merge(
$lead->getProfileFields(),
[
'tags' => array_map(
fn (Tag $v) => $v->getId(),
$lead->getTags()->toArray()
),
]
);
}
/**
* @param mixed[] $filters
* @param mixed[] $contactArray
*/
private function filtersMatchContact(array $filters, array $contactArray): bool
{
if (empty($contactArray['id'])) {
return false;
}
// We attempt even listeners first
if ($this->dispatcher->hasListeners(DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE)) {
/** @var Lead $contact */
$contact = $this->leadModel->getEntity($contactArray['id']);
$event = new ContactFiltersEvaluateEvent($filters, $contact);
$this->dispatcher->dispatch($event, DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE);
if ($event->isMatch()) {
return true;
}
}
return $this->matchFilterForLead($filters, $contactArray);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Mautic\DynamicContentBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticDynamicContentBundle extends Bundle
{
}

View File

@@ -0,0 +1,343 @@
<?php
namespace Mautic\DynamicContentBundle\Model;
use Doctrine\DBAL\Query\QueryBuilder;
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
use Mautic\CoreBundle\Helper\Chart\LineChart;
use Mautic\CoreBundle\Model\AjaxLookupModelInterface;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Model\GlobalSearchInterface;
use Mautic\CoreBundle\Model\TranslationModelTrait;
use Mautic\CoreBundle\Model\VariantModelTrait;
use Mautic\DynamicContentBundle\DynamicContentEvents;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Entity\DynamicContentRepository;
use Mautic\DynamicContentBundle\Entity\Stat;
use Mautic\DynamicContentBundle\Event\DynamicContentEvent;
use Mautic\DynamicContentBundle\Form\Type\DynamicContentType;
use Mautic\LeadBundle\Entity\Lead;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @extends FormModel<DynamicContent>
*
* @implements AjaxLookupModelInterface<DynamicContent>
*/
class DynamicContentModel extends FormModel implements AjaxLookupModelInterface, GlobalSearchInterface
{
use VariantModelTrait;
use TranslationModelTrait;
/**
* Retrieve the permissions base.
*/
public function getPermissionBase(): string
{
return 'dynamiccontent:dynamiccontents';
}
/**
* @return DynamicContentRepository
*/
public function getRepository()
{
/** @var DynamicContentRepository $repo */
$repo = $this->em->getRepository(DynamicContent::class);
$repo->setTranslator($this->translator);
$repo->setCurrentUser($this->userHelper->getUser());
return $repo;
}
/**
* @return \Mautic\DynamicContentBundle\Entity\StatRepository
*/
public function getStatRepository()
{
return $this->em->getRepository(Stat::class);
}
/**
* @param object $entity
* @param bool $unlock
*/
public function saveEntity($entity, $unlock = true): void
{
parent::saveEntity($entity, $unlock);
$this->postTranslationEntitySave($entity);
}
public function getEntity($id = null): ?DynamicContent
{
if (null === $id) {
return new DynamicContent();
}
return parent::getEntity($id);
}
public function checkEntityBySlotName(string $slotName, ?string $type = null, string $typeCondition = '=',
?int $skipId = null): bool
{
$qb = $this->em->getConnection()->createQueryBuilder();
$qb->select('1')
->from(MAUTIC_TABLE_PREFIX.'dynamic_content')
->where($qb->expr()->eq('slot_name', ':slot_name'))
->setParameter('slot_name', $slotName)
->setMaxResults(1);
if (!empty($type)) {
if (!in_array($typeCondition, ['=', '<>', '!='], true)) {
throw new \InvalidArgumentException("Invalid operator '$typeCondition'");
}
$qb->andWhere("type {$typeCondition} :type");
$qb->setParameter('type', $type);
}
if (null !== $skipId) {
$qb->andWhere('id != :id');
$qb->setParameter('id', $skipId);
}
return (bool) $qb->executeQuery()->fetchOne();
}
/**
* @param string|null $action
* @param array $options
*
* @throws \InvalidArgumentException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): FormInterface
{
if (!$entity instanceof DynamicContent) {
throw new \InvalidArgumentException('Entity must be of class DynamicContent');
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(DynamicContentType::class, $entity, $options);
}
public function setSlotContentForLead(DynamicContent $dwc, Lead $lead, $slot): void
{
$qb = $this->em->getConnection()->createQueryBuilder();
$qb->insert(MAUTIC_TABLE_PREFIX.'dynamic_content_lead_data')
->values([
'lead_id' => $lead->getId(),
'dynamic_content_id' => $dwc->getId(),
'slot' => ':slot',
'date_added' => $qb->expr()->literal((new \DateTime())->format('Y-m-d H:i:s')),
])->setParameter('slot', $slot);
$qb->executeStatement();
}
/**
* @param string $slot
* @param Lead|array $lead
*
* @return array<string, mixed>|false
*/
public function getSlotContentForLead($slot, $lead)
{
if (!$lead) {
return [];
}
$qb = $this->em->getConnection()->createQueryBuilder();
$id = $lead instanceof Lead ? $lead->getId() : $lead['id'];
$qb->select('dc.id, dc.content')
->from(MAUTIC_TABLE_PREFIX.'dynamic_content', 'dc')
->leftJoin('dc', MAUTIC_TABLE_PREFIX.'dynamic_content_lead_data', 'dcld', 'dcld.dynamic_content_id = dc.id')
->andWhere($qb->expr()->eq('dcld.slot', ':slot'))
->andWhere($qb->expr()->eq('dcld.lead_id', ':lead_id'))
->andWhere($qb->expr()->eq('dc.is_published', 1))
->setParameter('slot', $slot)
->setParameter('lead_id', $id)
->orderBy('dcld.date_added', 'DESC')
->addOrderBy('dcld.id', 'DESC');
return $qb->executeQuery()->fetchAssociative();
}
/**
* @param Lead|array $lead
* @param string $source
*
* @return Stat|null
*/
public function createStatEntry(DynamicContent $dynamicContent, $lead, $source = null)
{
if (empty($lead)) {
return null;
}
if ($lead instanceof Lead && !$lead->getId()) {
return null;
}
if (is_array($lead)) {
if (empty($lead['id'])) {
return null;
}
$lead = $this->em->getReference(Lead::class, $lead['id']);
}
$stat = new Stat();
$stat->setDateSent(new \DateTime());
$stat->setLead($lead);
$stat->setDynamicContent($dynamicContent);
$stat->setSource($source);
$this->getStatRepository()->saveEntity($stat);
return $stat;
}
/**
* @throws MethodNotAllowedHttpException
*/
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
{
if (!$entity instanceof DynamicContent) {
throw new MethodNotAllowedHttpException(['Dynamic Content']);
}
switch ($action) {
case 'pre_save':
$name = DynamicContentEvents::PRE_SAVE;
break;
case 'post_save':
$name = DynamicContentEvents::POST_SAVE;
break;
case 'pre_delete':
$name = DynamicContentEvents::PRE_DELETE;
break;
case 'post_delete':
$name = DynamicContentEvents::POST_DELETE;
break;
default:
return null;
}
if ($this->dispatcher->hasListeners($name)) {
if (empty($event)) {
$event = new DynamicContentEvent($entity, $isNew);
$event->setEntityManager($this->em);
}
$this->dispatcher->dispatch($event, $name);
return $event;
} else {
return null;
}
}
/**
* Joins the page table and limits created_by to currently logged in user.
*/
public function limitQueryToCreator(QueryBuilder &$q): void
{
$q->join('t', MAUTIC_TABLE_PREFIX.'dynamic_content', 'd', 'd.id = t.dynamic_content_id')
->andWhere('d.created_by = :userId')
->setParameter('userId', $this->userHelper->getUser()->getId());
}
/**
* Get line chart data of hits.
*
* @param char $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
* @param string $dateFormat
* @param array $filter
* @param bool $canViewOthers
*/
public function getHitsLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true): array
{
$flag = null;
if (isset($filter['flag'])) {
$flag = $filter['flag'];
unset($filter['flag']);
}
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
if (!$flag || 'total_and_unique' === $flag) {
$q = $query->prepareTimeDataQuery('dynamic_content_stats', 'date_sent', $filter);
if (!$canViewOthers) {
$this->limitQueryToCreator($q);
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.dynamicContent.show.total.views'), $data);
}
if ('unique' === $flag || 'total_and_unique' === $flag) {
$q = $query->prepareTimeDataQuery('dynamic_content_stats', 'date_sent', $filter);
$q->groupBy('t.lead_id, t.date_sent');
if (!$canViewOthers) {
$this->limitQueryToCreator($q);
}
$data = $query->loadAndBuildTimeData($q);
$chart->setDataset($this->translator->trans('mautic.dynamicContent.show.unique.views'), $data);
}
return $chart->render();
}
/**
* @param string $filter
* @param int $limit
* @param int $start
* @param array $options
*
* @return mixed[]
*/
public function getLookupResults($type, $filter = '', $limit = 10, $start = 0, $options = []): array
{
$results = [];
switch ($type) {
case 'dynamicContent':
$entities = $this->getRepository()->getDynamicContentList(
$filter,
$limit,
$start,
$this->security->isGranted($this->getPermissionBase().':viewother'),
$options['top_level'] ?? false,
$options['ignore_ids'] ?? [],
$options['where'] ?? ''
);
foreach ($entities as $entity) {
$results[$entity['language']][$entity['id']] = $entity['name'];
}
// sort by language
ksort($results);
break;
}
return $results;
}
}

View File

@@ -0,0 +1,29 @@
{% set troubleshootingContent %}
<div class='dwc--generator-troubleshooting'>
<i class='ri-close-circle-line ri-xl mb-sm mt-lg d-inline-flex'></i>
<h5 class='fw-sb mb-xs'>{{ 'mautic.dynamiccontent.generator.troubleshooting.content_not_displaying'|trans }}</h5>
<p>{{ 'mautic.dynamiccontent.generator.troubleshooting.content_not_displaying_text'|trans }}</p>
<i class='ri-javascript-line ri-xl mb-sm mt-lg d-inline-flex'></i>
<h5 class='fw-sb mb-xs'>{{ 'mautic.dynamiccontent.generator.troubleshooting.javascript'|trans }}</h5>
<p>{{ 'mautic.dynamiccontent.generator.troubleshooting.javascript_text'|trans }}</p>
<i class='ri-input-field ri-xl mb-sm mt-lg d-inline-flex'></i>
<h5 class='fw-sb mb-xs'>{{ 'mautic.dynamiccontent.generator.troubleshooting.default_content'|trans }}</h5>
<p>{{ 'mautic.dynamiccontent.generator.troubleshooting.default_content_text'|trans }}</p>
<i class='ri-server-line ri-xl mb-sm mt-lg d-inline-flex'></i>
<h5 class='fw-sb mb-xs'>{{ 'mautic.dynamiccontent.generator.troubleshooting.cache'|trans }}</h5>
<p>{{ 'mautic.dynamiccontent.generator.troubleshooting.cache_text'|trans }}</p>
</div>
{% endset %}
<div>
{% include '@MauticCore/Components/toggletip.html.twig' with {
label: 'mautic.core.troubleshooting',
size: 'xs',
icon: 'ri-question-answer-line',
content: troubleshootingContent,
html: true,
trigger: 'click'
} %}
</div>

View File

@@ -0,0 +1,150 @@
<link rel="stylesheet" type="text/css" href="{{ asset('app/bundles/DynamicContentBundle/Assets/css/dwc-generator.css') }}">
{% set htmlcode %}
<pre class="dwc--generator-content-code d-flex fd-column fg-1 gap-md pa-0 fs-16 text-helper">{{ '<div
data-slot="dwc"
data-param-slot-name="' ~ entity.slotName ~ '">'|raw }}<div class="dwc--generator-content-editable layer-two d-inline-flex gap-4 ai-center ml-lg text-primary"><i class="ri-pencil-line ri-sm" title="{{ 'mautic.dynamiccontent.generator.default_content_tooltip'|trans }}" data-toggle="tooltip"></i><input class="form-control dwc--generator-content-input" type="text" placeholder="{{ 'mautic.dynamiccontent.generator.input_placeholder'|trans }}"></div>{{ '</div>'|escape }}</pre>
{% endset %}
<div class="row mt-lg">
<div class="col-xs-12">
<h2 class="mt-lg mb-md">{{ 'mautic.dynamiccontent.generator.deployment_options'|trans }}</h2>
<p class="text-muted mb-lg">{{ 'mautic.dynamiccontent.generator.deployment_instructions'|trans }}</p>
</div>
<div class="col-xs-12 col-md-4">
<ul class="list-group" role="tablist">
<li role="presentation" class="list-group-item active">
<a href="#dwc--generator-html" aria-controls="dwc--generator-html" role="tab" data-toggle="tab">
<div class="d-flex jc-space-between mb-sm">
<h4 class="list-group-item-heading fw-sb">{{ 'mautic.dynamiccontent.generator.html'|trans }}</h4>
<i class="ri-html5-fill ri-lg"></i>
</div>
<p class="list-group-item-text">{{ 'mautic.dynamiccontent.generator.html_description'|trans }}</p>
</a>
</li>
<li role="presentation" class="list-group-item">
<a href="#dwc--generator-plugins" aria-controls="dwc--generator-plugins" role="tab" data-toggle="tab">
<div class="d-flex jc-space-between mb-sm">
<h4 class="list-group-item-heading fw-sb">{{ 'mautic.dynamiccontent.generator.plugins'|trans }}</h4>
<i class="ri-apps-2-add-fill ri-lg"></i>
</div>
<p class="list-group-item-text">{{ 'mautic.dynamiccontent.generator.plugins_description'|trans }}</p>
</a>
</li>
</ul>
{{ include('@MauticDynamicContent/DynamicContent/StandaloneSlotGenerator/dwc-generator--help.html.twig') }}
</div>
<div class="col-xs-12 col-md-8 tab-content">
<div role="tabpanel" class="tab-pane fade in active" id="dwc--generator-html">
<div class="tile dwc--generator-wrapper">
<div class="d-flex fg-1">
<div class="dwc--generator-toolbar pa-xs layer-two d-flex fd-column jc-space-between ai-start mr-md">
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
icon: 'ri-code-s-slash-line ri-lg',
label: 'mautic.dynamiccontent.generator.switch_html_tag',
icon_only: true,
variant: 'ghost',
size: 'sm',
attributes: {
'class': 'ma-0',
'type': 'button'
}
},
{
icon: 'ri-question-line ri-lg',
label: 'mautic.dynamiccontent.generator.more_information.html',
icon_only: true,
variant: 'ghost',
size: 'sm',
attributes: {
'class': 'ma-0',
'type': 'button',
'data-toggle': 'popover',
'title': 'mautic.dynamiccontent.generator.html_tag_title'|trans,
'data-content': 'mautic.dynamiccontent.generator.html_tag_info'|trans,
'data-container': 'body',
}
}
]
} %}
</div>
<div class="dwc--generator-content mt-md mb-sm d-flex fd-column fg-1">
{{ htmlcode }}
<div class="dwc--generator-content-help text-helper small">
<i class="ri-information-2-line"></i>
{{ 'mautic.dynamiccontent.generator.preview_content_discard_notice'|trans }}
</div>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane fade" id="dwc--generator-plugins">
<div class="tile dwc--generator-wrapper">
<div class="d-flex fg-1">
<div class="dwc--generator-toolbar pa-xs layer-two d-flex fd-column jc-space-between ai-start mr-md">
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
icon: 'ri-brackets-line ri-lg',
label: 'mautic.dynamiccontent.generator.switch_code_wrapper',
icon_only: true,
variant: 'ghost',
size: 'sm',
attributes: {
'class': 'ma-0',
'type': 'button'
}
},
{
icon: 'ri-question-line ri-lg',
label: 'mautic.dynamiccontent.generator.more_information.plugins',
icon_only: true,
variant: 'ghost',
size: 'sm',
attributes: {
'class': 'ma-0',
'data-toggle': 'popover',
'title': 'mautic.dynamiccontent.generator.when_to_change_brackets'|trans,
'data-content': 'mautic.dynamiccontent.generator.joomla_syntax_info'|trans,
'data-container': 'body',
'type': 'button'
}
}
]
} %}
</div>
<div class="dwc--generator-content mt-md mb-sm d-flex fd-column fg-1">
<pre class="dwc--generator-content-code d-flex fd-column fg-1 gap-md pa-0 fs-16 text-helper">{mautic type="content" slot="{{ entity.slotName }}"} <div class="dwc--generator-content-editable layer-two d-inline-flex gap-4 ai-center ml-lg text-primary"> <i class="ri-pencil-line ri-sm" title="{{ 'mautic.dynamiccontent.generator.default_content_tooltip'|trans }}" data-toggle="tooltip"></i><input class="form-control dwc--generator-content-input" type="text" placeholder="{{ 'mautic.dynamiccontent.generator.input_placeholder'|trans }}"></div>{/mautic}
</pre>
<div class="dwc--generator-content-help text-helper small">
<i class="ri-information-2-line"></i>
{{ 'mautic.dynamiccontent.generator.preview_content_discard_notice'|trans }}
</div>
</div>
</div>
</div>
</div>
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: 'mautic.dynamiccontent.generator.copy_dynamic_content_slot',
icon: 'ri-clipboard-line',
variant: 'secondary',
size: 'xl',
wide: 'true',
attributes: {
'class': 'dwc--generator-copy'
}
}
]
} %}
</div>
</div>

View File

@@ -0,0 +1,109 @@
{#
Variables
- searchValue
- items (\Mautic\DynamicContentBundle\Entity\DynamicContent[])
- categories
- page
- limit
- permissions
- model
- tmpl
#}
{% if items|length > 0 %}
<div class="table-responsive page-list">
<table class="table table-hover dwctable-list" id="dwcTable">
<thead>
<tr>
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'checkall': 'true',
'target': '#dwcTable',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'dynamicContent',
'orderBy': 'e.name',
'text': 'mautic.core.name',
'class': 'col-dwc-name',
'default': true,
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'dynamicContent',
'orderBy': 'e.slotName',
'text': 'mautic.dynamicContent.label.slot_name',
'class': 'col-dwc-slotname visible-md visible-lg',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'dynamicContent',
'orderBy': 'c.title',
'text': 'mautic.core.category',
'class': 'col-dwc-category visible-md visible-lg',
}) }}
{{ include('@MauticCore/Helper/tableheader.html.twig', {
'sessionVar': 'dynamicContent',
'orderBy': 'e.id',
'text': 'mautic.core.id',
'class': 'col-dwc-id visible-md visible-lg',
}) }}
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{{ include('@MauticCore/Helper/list_actions.html.twig', {
'item': item,
'templateButtons': {
'edit': securityHasEntityAccess(permissions['dynamiccontent:dynamiccontents:editown'], permissions['dynamiccontent:dynamiccontents:editother'], item.createdBy),
'clone': permissions['dynamiccontent:dynamiccontents:create'],
'delete': securityHasEntityAccess(permissions['dynamiccontent:dynamiccontents:deleteown'], permissions['dynamiccontent:dynamiccontents:deleteother'], item.createdBy),
},
'routeBase': 'dynamicContent',
'nameGetter': 'getName',
}) }}
</td>
<td>
{{ include('@MauticCore/Helper/publishstatus_icon.html.twig', {'item': item, 'model': 'dynamicContent'}) }}
<a href="{{ url('mautic_dynamicContent_action', {'objectAction': 'view', 'objectId': item.id}) }}" data-toggle="ajax">
{{ item.name }}
{% if item.isVariant or item.isTranslation or item.isCampaignBased %}
<span>
{% if item.isVariant %}
<span data-toggle="tooltip" title="{{ 'mautic.core.icon_tooltip.ab_test'|trans }}"><i class="ri-fw ri-organization-chart"></i></span>
{% endif %}
{% if item.isTranslation %}
<span data-toggle="tooltip" title="{{ 'mautic.core.icon_tooltip.translation'|trans }}"><i class="ri-fw ri-translate"></i></span>
{% endif %}
{% if item.isCampaignBased %}
<span data-toggle="tooltip" title="{{ 'mautic.dwc.form.is.campaign.tooltip'|trans }}"><i class="ri-fw ri-megaphone-line"></i></span>
{% endif %}
</span>
{% endif %}
</a>
{{ customContent('dynamiccontent.name', _context) }}
</td>
<td class="visible-md visible-lg">{{ item.slotName }}</td>
<td class="visible-md visible-lg">
{{ include('@MauticCore/Modules/category--expanded.html.twig', {'category': item.category}) }}
</td>
<td class="visible-md visible-lg">{{ item.id }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
{{ include('@MauticCore/Helper/pagination.html.twig', {
'totalItems': items|length,
'page': page,
'limit': limit,
'menuLinkId': 'mautic_dynamicContent_index',
'baseUrl': url('mautic_dynamicContent_index'),
'sessionVar': 'dynamicContent',
}) }}
</div>
</div>
{% else %}
{{ include('@MauticCore/Helper/noresults.html.twig') }}
{% endif %}

View File

@@ -0,0 +1,214 @@
{#
#}
{% extends (not isEmbedded) ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent 'dynamicContent' %}
{% block headerTitle entity.name %}
{% block publishStatus %}
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': entity,
'status': 'available'
}) -}}
{% set tags = [
{
color: 'warm-gray',
label: entity.isCampaignBased
? 'mautic.dwc.form.is.campaign'
: 'mautic.dwc.form.is.standalone'
}
] %}
{% set tags = tags
|merge(entity.isTranslation and not entity.isTranslation(true)
? [{ color: 'warm-gray', label: 'mautic.core.icon_tooltip.translation' }]
: [])
|merge(entity.isTranslation(true)
? [{ color: 'warm-gray', label: 'mautic.core.translation_of'|trans({'%parent%' : translations.parent.getName()}), icon: 'ri-translate' }]
: [])
%}
{% include '@MauticCore/Helper/_tag.html.twig' with { tags: tags } %}
{% endblock %}
{% block preHeader %}
{{- include('@MauticCore/Helper/page_actions.html.twig',
{
'item' : entity,
'templateButtons' : {
'close' : securityHasEntityAccess(permissions['dynamiccontent:dynamiccontents:viewown'], permissions['dynamiccontent:dynamiccontents:viewother'], entity.createdBy),
},
'routeBase' : 'dynamicContent',
'targetLabel' : 'mautic.dynamicContent.dynamicContents'|trans
}
) -}}
{{ include('@MauticCore/Modules/category--inline.html.twig', {'category': entity.category}) }}
{% endblock %}
{% block actions %}
{{- include('@MauticCore/Helper/page_actions.html.twig', {
'item': entity,
'customButtons': customButtons|default([]),
'templateButtons': {
'edit': securityHasEntityAccess(permissions['dynamiccontent:dynamiccontents:editown'], permissions['dynamiccontent:dynamiccontents:editother'], entity.createdBy),
'clone': permissions['dynamiccontent:dynamiccontents:create'],
'delete': securityHasEntityAccess(permissions['dynamiccontent:dynamiccontents:deleteown'], permissions['dynamiccontent:dynamiccontents:deleteother'], entity.createdBy),
},
'routeBase': 'dynamicContent',
}) -}}
{% endblock %}
{% block content %}
{%- set translationContent = include('@MauticCore/Translation/index.html.twig', {
'activeEntity': entity,
'translations': translations,
'model': 'dynamicContent',
'actionRoute': 'mautic_dynamicContent_action',
})|trim -%}
{%- set showTranslations = translationContent is not empty -%}
<!-- start: box layout -->
<div class="box-layout">
<!-- left section -->
<div class="col-md-9 height-auto">
<div>
<!-- page detail header -->
{% set description %}
{% if entity.isVariant(true) %}
<div class="small">
<a href="{{ path('mautic_dynamicContent_action', {'objectAction': 'view', 'objectId': variants.parent.id}) }}" data-toggle="ajax">
{{ 'mautic.core.variant_of'|trans({'%parent%': variants.parent.name}) }}
</a>
</div>
{% endif %}
{% if entity.isTranslation(true) %}
<div class="small">
<a href="{{ path('mautic_dynamicContent_action', {'objectAction': 'view', 'objectId': translations.parent.id}) }}" data-toggle="ajax">
{{ 'mautic.core.translation_of'|trans({'%parent%': translations.parent.name}) }}
</a>
</div>
{% endif %}
{% if not entity.isCampaignBased %}
<div class="small">
{{ 'mautic.dynamicContent.header.is_filter_based'|trans({'%slot%': entity.slotName}) }}
</div>
{% endif %}
{% endset %}
{% include '@MauticCore/Helper/description--expanded.html.twig' with { 'description': description } %}
<!--/ page detail header -->
<!-- page detail collapseable -->
<div class="collapse pr-md pl-md" id="page-details">
<div class="pr-md pl-md pb-md">
<div class="panel shd-none mb-0">
<table class="table table-hover mb-0">
<tbody>
{{ include('@MauticCore/Helper/details.html.twig', {'entity': entity}) }}
<tr>
<td width="20%"><span class="fw-b textTitle">{{ 'mautic.dynamicContent.slot.campaign'|trans }}</span></td>
<td>{{ entity.isCampaignBased ? 'Yes' : 'No' }}</td>
</tr>
{% if not entity.isCampaignBased %}
<tr>
<td width="20%"><span class="fw-b textTitle">{{ 'mautic.dynamicContent.label.slot_name'|trans }}</span></td>
<td>{{ entity.slotName }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!--/ page detail collapseable -->
</div>
<div>
<!-- page detail collapseable toggler -->
<div class="hr-expand nm">
<span data-toggle="tooltip" title="Detail">
<a href="javascript:void(0)" class="arrow text-secondary collapsed" data-toggle="collapse" data-target="#page-details">
<span class="caret"></span>
{{ 'mautic.core.details'|trans }}
</a>
</span>
</div>
<!--/ page detail collapseable toggler -->
<!-- some stats -->
<div class="pa-md">
<div class="row">
<div class="col-sm-12">
<div class="panel">
<div class="panel-body box-layout">
<div class="col-md-3 va-m">
<h5 class="text-white dark-md fw-sb mb-xs">
<span class="ri-line-chart-fill"></span>
{{ 'mautic.dynamicContent.views'|trans }}
</h5>
</div>
<div class="col-md-9 va-m">
{{ include('@MauticCore/Helper/graph_dateselect.html.twig', {'dateRangeForm': dateRangeForm, 'class': 'pull-right'}) }}
</div>
</div>
<div class="d-flex fd-column pt-0 pl-15 pb-15 pr-15 min-h-256">
{{ include('@MauticCore/Helper/chart.html.twig', {'chartData': entityViews, 'chartType': 'line', 'chartHeight': 300}) }}
</div>
</div>
{% if not entity.isCampaignBased %}
{{ include('@MauticDynamicContent/DynamicContent/StandaloneSlotGenerator/dwc-generator.html.twig') }}
<hr>
{% endif %}
</div>
</div>
</div>
<!--/ stats -->
{{ customContent('details.stats.graph.below', _context) }}
<!-- tabs controls -->
<ul class="nav nav-tabs nav-tabs-contained">
<li class="active">
<a href="#clicks-container" role="tab" data-toggle="tab">
{{ 'mautic.trackable.click_counts'|trans }}
</a>
</li>
{% if showTranslations %}
<li class>
<a href="#translation-container" role="tab" data-toggle="tab">
{{ 'mautic.core.translations'|trans }}
</a>
</li>
{% endif %}
</ul>
<!--/ tabs controls -->
</div>
<!-- start: tab-content -->
<div class="tab-content pa-md">
<div class="tab-pane active active bdr-w-0" id="clicks-container">
{{ include('@MauticPage/Trackable/click_counts.html.twig', {'trackables': trackables, 'entity': entity, 'channel': 'dynamicContent'}) }}
</div>
<!-- #translation-container -->
{% if showTranslations %}
<div class="tab-pane bdr-w-0" id="translation-container">
{{ translationContent|raw }}
</div>
{% endif %}
<!--/ #translation-container -->
</div>
<!-- end: tab-content -->
</div>
<!--/ left section -->
<!-- right section -->
<div class="col-md-3 bdr-l height-auto">
<hr class="hr-w-2" style="width:50%">
<!-- recent activity -->
{{ include('@MauticCore/Helper/recentactivity.html.twig', {'logs': logs}) }}
</div>
<!--/ right section -->
</div>
<!--/ end: box layout -->
{% endblock %}

View File

@@ -0,0 +1,196 @@
{#
Used to create and edit dynamic content
#}
{% extends '@MauticCore/Default/content.html.twig' %}
{% form_theme form '@MauticDynamicContent/FormTheme/Filter/layout.html.twig' %}
{% block mauticContent 'dynamicContent' %}
{% block translationBase 'mautic.dynamicContent' %}
{% block headerTitle %}
{% if form.vars.data.id is empty %}
{{ 'mautic.dynamicContent.header.new'|trans }}
{% else %}
{{ 'mautic.dynamicContent.header.edit'|trans({'%name%': form.vars.data.name}) }}
{% endif %}
{% endblock %}
{% block content %}
{%- set fields = form.vars.fields -%}
{%- set index = form.filters.vars.value|keys|length -%}
{%- set hasMainErrors = formContainsErrors(form, ['filters']) -%}
{%- set hasFilterErrors = formContainsErrors(form.filters) -%}
{{ form_start(form) }}
<div class="box-layout">
<div class="col-md-9 height-auto">
<div class="row">
<div class="col-xs-12">
<ul class="nav nav-tabs nav-tabs-contained">
<li class="active">
<a href="#details" role="tab" data-toggle="tab" class="{% if hasMainErrors %}text-danger{% endif %}">
{{ 'mautic.core.details'|trans }}
{% if hasMainErrors %}
<i class="ri-alert-line"></i>
{% endif %}
</a>
</li>
<li class="{% if form.vars.value.isCampaignBased or form.updateSelect is defined %}hide{% endif %}" id="dwcFiltersTab">
<a href="#filters" role="tab" data-toggle="tab" class="{% if hasFilterErrors %}text-danger{% endif %}">
{{ 'mautic.core.filters'|trans }}
{% if hasFilterErrors %}
<i class="ri-alert-line"></i>
{% endif %}
</a>
</li>
</ul>
<!-- start: tab-content -->
<div class="tab-content pa-md">
<div class="tab-pane fade in active bdr-w-0" id="details">
<div class="row">
<div class="col-md-6">
{{ form_row(form.name) }}
</div>
</div>
<div class="row">
<div class="col-xs-12">
{{ form_row(form.content) }}
</div>
</div>
</div>
<div class="tab-pane fade bdr-w-0" id="filters">
{% if hasFilterErrors and '' is not same as form_errors(form.filters) %}
<div class="has-error alert alert-danger" role="alert" style="padding:5px 10px 0 10px;">
{{ form_errors(form.filters) }}
</div>
{% endif %}
<div class="dwc-filter bdr-w-0" id="{{ form.vars.id }}">
<div class="row">
<div class="col-xs-7">
<label>{{ 'Filters'|trans }}</label>
</div>
<div class="col-xs-5">
<div class="form-group">
<div class="available-filters mb-md pl-0"
data-prototype="{{ form_widget(form.filters.vars.prototype)|e }}"
data-index="{{ index + 1 }}">
<select class="chosen form-control" id="available_filters">
<option value=""></option>
{% for object, field in fields %}
{% set icon = 'company' == object ? 'building' : 'user' %}
<optgroup label="{{ ('mautic.lead.' ~ object)|trans }}">
{% for value, params in field %}
{% set list = params.properties.list|default([]) %}
{% if 'boolean' is same as params.properties.type %}
{% set choices = formFieldParseBooleanList(list)|reverse %}
{% else %}
{% set choices = leadFormFieldParseListForChoices(list) %}
{% endif %}
{% set callback = params.properties.callback|default('') %}
{% set operators = params.operators %}
<option value="{{ value|e }}"
id="available_{{ object }}_{{ value }}"
data-field-object="{{ object }}"
data-field-type="{{ params.properties.type }}"
data-field-list="{{ list|json_encode }}"
data-field-callback="{{ callback }}"
data-field-operators="{{ operators|json_encode }}"
class="segment-filter {{ icon }}">{{ params.label|trans }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</div>
<div class="clearfix"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="selected-filters" id="dwc_filters" data-filter-container>
{{ form_widget(form.filters) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 height-auto bdr-l">
<div class="pr-lg pl-lg pt-md pb-md">
{{ form_row(form.type) }}
{{ form_row(form.category) }}
{{ form_row(form.projects) }}
{{ form_row(form.language) }}
{{ form_row(form.translationParent) }}
<div id="publishStatus">
{{ form_row(form.isPublished) }}
</div>
{% if form.updateSelect is not defined %}
{{ form_row(form.isCampaignBased) }}
{% endif %}
<div id="slotNameDiv" class="{% if form.vars.value.isCampaignBased %}hide{% endif %}">
{{ form_row(form.slotName) }}
</div>
<hr/>
{% include '@MauticCore/FormTheme/Fields/_utm_tags_fields.html.twig' %}
<div class="hide">
{{ form_row(form.publishUp) }}
{{ form_row(form.publishDown) }}
{{ form_rest(form) }}
</div>
</div>
</div>
</div>
{{ form_end(form) }}
{%- set templates = {
'countries': 'country-template',
'regions': 'region-template',
'timezones': 'timezone-template',
'select': 'select-template',
'lists': 'leadlist-template',
'campaign': 'campaign-template',
'deviceTypes': 'device_type-template',
'deviceBrands': 'device_brand-template',
'deviceOs': 'device_os-template',
'emails': 'lead_email_received-template',
'tags': 'tags-template',
'stage': 'stage-template',
'locales': 'locale-template',
'globalcategory': 'globalcategory-template',
} -%}
<div class="hide" id="templates">
{% for dataKey, template in templates %}
<select class="form-control not-chosen {{ template }}"
{% if 'tags' == dataKey %}data-placeholder="{{ 'mautic.lead.tags.select_or_create'|trans }}"{% endif %}
{% if 'tags' == dataKey %}data-no-results-text="{{ 'mautic.lead.tags.enter_to_create'|trans }}"{% endif %}
{% if 'tags' == dataKey %}data-allow-add="true"{% endif %}
{% if 'tags' == dataKey %}onchange="Mautic.createLeadTag(this)"{% endif %}
name="dwc[filters][__name__][filter]"
id="dwc_filters___name___filter">
{% if form.vars[dataKey] is defined %}
{% set index = 0 %}
{% for label, value in form.vars[dataKey] %}
{% if value is iterable %}
<optgroup label="{{ label }}">
{% for optionLabel, optionValue in value %}
{% if (dataKey == 'regions') %}
{% set optionValue = index %}
{% set index = index + 1 %}
{% endif %}
<option value="{{ optionValue }}">{{ optionLabel }}</option>
{% endfor %}
</optgroup>
{% elseif 'lists' != dataKey or (currentListId is defined and value != currentListId) %}
<option value="{{ value }}">{{ label }}</option>
{% endif %}
{% endfor %}
{% endif %}
</select>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,73 @@
{#
Variables
- searchValue
- items (\Mautic\DynamicContentBundle\Entity\DynamicContent[])
- categories
- page
- limit
- permissions
- model
- tmpl
#}
{%- set isIndex = 'index' == tmpl -%}
{%- set tmpl = 'list' -%}
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
{% block mauticContent 'dynamicContent' %}
{% block headerTitle 'mautic.dynamicContent.dynamicContents'|trans %}
{% block content %}
{% if isIndex %}
<div id="page-list-wrapper" class="panel panel-default">
{{ include('@MauticCore/Helper/list_toolbar.html.twig', {
'searchValue': searchValue,
'searchHelp': 'mautic.page.help.searchcommands',
'action': currentRoute,
'page_actions': {
'templateButtons': {
'new': permissions['dynamiccontent:dynamiccontents:create'],
},
'routeBase': 'dynamicContent',
},
'bulk_actions': {
'routeBase': 'dynamicContent',
'templateButtons': {
'delete': permissions['dynamiccontent:dynamiccontents:deleteown'] or permissions['dynamiccontent:dynamiccontents:deleteother'],
},
},
'quickFilters': [
{
'search': 'mautic.core.searchcommand.ispublished',
'label': 'mautic.core.form.available',
'tooltip': 'mautic.core.searchcommand.ispublished.description',
'icon': 'ri-check-line'
},
{
'search': 'mautic.core.searchcommand.isunpublished',
'label': 'mautic.core.form.unavailable',
'tooltip': 'mautic.core.searchcommand.isunpublished.description',
'icon': 'ri-close-line'
},
{
'search': 'mautic.core.searchcommand.isuncategorized',
'label': 'mautic.core.form.uncategorized',
'tooltip': 'mautic.core.searchcommand.isuncategorized.description',
'icon': 'ri-folder-unknow-line'
},
{
'search': 'mautic.core.searchcommand.ismine',
'label': 'mautic.core.searchcommand.ismine.label',
'tooltip': 'mautic.core.searchcommand.ismine.description',
'icon': 'ri-user-line'
}
]
}) }}
<div class="page-list">
{{ include('@MauticDynamicContent/DynamicContent/_list.html.twig') }}
</div>
</div>
{% else %}
{{ include('@MauticDynamicContent/DynamicContent/_list.html.twig') }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% block _dynamiccontentdecision_list_row %}
<div class="row">
<div class="col-xs-8">
{{ form_row(form.dynamicContent) }}
</div>
<div class="col-xs-4 mt-lg">
<div class="mt-3">
{{ form_row(form.newDynamicContentButton) }}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% block _dynamiccontentpush_list_row %}
<div class="row">
<div class="col-xs-8">
{{ form_row(form.dynamicContent) }}
</div>
<div class="col-xs-4 mt-lg">
<div class="mt-3">
{{ form_row(form.newDynamicContentButton) }}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% block _dwc_filters_entry_widget %}
{%- set isPrototype = '__name__' == form.vars.name -%}
{%- set filterType = form.field.vars.value -%}
{%- set inGroup = form.vars.data.glue is defined and 'and' is same as form.vars.data.glue -%}
{%- set isBehavior = fields.behaviors[filterType].label is defined -%}
{%- set icon = form.vars.data.object is defined and 'company' == form.vars.data.object ? 'ri-building-2-line' : 'ri-user-6-fill' -%}
{%- if isBehavior -%}
{%- set object = 'behaviors' -%}
{%- else -%}
{%- set object = form.vars.data.object is defined ? form.vars.data.object : 'lead' -%}
{%- endif -%}
{%- if isPrototype or fields[object][filterType].label is defined -%}
<div class="panel {% if inGroup and false is same as first %}in-group{% endif %}">
<div class="panel-heading {% if not isPrototype and '0' is same as form.vars.name %}hide{% endif %}">
<div class="panel-glue col-sm-2 pl-0 ">
{{ form_widget(form.glue) }}
</div>
</div>
<div class="panel-body">
<div class="col-xs-6 col-sm-3 field-name">
<i class="object-icon fa {{ icon }}" aria-hidden="true"></i>
<span>{{ isPrototype ? '__label__' : fields[object][filterType].label }}</span>
</div>
<div class="col-xs-6 col-sm-3 padding-none">
{{ form_widget(form.operator) }}
</div>
{%- set hasErrors = form.filter.vars.errors|length or form.display.vars.errors|length -%}
<div class="col-xs-10 col-sm-5 padding-none {% if hasErrors %}has-error{% endif %}">
{{ form_widget(form.filter) }}
{{ form_widget(form.display) }}
{{ form_errors(form.filter) }}
{{ form_errors(form.display) }}
</div>
<div class="col-xs-2 col-sm-1">
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: 'mautic.report.report.label.removefilter',
variant: 'ghost',
icon_only: true,
size: 'xs',
danger: 'true',
icon: 'ri-delete-bin-line',
href: 'javascript: void(0);',
attributes: {
'class': 'remove-selected text-danger pull-right'
}
}
]
} %}
</div>
{{ form_widget(form.field) }}
{{ form_widget(form.type) }}
{{ form_widget(form.object) }}
</div>
</div>
{%- endif -%}
{% endblock %}
{% block _dwc_filters_widget %}
{% use '@MauticLead/FormTheme/Filter/_leadlist_filters_widget.html.twig' %}
{{ block('_leadlist_filters_widget') }}
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% if showMore is defined %}
<a href="{{ url('mautic_dynamicContent_index', {'search': searchString}) }}" data-toggle="ajax">
<span>{{ 'mautic.core.search.more'|trans({'%count%': remaining}) }}</span>
</a>
{% else %}
<div class="d-flex ai-center">
<a class="fg-1" href="{{ url('mautic_dynamicContent_action', {'objectAction': 'view', 'objectId': item.id}) }}" data-toggle="ajax">
<span class="fw-sb">{{ item.name }}</span>
{{- include('@MauticCore/Helper/publishstatus_badge.html.twig', {
'entity': item,
'status': item.getPublishUp() is not null ? 'active' : 'available',
'simplified': 'true'
}) -}}
</a>
<div class="clearfix"></div>
</div>
{% endif %}

View File

@@ -0,0 +1,8 @@
{% set data = event.extra.stat.sentDetails %}
{% if data.failed is not defined and data.timeline is defined %}
<dl class="dl-horizontal">
<dt>{{ 'mautic.dynamicContent.timeline.content'|trans }}</dt>
<dd>{{ data.timeline|trans }}</dd>
</dl>
{% endif %}

View File

@@ -0,0 +1,27 @@
<!-- Dynamic Content tokens -->
<li class="panel">
<a role="button" id="headingDynamicContentTokens" class="accordion-heading collapsed" data-toggle="collapse"
data-parent="#tokensAccordion" href="#collapseDynamicContentTokens" aria-expanded="false"
aria-controls="collapseDynamicContentTokens">
<i class="ri-arrow-down-s-line accordion-arrow"></i>
<span class="accordion-title">{{ 'mautic.placeholder_tokens.dynamic_content_tokens'|trans }}</span>
</a>
<div id="collapseDynamicContentTokens" class="collapse accordion-wrapper" role="tabpanel"
aria-labelledby="headingDynamicContentTokens">
<table class="table table-hover">
<thead>
<tr>
<th>{{ 'mautic.placeholder_tokens.variable_name'|trans }}</th>
<th>{{ 'mautic.placeholder_tokens.variable_syntax'|trans }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ 'mautic.placeholder_tokens.dynamic_content.example'|trans }}</td>
<td><code>{dynamiccontent="Dynamic Content 1"}</code></td>
</tr>
</tbody>
</table>
</div>
</li>

View File

@@ -0,0 +1,28 @@
<?php
namespace Mautic\DynamicContentBundle\Security\Permissions;
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
use Symfony\Component\Form\FormBuilderInterface;
class DynamicContentPermissions extends AbstractPermissions
{
public function __construct($params)
{
parent::__construct($params);
$this->addStandardPermissions('categories');
$this->addExtendedPermissions('dynamiccontents');
}
public function getName(): string
{
return 'dynamiccontent';
}
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
{
$this->addStandardFormFields('dynamiccontent', 'categories', $builder, $data);
$this->addExtendedFormFields('dynamiccontent', 'dynamiccontents', $builder, $data);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Controller;
use Mautic\CoreBundle\Helper\ClickthroughHelper;
use Mautic\CoreBundle\Test\IsolatedTestTrait;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Entity\DynamicContentLeadData;
use Mautic\EmailBundle\Entity\Stat;
use Mautic\LeadBundle\Entity\Lead;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
class DynamicContentApiControllerFunctionalTest extends MauticMysqlTestCase
{
use IsolatedTestTrait;
public function testDwcGetEndpointForNoSlotNorContact(): void
{
$this->client->request(Request::METHOD_GET, '/dwc/slot-a');
self::assertResponseStatusCodeSame(Response::HTTP_NO_CONTENT, $this->client->getResponse()->getContent());
}
public function testDwcGetEndpointForASlotAndContact(): void
{
$contact = new Lead();
$contact->setEmail('johana@doe.email');
$dwc = new DynamicContent();
$dwc->setContent('<some>content</some>');
$dwc->setName('Slot A');
$dwc->setSlotName('slot-a');
$dwcContact = new DynamicContentLeadData();
$dwcContact->setDateAdded(new \DateTime());
$dwcContact->setDynamicContent($dwc);
$dwcContact->setLead($contact);
$dwcContact->setSlot($dwc->getSlotName());
$stat = new Stat();
$stat->setLead($contact);
$stat->setTrackingHash('tracking-hash-1');
$stat->setEmailAddress($contact->getEmail());
$stat->setDateSent(new \DateTime());
$this->em->persist($contact);
$this->em->persist($stat);
$this->em->persist($dwc);
$this->em->persist($dwcContact);
$this->em->flush();
$ct = ClickthroughHelper::encodeArrayForUrl(['stat' => 'tracking-hash-1']);
$this->client->request(Request::METHOD_GET, "/dwc/slot-a?ct={$ct}");
self::assertResponseIsSuccessful($this->client->getResponse()->getContent());
$responseArray = json_decode($this->client->getResponse()->getContent(), true);
Assert::assertSame('<some>content</some>', $responseArray['content']);
}
public function testCreateDwc(): void
{
$payload = [
'name' => 'API test',
'content' => 'API test',
];
$this->client->request(Request::METHOD_POST, '/api/dynamiccontents/new', $payload);
self::assertResponseStatusCodeSame(Response::HTTP_CREATED, $this->client->getResponse()->getContent());
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\ProjectBundle\Entity\Project;
use Mautic\UserBundle\Entity\Permission;
use Mautic\UserBundle\Entity\Role;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
class DynamicContentControllerFunctionalTest extends MauticMysqlTestCase
{
public const PERMISSION_CREATE = 'dynamiccontent:dynamiccontents:create';
public const PERMISSION_DELETE_OTHER = 'dynamiccontent:dynamiccontents:deleteother';
public const PERMISSION_DELETE_OWN = 'dynamiccontent:dynamiccontents:deleteown';
public const BITWISE_BY_PERM = [
self::PERMISSION_CREATE => 52,
self::PERMISSION_DELETE_OWN => 66,
self::PERMISSION_DELETE_OTHER => 150,
];
private const NO_NESTING_VALIDATION_MESSAGE = 'DWC tokens cannot be used within another DWC. Please remove any DWC tokens from the content to proceed.';
public function testAccessControlNewAction(): void
{
$this->createAndLoginUser(self::PERMISSION_CREATE);
$this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testNoNestingValidationNewAction(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertTrue($this->client->getResponse()->isOk());
$this->submitFormAndAssertNoNestingValidation($crawler);
}
public function testForbiddenNewAction(): void
{
$this->createAndLoginUser();
$this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testNoNestingValidationEditAction(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/s/dwc/new');
Assert::assertTrue($this->client->getResponse()->isOk());
$buttonCrawler = $crawler->selectButton('Save');
$form = $buttonCrawler->form();
$form->setValues([
'dwc[name]' => 'Some name',
'dwc[content]' => 'Some content',
]);
$crawler = $this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
Assert::assertStringNotContainsString(self::NO_NESTING_VALIDATION_MESSAGE, $crawler->text());
Assert::assertStringContainsString('Edit Dynamic Content', $crawler->text());
$this->submitFormAndAssertNoNestingValidation($crawler);
}
public function testAccessDeleteAction(): void
{
$this->createAndLoginUser(self::PERMISSION_DELETE_OWN);
$this->client->request(Request::METHOD_POST, '/s/dwc/delete');
Assert::assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testForbiddenDeleteAction(): void
{
$this->createAndLoginUser();
$this->client->request('GET', '/s/dwc/delete');
Assert::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
}
public function testDwcWithProject(): void
{
$dynamicContent = new DynamicContent();
$dynamicContent->setName('test');
$this->em->persist($dynamicContent);
$project = new Project();
$project->setName('Test Project');
$this->em->persist($project);
$this->em->flush();
$this->em->clear();
$crawler = $this->client->request('GET', '/s/dwc/edit/'.$dynamicContent->getId());
$form = $crawler->selectButton('Save')->form();
$form['dwc[projects]']->setValue((string) $project->getId());
$this->client->submit($form);
$this->assertResponseIsSuccessful();
$savedAsset = $this->em->find(DynamicContent::class, $dynamicContent->getId());
Assert::assertSame($project->getId(), $savedAsset->getProjects()->first()->getId());
}
private function createAndLoginUser(?string $permission = null): User
{
// Create non-admin role
$role = $this->createRole();
// Create permissions to update user for the role
if (!empty($permission)) {
$this->createPermission($permission, $role, self::BITWISE_BY_PERM[$permission]);
}
// Create non-admin user
$user = $this->createUser($role);
$this->em->flush();
$this->em->detach($role);
$this->loginUser($user);
$this->client->setServerParameter('PHP_AUTH_USER', $user->getUserIdentifier());
$this->client->setServerParameter('PHP_AUTH_PW', 'Maut1cR0cks!');
return $user;
}
private function createRole(bool $isAdmin = false): Role
{
$role = new Role();
$role->setName('Role');
$role->setIsAdmin($isAdmin);
$this->em->persist($role);
return $role;
}
private function createPermission(string $rawPermission, Role $role, int $bitwise): void
{
$parts = explode(':', $rawPermission);
$permission = new Permission();
$permission->setBundle($parts[0]);
$permission->setName($parts[1]);
$permission->setRole($role);
$permission->setBitwise($bitwise);
$this->em->persist($permission);
}
private function createUser(Role $role): User
{
$user = new User();
$user->setFirstName('John');
$user->setLastName('Doe');
$user->setUsername('john.doe');
$user->setEmail('john.doe@email.com');
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
\assert($hasher instanceof PasswordHasherInterface);
$user->setPassword($hasher->hash('Maut1cR0cks!'));
$user->setRole($role);
$this->em->persist($user);
return $user;
}
public function testIndexActionIsSuccessful(): void
{
$this->client->request(Request::METHOD_GET, '/s/dwc');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testNewActionIsSuccessful(): void
{
$this->client->request(Request::METHOD_GET, '/s/dwc/new');
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testEditActionIsSuccessful(): void
{
$entity = new DynamicContent();
$entity->setName('Test Dynamic Content');
$this->em->persist($entity);
$this->em->flush();
$this->client->request(Request::METHOD_GET, '/s/dwc/edit/'.$entity->getId());
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
public function testViewActionIsSuccessful(): void
{
$entity = new DynamicContent();
$entity->setName('Test Dynamic Content');
$this->em->persist($entity);
$this->em->flush();
$this->client->request(Request::METHOD_GET, '/s/dwc/view/'.$entity->getId());
$response = $this->client->getResponse();
$this->assertEquals(Response::HTTP_OK, $response->getStatusCode());
}
private function submitFormAndAssertNoNestingValidation(Crawler $crawler): void
{
$buttonCrawler = $crawler->selectButton('Save');
$form = $buttonCrawler->form();
$form->setValues([
'dwc[name]' => 'Some name',
'dwc[content]' => 'Some {dwc=slotname}',
]);
$crawler = $this->client->submit($form);
Assert::assertTrue($this->client->getResponse()->isOk());
Assert::assertStringContainsString(self::NO_NESTING_VALIDATION_MESSAGE, $crawler->text());
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Controller;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\ProjectBundle\Tests\Functional\AbstractProjectSearchTestCase;
final class DynamicContentProjectSearchFunctionalTest extends AbstractProjectSearchTestCase
{
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void
{
$projectOne = $this->createProject('Project One');
$projectTwo = $this->createProject('Project Two');
$projectThree = $this->createProject('Project Three');
$dynamicContentAlpha = $this->createDynamicContent('DynamicContent Alpha');
$dynamicContentBeta = $this->createDynamicContent('DynamicContent Beta');
$this->createDynamicContent('DynamicContent Gamma');
$this->createDynamicContent('DynamicContent Delta');
$dynamicContentAlpha->addProject($projectOne);
$dynamicContentAlpha->addProject($projectTwo);
$dynamicContentBeta->addProject($projectTwo);
$dynamicContentBeta->addProject($projectThree);
$this->em->flush();
$this->em->clear();
$this->searchAndAssert($searchTerm, $expectedEntities, $unexpectedEntities, ['/api/dynamiccontents', '/s/dwc']);
}
/**
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
*/
public static function searchDataProvider(): \Generator
{
yield 'search by one project' => [
'searchTerm' => 'project:"Project Two"',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by one project AND dynamicContent name' => [
'searchTerm' => 'project:"Project Two" AND Beta',
'expectedEntities' => ['DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by one project OR dynamicContent name' => [
'searchTerm' => 'project:"Project Two" OR Gamma',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta', 'DynamicContent Gamma'],
'unexpectedEntities' => ['DynamicContent Delta'],
];
yield 'search by NOT one project' => [
'searchTerm' => '!project:"Project Two"',
'expectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
];
yield 'search by two projects with AND' => [
'searchTerm' => 'project:"Project Two" AND project:"Project Three"',
'expectedEntities' => ['DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by two projects with NOT AND' => [
'searchTerm' => '!project:"Project Two" AND !project:"Project Three"',
'expectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
'unexpectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
];
yield 'search by two projects with OR' => [
'searchTerm' => 'project:"Project Two" OR project:"Project Three"',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Beta'],
'unexpectedEntities' => ['DynamicContent Gamma', 'DynamicContent Delta'],
];
yield 'search by two projects with NOT OR' => [
'searchTerm' => '!project:"Project Two" OR !project:"Project Three"',
'expectedEntities' => ['DynamicContent Alpha', 'DynamicContent Gamma', 'DynamicContent Delta'],
'unexpectedEntities' => ['DynamicContent Beta'],
];
}
private function createDynamicContent(string $name): DynamicContent
{
$dynamicContent = new DynamicContent();
$dynamicContent->setName($name);
$this->em->persist($dynamicContent);
return $dynamicContent;
}
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Form\Type;
use DeviceDetector\Parser\Device\AbstractDeviceParser as DeviceParser;
use DeviceDetector\Parser\OperatingSystem;
use Doctrine\ORM\EntityManager;
use Mautic\DynamicContentBundle\DynamicContent\TypeList;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Form\Type\DynamicContentListType;
use Mautic\DynamicContentBundle\Form\Type\DynamicContentType;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Helper\FormFieldHelper;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\LeadBundle\Model\ListModel;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Contracts\Translation\TranslatorInterface;
class DynamicContentTypeTest extends TestCase
{
public function testFormBuild(): void
{
$entityManagerMock = $this->createMock(EntityManager::class);
$listModelMock = $this->createMock(ListModel::class);
$translatorInterfaceMock = $this->createMock(TranslatorInterface::class);
$leadModelMock = $this->createMock(LeadModel::class);
$listModelMock->expects($this->once())
->method('getChoiceFields')
->willReturn($this->getMockChoiceFields());
$leadRepositoryMock = $this->createMock(LeadRepository::class);
$leadModelMock->expects($this->once())
->method('getRepository')
->willReturn($leadRepositoryMock);
$leadRepositoryMock->expects($this->once())
->method('getCustomFieldList')
->with('lead')
->willReturn($this->getMockCustomFieldList());
$tags = $this->getMockTagList();
$leadModelMock->expects($this->once())
->method('getTagList')
->willReturn($tags);
$dynamicContentType = new DynamicContentType(
$entityManagerMock,
$listModelMock,
$translatorInterfaceMock,
$leadModelMock,
new TypeList(),
);
$formBuilderInterfaceMock = $this->createMock(FormBuilderInterface::class);
$options['data'] = new DynamicContent();
$tagChoices = [];
foreach ($tags as $tag) {
$tagChoices[$tag['value']] = $tag['label'];
}
$matcher = $this->exactly(2);
$formBuilderInterfaceMock->expects($matcher)
->method('create')->willReturnCallback(function (...$parameters) use ($matcher, $tagChoices, $formBuilderInterfaceMock) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame('translationParent', $parameters[0]);
$this->assertSame(DynamicContentListType::class, $parameters[1]);
$this->assertSame([
'label' => 'mautic.core.form.translation_parent',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
'tooltip' => 'mautic.core.form.translation_parent.help',
],
'required' => false,
'multiple' => false,
'placeholder' => 'mautic.core.form.translation_parent.empty',
'top_level' => 'translation',
'ignore_ids' => [0 => 0],
], $parameters[2]);
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame('filters', $parameters[0]);
$this->assertSame(CollectionType::class, $parameters[1]);
$this->assertSame([
'entry_type' => \Mautic\DynamicContentBundle\Form\Type\DwcEntryFiltersType::class,
'entry_options' => [
'countries' => FormFieldHelper::getCountryChoices(),
'regions' => FormFieldHelper::getRegionChoices(),
'timezones' => FormFieldHelper::getTimezonesChoices(),
'locales' => FormFieldHelper::getLocaleChoices(),
'fields' => $this->getMockChoiceFields(),
'deviceTypes' => array_combine(
DeviceParser::getAvailableDeviceTypeNames(),
DeviceParser::getAvailableDeviceTypeNames()
),
'deviceBrands' => DeviceParser::$deviceBrands,
'deviceOs' => array_combine(
array_keys(OperatingSystem::getAvailableOperatingSystemFamilies()),
array_keys(OperatingSystem::getAvailableOperatingSystemFamilies())
),
'tags' => $tagChoices,
],
'error_bubbling' => false,
'mapped' => true,
'allow_add' => true,
'allow_delete' => true,
], $parameters[2]);
}
return $formBuilderInterfaceMock;
});
$matcher = $this->exactly(3);
$formBuilderInterfaceMock->expects($matcher)
->method('addEventListener')->willReturnCallback(function (...$parameters) use ($matcher, $formBuilderInterfaceMock) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame(FormEvents::PRE_SUBMIT, $parameters[0]);
$callback = function ($listener) {
$reflection = new \ReflectionFunction($listener);
$parameters = $reflection->getParameters();
return FormEvent::class === (string) $parameters[0]->getType();
};
$this->assertTrue($callback($parameters[1]));
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame(FormEvents::PRE_SET_DATA, $parameters[0]);
$callback = function ($listener) {
$reflection = new \ReflectionFunction($listener);
$parameters = $reflection->getParameters();
return FormEvent::class === (string) $parameters[0]->getType();
};
$this->assertTrue($callback($parameters[1]));
}
if (3 === $matcher->numberOfInvocations()) {
$this->assertSame(FormEvents::POST_SUBMIT, $parameters[0]);
$callback = function ($listener) {
$reflection = new \ReflectionFunction($listener);
$parameters = $reflection->getParameters();
return FormEvent::class === (string) $parameters[0]->getType();
};
$this->assertTrue($callback($parameters[1]));
}
return $formBuilderInterfaceMock;
});
$formBuilderInterfaceMock->expects($this->once())
->method('get')
->with('type')
->willReturn($formBuilderInterfaceMock);
$dynamicContentType->buildForm($formBuilderInterfaceMock, $options);
}
/**
* @return array<string, array<string, array<string, mixed>>>
*/
private function getMockChoiceFields(): array
{
return [
'lead' => [
'email' => [
'label' => 'Email',
'properties' => ['type' => 'email'],
'object' => 'lead',
'operators' => [
'equals' => '=',
'not equal' => '!=',
'empty' => 'empty',
'not empty' => '!empty',
'like' => 'like',
'not like' => '!like',
'regexp' => 'regexp',
'not regexp' => '!regexp',
'starts with' => 'startsWith',
'ends with' => 'endsWith',
'contains' => 'contains',
],
],
'firstname' => [
'label' => 'First Name',
'properties' => ['type' => 'text'],
'object' => 'lead',
'operators' => [
'equals' => '=',
'not equal' => '!=',
'empty' => 'empty',
'not empty' => '!empty',
'like' => 'like',
'not like' => '!like',
'regexp' => 'regexp',
'not regexp' => '!regexp',
'starts with' => 'startsWith',
'ends with' => 'endsWith',
'contains' => 'contains',
],
],
'lastname' => [
'label' => 'Last Name',
'properties' => ['type' => 'text'],
'object' => 'lead',
'operators' => [
'equals' => '=',
'not equal' => '!=',
'empty' => 'empty',
'not empty' => '!empty',
'like' => 'like',
'not like' => '!like',
'regexp' => 'regexp',
'not regexp' => '!regexp',
'starts with' => 'startsWith',
'ends with' => 'endsWith',
'contains' => 'contains',
],
],
],
];
}
/**
* @return array<int, array<string, array<string,string|null>|string>>
*/
private function getMockCustomFieldList(): array
{
return [
[
'firstname' => [
'id' => '2',
'label' => 'First Name',
'alias' => 'firstname',
'type' => 'text',
'group' => 'core',
'object' => 'lead',
'is_fixed' => '1',
'properties' => 'a:0:{}',
'default_value' => null,
],
'lastname' => [
'id' => '3',
'label' => 'Last Name',
'alias' => 'lastname',
'type' => 'text',
'group' => 'core',
'object' => 'lead',
'is_fixed' => '1',
'properties' => 'a:0:{}',
'default_value' => null,
],
'email' => [
'id' => '6',
'label' => 'Email',
'alias' => 'email',
'type' => 'email',
'group' => 'core',
'object' => 'lead',
'is_fixed' => '1',
'properties' => 'a:0:{}',
'default_value' => null,
],
],
[
'firstname' => 'firstname',
'lastname' => 'lastname',
'email' => 'email',
],
];
}
/**
* @return array<int, array<string, string>>
*/
private function getMockTagList(): array
{
return [
[
'value' => '1',
'label' => 't1',
],
[
'value' => '2',
'label' => 't2',
],
[
'value' => '3',
'label' => 't3',
],
];
}
}

View File

@@ -0,0 +1,371 @@
<?php
namespace Mautic\DynamicContentBundle\Tests\EventListener;
use Mautic\AssetBundle\Helper\TokenHelper as AssetTokenHelper;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\CoreBundle\Model\AuditLogModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\EventListener\DynamicContentSubscriber;
use Mautic\DynamicContentBundle\Helper\DynamicContentHelper;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\FormBundle\Helper\TokenHelper as FormTokenHelper;
use Mautic\LeadBundle\Entity\CompanyLeadRepository;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Tracker\ContactTracker;
use Mautic\PageBundle\Event\PageDisplayEvent;
use Mautic\PageBundle\Helper\TokenHelper as PageTokenHelper;
use Mautic\PageBundle\Model\TrackableModel;
use MauticPlugin\MauticFocusBundle\Helper\TokenHelper as FocusTokenHelper;
use PHPUnit\Framework\MockObject\MockObject;
class DynamicContentSubscriberTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject|TrackableModel
*/
private MockObject $trackableModel;
/**
* @var MockObject|PageTokenHelper
*/
private MockObject $pageTokenHelper;
/**
* @var MockObject|AssetTokenHelper
*/
private MockObject $assetTokenHelper;
/**
* @var MockObject|FormTokenHelper
*/
private MockObject $formTokenHelper;
/**
* @var MockObject|FocusTokenHelper
*/
private MockObject $focusTokenHelper;
/**
* @var MockObject|AuditLogModel
*/
private MockObject $auditLogModel;
/**
* @var MockObject|DynamicContentHelper
*/
private MockObject $dynamicContentHelper;
/**
* @var MockObject|DynamicContentModel
*/
private MockObject $dynamicContentModel;
/**
* @var MockObject|CorePermissions
*/
private MockObject $security;
/**
* @var MockObject|ContactTracker
*/
private MockObject $contactTracker;
private \PHPUnit\Framework\MockObject\MockObject|CompanyLeadRepository $companyLeadRepositoryMock;
private DynamicContentSubscriber $subscriber;
/**
* @var CompanyModel|(CompanyModel&MockObject)|MockObject
*/
private MockObject $companyModel;
protected function setUp(): void
{
parent::setUp();
$this->trackableModel = $this->createMock(TrackableModel::class);
$this->pageTokenHelper = $this->createMock(PageTokenHelper::class);
$this->assetTokenHelper = $this->createMock(AssetTokenHelper::class);
$this->formTokenHelper = $this->createMock(FormTokenHelper::class);
$this->focusTokenHelper = $this->createMock(FocusTokenHelper::class);
$this->auditLogModel = $this->createMock(AuditLogModel::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
$this->dynamicContentHelper = $this->createMock(DynamicContentHelper::class);
$this->dynamicContentModel = $this->createMock(DynamicContentModel::class);
$this->security = $this->createMock(CorePermissions::class);
$this->contactTracker = $this->createMock(ContactTracker::class);
$this->companyModel = $this->createMock(CompanyModel::class);
$this->companyLeadRepositoryMock = $this->createMock(CompanyLeadRepository::class);
$this->subscriber = new DynamicContentSubscriber(
$this->trackableModel,
$this->pageTokenHelper,
$this->assetTokenHelper,
$this->formTokenHelper,
$this->focusTokenHelper,
$this->auditLogModel,
$this->dynamicContentHelper,
$this->dynamicContentModel,
$this->security,
$this->contactTracker,
$this->companyModel
);
}
/**
* This test is ensuring this error won't happen again:.
*
* DOMDocumentFragment::appendXML(): Entity: line 1: parser error : xmlParseEntityRef: no name
*
* It happens when there is an ampersand in the DWC content.
*/
public function testDecodeTokensWithAmpersandDataAttribute(): void
{
$content = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
<div data-slot="dwc" data-param-slot-name="test-token"></div>
</body>
</html>
HTML;
$expected = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
<a href="https://john.doe&son">Link</a>
</body>
</html>
HTML;
$dwcContent = '<a href="https://john.doe&son">Link</a>';
$event = $this->createMock(PageDisplayEvent::class);
$contact = new Lead();
$event->expects($this->once())
->method('getContent')
->willReturn($content);
$this->security->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$this->contactTracker->expects($this->once())
->method('getContact')
->willReturn($contact);
$this->dynamicContentHelper->expects($this->never())
->method('convertLeadToArray');
$this->dynamicContentHelper->expects($this->once())
->method('findDwcTokens')
->with($content, $contact)
->willReturn([]);
$this->dynamicContentHelper->expects($this->once())
->method('getDynamicContentForLead')
->with('test-token', $contact)
->willReturn($dwcContent);
$event->expects($this->once())
->method('setContent')
->with($expected);
$this->subscriber->decodeTokens($event);
}
/**
* This test is ensuring this error won't happen again:.
*
* DOMDocumentFragment::appendXML(): Entity: line 1: parser error : xmlParseEntityRef: no name
*
* It happens when there is an ampersand in the DWC content.
*/
public function testDecodeTokensWithAmpersandInlineDwc(): void
{
$content = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
{dwc=test-token}
</body>
</html>
HTML;
$expected = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
<a href="https://john.doe&son">Link</a>
</body>
</html>
HTML;
$dwcContent = '<a href="https://john.doe&son">Link</a>';
$event = $this->createMock(PageDisplayEvent::class);
$contact = new Lead();
$event->expects($this->once())
->method('getContent')
->willReturn($content);
$this->security->expects($this->once())
->method('isAnonymous')
->willReturn(true);
$this->contactTracker->expects($this->once())
->method('getContact')
->willReturn($contact);
$this->dynamicContentHelper->expects($this->never())
->method('convertLeadToArray');
$this->dynamicContentHelper->expects($this->once())
->method('findDwcTokens')
->with($content, $contact)
->willReturn([
'{dwc=test-token}' => [
'content' => $dwcContent,
'filters' => [
[
'field' => 'email',
'operator' => '!empty',
'filter' => '',
'type' => 'email',
],
],
],
]);
$this->dynamicContentHelper->expects($this->never())
->method('getDynamicContentForLead');
$event->expects($this->once())
->method('setContent')
->with($expected);
$this->subscriber->decodeTokens($event);
}
public function testOnTokenReplacement(): void
{
$content = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
Company name : {contactfield=companyname}
Company Country : {contactfield=companycountry}
Company website : {contactfield=companywebsite}
</body>
</html>
HTML;
$expected = <<< HTML
<!DOCTYPE html>
<html>
<head></head>
<body>
<h2>Hello there!</h2>
Company name : Doe Corp
Company Country : India
Company website : https://www.doe.corp
</body>
</html>
HTML;
$contact = $this->createMock(Lead::class);
$event = $this->createMock(TokenReplacementEvent::class);
$event
->expects($this->once())
->method('getContent')
->willReturn($content);
$event
->expects($this->once())
->method('getLead')
->willReturn($contact);
$event
->expects($this->once())
->method('getClickthrough')
->willReturn([
'slot' => 'slotOne',
'dynamic_content_id' => 1,
'lead' => 1,
]);
$contact
->expects($this->once())
->method('getProfileFields')
->willReturn([
'id' => 1,
'firstname' => 'John',
'lastname' => 'Doe',
'company' => 'Doe Corp',
'email' => 'john@doe.com',
]);
$this->companyModel
->expects($this->once())
->method('getCompanyLeadRepository')
->willReturn($this->companyLeadRepositoryMock);
$this->companyLeadRepositoryMock->expects($this->once())
->method('getPrimaryCompanyByLeadId')
->willReturn(
[
'id' => 1,
'companyname' => 'Doe Corp',
'companycountry' => 'India',
'companywebsite' => 'https://www.doe.corp',
'is_primary' => true,
]
);
$this->pageTokenHelper
->method('findPageTokens')
->willReturn([]);
$this->assetTokenHelper
->method('findAssetTokens')
->willReturn([]);
$this->formTokenHelper
->method('findFormTokens')
->willReturn([]);
$this->focusTokenHelper
->method('findFocusTokens')
->willReturn([]);
$this->trackableModel
->method('parseContentForTrackables')
->willReturn([
$content,
[],
]);
$dwc = new DynamicContent();
$dwc->setContent($content);
$this->dynamicContentModel
->expects($this->once())
->method('getEntity')
->willReturn($dwc);
$event->expects($this->once())
->method('setContent')
->with($expected);
$this->subscriber->onTokenReplacement($event);
}
}

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Unit\Helper;
use Mautic\CampaignBundle\Executioner\RealTimeExecutioner;
use Mautic\CoreBundle\Event\TokenReplacementEvent;
use Mautic\DynamicContentBundle\DynamicContentEvents;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Event\ContactFiltersEvaluateEvent;
use Mautic\DynamicContentBundle\Helper\DynamicContentHelper;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\LeadModel;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\EventDispatcher\EventDispatcher;
class DynamicContentHelperTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject&DynamicContentModel
*/
private MockObject $mockModel;
/**
* @var MockObject&RealTimeExecutioner
*/
private MockObject $realTimeExecutioner;
/**
* @var MockObject&EventDispatcher
*/
private MockObject $mockDispatcher;
/**
* @var MockObject&LeadModel
*/
private MockObject $leadModel;
private DynamicContentHelper $helper;
protected function setUp(): void
{
$this->mockModel = $this->createMock(DynamicContentModel::class);
$this->realTimeExecutioner = $this->createMock(RealTimeExecutioner::class);
$this->mockDispatcher = $this->createMock(EventDispatcher::class);
$this->leadModel = $this->createMock(LeadModel::class);
$this->helper = new DynamicContentHelper(
$this->mockModel,
$this->realTimeExecutioner,
$this->mockDispatcher,
$this->leadModel,
);
}
public function testGetDwcBySlotNameWithPublished(): void
{
$matcher = $this->exactly(2);
$this->mockModel->expects($matcher)
->method('getEntities')->willReturnCallback(function (...$parameters) use ($matcher) {
if (1 === $matcher->numberOfInvocations()) {
$this->assertSame([
'filter' => [
'where' => [
[
'col' => 'e.slotName',
'expr' => 'eq',
'val' => 'test',
],
[
'col' => 'e.isPublished',
'expr' => 'eq',
'val' => 1,
],
],
],
'ignore_paginator' => true,
], $parameters[0]);
return ['some entity'];
}
if (2 === $matcher->numberOfInvocations()) {
$this->assertSame([
'filter' => [
'where' => [
[
'col' => 'e.slotName',
'expr' => 'eq',
'val' => 'secondtest',
],
],
],
'ignore_paginator' => true,
], $parameters[0]);
return [];
}
});
// Only get published
$this->assertCount(1, $this->helper->getDwcsBySlotName('test', true));
// Get all
$this->assertCount(0, $this->helper->getDwcsBySlotName('secondtest'));
}
public function testGetDynamicContentSlotForLeadWithListenerFindingMatch(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
// Setting filter that is not known to Mautic, but is for a plugin.
$slot->setFilters([['field' => 'unicorn', 'type' => 'text', 'operator' => '=', 'filter' => 'magic']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(true);
$matcher = $this->exactly(2);
$this->mockDispatcher->expects($matcher)
->method('dispatch')->willReturnCallback(function (...$parameters) use ($matcher, $contact, $slot) {
if (1 === $matcher->numberOfInvocations()) {
$callback = function (ContactFiltersEvaluateEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getContact());
$this->assertSame($slot->getFilters(), $event->getFilters());
$event->setIsEvaluated(true);
$event->setIsMatched(true); // Match found in a subscriber.
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE, $parameters[1]);
}
if (2 === $matcher->numberOfInvocations()) {
$callback = function (TokenReplacementEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getLead());
$this->assertSame($slot->getContent(), $event->getContent());
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::TOKEN_REPLACEMENT, $parameters[1]);
}
return $parameters[0];
});
Assert::assertSame(
'<p>test</p>',
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
public function testGetDynamicContentSlotForLeadWithListenerNotFindingMatch(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
// Setting filter that is not known to Mautic, nor any plugin.
$slot->setFilters([['field' => 'unicorn', 'type' => 'text', 'operator' => '=', 'filter' => 'magic']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(true);
$matcher = $this->once();
$this->mockDispatcher->expects($matcher)
->method('dispatch')
->willReturnCallback(
function (...$parameters) use ($matcher, $contact, $slot) {
if (1 === $matcher->numberOfInvocations()) {
$callback = function (ContactFiltersEvaluateEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getContact());
$this->assertSame($slot->getFilters(), $event->getFilters());
// Match not found in any subscriber.
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::ON_CONTACTS_FILTER_EVALUATE, $parameters[1]);
}
return $parameters[0];
}
);
Assert::assertSame(
'', // No content returned as the filter did not match anything.
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
public function testGetDynamicContentSlotForLeadWithNoListenerWithMatchingFilter(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
$slot->setFilters([['field' => 'email', 'type' => 'email', 'operator' => '=', 'filter' => 'ma@ka.t']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(false);
$matcher = $this->once();
$this->mockDispatcher->expects($matcher)
->method('dispatch')
->willReturnCallback(
function (...$parameters) use ($matcher, $contact, $slot) {
if (1 === $matcher->numberOfInvocations()) {
$callback = function (TokenReplacementEvent $event) use ($contact, $slot) {
$this->assertSame($contact, $event->getLead());
$this->assertSame($slot->getContent(), $event->getContent());
};
$callback($parameters[0]);
$this->assertSame(DynamicContentEvents::TOKEN_REPLACEMENT, $parameters[1]);
}
return $parameters[0];
}
);
Assert::assertSame(
'<p>test</p>',
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
public function testGetDynamicContentSlotForLeadWithNoListenerWithNotMatchingFilter(): void
{
$slotName = 'test';
$contact = new Lead();
$contact->setFields(['email' => 'ma@ka.t', 'id' => 123]);
$slot = new DynamicContent();
$slot->setName($slotName);
$slot->setIsCampaignBased(false);
$slot->setFilters([['field' => 'email', 'type' => 'email', 'operator' => '=', 'filter' => 'uni@co.rn']]);
$slot->setContent('<p>test</p>');
$this->mockModel->method('getEntities')
->willReturn([$slot]);
$this->mockModel->method('getTranslatedEntity')
->willReturn([$slot, $slot]);
$this->leadModel->method('getEntity')
->with(123)
->willReturn($contact);
$this->mockDispatcher->method('hasListeners')->willReturn(false);
$this->mockDispatcher->expects($this->never())->method('dispatch');
Assert::assertSame(
'',
$this->helper->getDynamicContentSlotForLead($slotName, $contact)
);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Tests\Unit\Validator\Constraints;
use Mautic\DynamicContentBundle\Validator\Constraints\NoNesting;
use Mautic\DynamicContentBundle\Validator\Constraints\NoNestingValidator;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\Loader\ArrayLoader;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Context\ExecutionContext;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class NoNestingValidatorTest extends TestCase
{
private const TRANSLATED_MESSAGE = 'DWC tokens cannot be used within another DWC.';
private NoNesting $constraint;
private NoNestingValidator $validator;
private ExecutionContextInterface $context;
protected function setUp(): void
{
$this->constraint = new NoNesting();
$this->validator = new NoNestingValidator();
$this->context = $this->createContext();
$this->context->setConstraint($this->constraint);
$this->validator->initialize($this->context);
}
public function testValidateWithInvalidConstraint(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->expectExceptionMessage(sprintf('Expected argument of type "%s"', NoNesting::class));
$this->validator->validate('value', new NotBlank());
}
public function testValidateWithInvalidType(): void
{
$this->expectException(UnexpectedTypeException::class);
$this->expectExceptionMessage('Expected argument of type "string", "stdClass" given');
$this->validator->validate(new \stdClass(), $this->constraint);
}
public function testValidateWithNull(): void
{
$this->validator->validate(null, $this->constraint);
Assert::assertCount(0, $this->context->getViolations(), 'No violation should be added for a null value.');
}
public function testValidateWithValidValue(): void
{
$this->validator->validate('Some valid value', $this->constraint);
Assert::assertCount(0, $this->context->getViolations(), 'No violation should be added for a valid value.');
}
public function testValidateWithInvalidValue(): void
{
$this->validator->validate('Some invalid value {dwc=some}', $this->constraint);
Assert::assertCount(1, $this->context->getViolations(), 'There should be one violation for an invalid value.');
Assert::assertSame(self::TRANSLATED_MESSAGE, $this->context->getViolations()->get(0)->getMessage());
}
private function createContext(): ExecutionContextInterface
{
$locale = 'en_US';
$validator = $this->createMock(ValidatorInterface::class);
$translator = new Translator($locale);
$translator->addLoader('array', new ArrayLoader());
$translator->addResource('array', [
'mautic.dynamicContent.no_nesting' => self::TRANSLATED_MESSAGE,
], $locale, 'validators');
return new ExecutionContext($validator, null, $translator, 'validators');
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Mautic\DynamicContentBundle\Tests\Validator\Constraints;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Mautic\DynamicContentBundle\Validator\Constraints\SlotNameType;
use Mautic\DynamicContentBundle\Validator\Constraints\SlotNameTypeValidator;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class SlotNameTypeValidatorTest extends ConstraintValidatorTestCase
{
/**
* @var DynamicContentModel|MockObject
*/
private $dynamicContentModel;
protected function createValidator(): SlotNameTypeValidator
{
$this->dynamicContentModel = $this->createMock(DynamicContentModel::class);
return new SlotNameTypeValidator($this->dynamicContentModel);
}
public function testValidSlotNameType(): void
{
$dynamicContent = new DynamicContent();
$dynamicContent->setSlotName('slot1');
$dynamicContent->setType('html');
$dynamicContent->setIsCampaignBased(false);
$existingContent = new DynamicContent();
$existingContent->setSlotName('slot1');
$existingContent->setType('html');
$dynamicContent->setIsCampaignBased(false);
$this->dynamicContentModel->method('checkEntityBySlotName')->willReturn(false);
$this->validator->validate($dynamicContent, new SlotNameType());
$this->assertNoViolation();
}
public function testInvalidSlotNameType(): void
{
$dynamicContent = new DynamicContent();
$dynamicContent->setSlotName('slot1');
$dynamicContent->setType('text');
$dynamicContent->setIsCampaignBased(false);
$existingContent = new DynamicContent();
$existingContent->setSlotName('slot1');
$existingContent->setType('html');
$dynamicContent->setIsCampaignBased(false);
$this->dynamicContentModel->method('checkEntityBySlotName')->willReturn(true);
$constraint = new SlotNameType();
$this->validator->validate($dynamicContent, $constraint);
$this->buildViolation($constraint->message)
->atPath('property.path.type')
->assertRaised();
}
}

View File

@@ -0,0 +1,2 @@
mautic.dynamicContent.error.notfound="No Dynamic Content with an id of %id% was found!"
mautic.dynamicContent.notice.batch_deleted="%count% entities have been deleted!"

View File

@@ -0,0 +1,115 @@
mautic.dynamicContent.dynamicContent="Dynamic Web Content"
mautic.dynamicContent.dynamicContents="Dynamic Web Content"
mautic.placeholder_tokens.dynamic_content_tokens="Dynamic Content"
mautic.placeholder_tokens.dynamic_content.example="[Dynamic Content 1] | for example User-defined variable name"
mautic.dynamicContent.campaign.event.form.dynamicContents="Limit to Pages"
mautic.dynamicContent.campaign.event.form.dynamicContents.descr="Select the pages this trigger applies to. If none are selected, it'll apply to any page."
mautic.dynamicContent.campaign.event.form.url="URL"
mautic.dynamicContent.campaign.event.form.url.descr="Insert the URL of the page where you placed the tracking pixel. It is possible to use wildcard (*). Example: *product/123* will match http://web.com/product/1234.htm. Separate multiple URLs with a comma."
mautic.dynamicContent.campaign.event.dynamicContenthit="Visits a page"
mautic.dynamicContent.campaign.event.dynamicContenthit_descr="Trigger actions on a page/url hit."
mautic.dynamicContent.config.form.cat.in.url="Show category in page URL?"
mautic.dynamicContent.config.form.cat.in.url.tooltip="If enabled, the category slug will be included in the URL."
mautic.dynamicContent.config.form.google.analytics="Analytics script (i.e. Google Analytics)"
mautic.dynamicContent.config.form.google.analytics.tooltip="Insert the analytics script to have it automatically included in the source of landing pages."
mautic.dynamicContent.event.hit="Page Hit"
mautic.dynamicContent.event.publish.down="Set %dwc% as unavailable"
mautic.dynamicContent.event.publish.down.description="Dynamic Content '%dwc%' is going to become unavailable for use."
mautic.dynamicContent.event.publish.up="Set %dwc% as unavailable"
mautic.dynamicContent.event.publish.up.description="Page '%dwc%' is going to become available for use."
mautic.dynamiccontent.generator.default_content_tooltip="Add your own default content to ensure it displays when the filters aren't matching - for example with new anonymous visitors or a Contact that doesn't match the criteria you have specified."
mautic.dynamiccontent.generator.deployment_options="Choose how to integrate your dynamic content"
mautic.dynamiccontent.generator.deployment_instructions="Copy and paste the plugin code into your CMS editor, or add the HTML snippet directly to your website's code."
mautic.dynamiccontent.generator.plugins="Plugins"
mautic.dynamiccontent.generator.plugins_description="Compatible with popular CMS community plugins."
mautic.dynamiccontent.generator.html="HTML"
mautic.dynamiccontent.generator.html_description="Works with any website using our tracking script."
mautic.dynamiccontent.generator.switch_code_wrapper="Switch code wrapper"
mautic.dynamiccontent.generator.more_information.plugins="Information about code wrapper"
mautic.dynamiccontent.generator.more_information.html="Information about HTML tags"
mautic.dynamiccontent.generator.when_to_change_brackets="Different CMS plugins use different syntax"
mautic.dynamiccontent.generator.joomla_syntax_info="Joomla uses curly braces {mautic}, while WordPress uses square brackets [mautic]. Choose the appropriate syntax based on your CMS plugin."
mautic.dynamiccontent.generator.html_tag_title="Choosing the right HTML tag"
mautic.dynamiccontent.generator.html_tag_info="Use div for block-level content that should start on a new line, or span for inline content that flows within your text. Both tags support the same dynamic content functionality."
mautic.dynamiccontent.generator.preview_content_discard_notice="Any text entered in the input field will be lost when closing this window."
mautic.dynamiccontent.generator.input_placeholder="Draft your default content here"
mautic.dynamiccontent.generator.switch_html_tag="Switch HTML tag"
mautic.dynamiccontent.generator.copy_dynamic_content_slot="Copy dynamic content slot"
mautic.dynamiccontent.generator.troubleshooting.title="Troubleshooting"
mautic.dynamiccontent.generator.troubleshooting.content_not_displaying="Content not displaying"
mautic.dynamiccontent.generator.troubleshooting.content_not_displaying_text="Try testing with an anonymous session or incognito window. Mautic ignores administrator activity."
mautic.dynamiccontent.generator.troubleshooting.javascript="JavaScript limitations"
mautic.dynamiccontent.generator.troubleshooting.javascript_text="Mautic cleans your DWC for security, use elements with technical attributes in your content that can be targeted by external scripts instead."
mautic.dynamiccontent.generator.troubleshooting.default_content="Include default content"
mautic.dynamiccontent.generator.troubleshooting.default_content_text="Ensure visitors always see something meaningful. This is crucial for anonymous visitors or when contacts don't match your specified criteria."
mautic.dynamiccontent.generator.troubleshooting.cache="Cache related issues"
mautic.dynamiccontent.generator.troubleshooting.cache_text="Try disabling caching during testing, clearing your browser cache, and purging CDN cache if using services like Cloudflare."
mautic.dynamicContent.form.internal.name="Internal Name"
mautic.dynamicContent.form.confirmbatchdelete="Delete the selected items? WARNING - this will also delete all associated translations!"
mautic.dynamicContent.form.confirmdelete="Delete the dynamic content item, %name%? WARNING - this will also delete all associated translations!"
mautic.dynamicContent.form.content="Content"
mautic.dynamicContent.form.content.help="The content to return when this variant/default item is requested."
mautic.dynamicContent.form.language.help="Translate the content into multiple languages and link them together by assigning them to a parent page. Language selection links will become available on the related landing pages for customers to choose the language they want to see."
mautic.dynamicContent.header.edit="Edit Dynamic Content - %name%"
mautic.dynamicContent.header.new="New Dynamic Content"
mautic.dynamicContent.menu.edit="Edit Page"
mautic.dynamicContent.menu.view="View Page"
mautic.dynamiccontent.permissions.header="Dynamic Content Permissions"
mautic.dynamiccontent.permissions.dynamiccontents="Dynamic Content - User has access to"
mautic.dynamicContent.publish.down="Set as unavailable"
mautic.dynamicContent.publish.up="Set as available"
mautic.dynamicContent.report.revision="Revision"
mautic.dynamicContent.campaign.send_dwc="Push dynamic content"
mautic.dynamicContent.campaign.send_dwc.tooltip="Trigger actions when a Dynamic Content slot is requested."
mautic.dynamicContent.send.selectDynamicContents.default="Select Default Content"
mautic.dynamicContent.send.selectDynamicContents="Select Dynamic Content"
mautic.dynamicContent.send.new.dynamicContent="New Dynamic Content"
mautic.dynamicContent.send.edit.dynamicContent="Edit Dynamic Content"
mautic.dynamicContent.send.slot_name="Requested Slot Name"
mautic.dynamicContent.label.slot_name="Slot Name"
mautic.dynamicContent.send.slot_name.tooltip="The name of the slot the contact is requesting."
mautic.dynamicContent.choose.dynamicContents="Select the default dynamic content to show."
mautic.dynamicContent.campaign.decision_dwc="Request dynamic content"
mautic.dynamicContent.campaign.decision_dwc.tooltip="This is the top level for a dynamic content request."
mautic.dynamicContent.error.notfound="Dynamic Content item %id% not found."
mautic.dynamicContent.notice.batch_deleted="%count% dynamic content items deleted."
mautic.dwc.form.confirmbatchdelete="Confirm batch delete"
mautic.dwc.delete_new="Discard the new Dynamic Content"
mautic.dwc.form.is.campaign="Campaign dynamic content"
mautic.dwc.form.is.campaign.tooltip="Is campaign dynamic content"
mautic.dwc.form.is.standalone="Standalone dynamic content"
mautic.dwc.form.is_campaign_based="Is campaign based?"
mautic.dwc.form.is_campaign_based.tooltip="Select whether the content will be displayed based on campaign conditions or filters"
mautic.campaign.dwc.decision="Request dynamic content"
mautic.campaign.dwc.push_content="Push dynamic content"
mautic.dynamic.content.triggered="Dynamic Content Triggered"
mautic.dynamicContent.views="Views"
mautic.dynamicContent.show.total.views="Total Views"
mautic.dynamicContent.show.unique.views="Unique Views"
mautic.dynamicContent.timeline.content="Content"
mautic.dynamicContent.timeline.title="Read Dynamic Content"
mautic.dynamicContent.token.slot="Dynamic Web Content Slot"
mautic.dynamicContent.slot.campaign="Is campaign based"
mautic.core.icon_tooltip.is_filter_based="Is a filter based dynamic web content"
mautic.dynamicContent.header.is_filter_based="Is a filter based dynamic web content for slot: %slot%"
mautic.dynamicContent.type.label="Type"
mautic.dynamic.content.type.html="HTML"
mautic.dynamic.content.type.text="Text"

View File

@@ -0,0 +1,5 @@
mautic.dynamicContent.name.notblank="Please enter a name."
mautic.dynamicContent.slot_name.notblank="Please enter a slot name."
mautic.dynamicContent.filter.options.empty="At least one filter is required."
mautic.dynamicContent.no_nesting="DWC tokens cannot be used within another DWC. Please remove any DWC tokens from the content to proceed."
mautic.dynamicContent.slot_name_type="All Dynamic Contents with the same slot name must be of the same type."

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* This constraint checks if the content does not include another DWC token.
*/
class NoNesting extends Constraint
{
public string $message = 'mautic.dynamicContent.no_nesting';
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Validator\Constraints;
use Mautic\DynamicContentBundle\Helper\DynamicContentHelper;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class NoNestingValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof NoNesting) {
throw new UnexpectedTypeException($constraint, NoNesting::class);
}
if (null === $value) {
return;
}
if (!is_string($value)) {
throw new UnexpectedTypeException($value, 'string');
}
if (preg_match(DynamicContentHelper::DYNAMIC_WEB_CONTENT_REGEX, $value)) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* This constraint makes sure all entities with same slot name have the same type.
*/
class SlotNameType extends Constraint
{
public string $message = 'mautic.dynamicContent.slot_name_type';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Mautic\DynamicContentBundle\Validator\Constraints;
use Mautic\DynamicContentBundle\Entity\DynamicContent;
use Mautic\DynamicContentBundle\Model\DynamicContentModel;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class SlotNameTypeValidator extends ConstraintValidator
{
public function __construct(private DynamicContentModel $dynamicContentModel)
{
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof SlotNameType) {
throw new UnexpectedTypeException($constraint, SlotNameType::class);
}
if (!$value instanceof DynamicContent) {
return;
}
$slotName = $value->getSlotName();
if (empty($slotName) || $value->getIsCampaignBased()) {
return;
}
$existingContents = $this->dynamicContentModel->
checkEntityBySlotName($slotName, $value->getType(), '!=', $value->getId());
if ($existingContents) {
$this->context->buildViolation($constraint->message)
->atPath('type')
->addViolation();
}
}
}