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,168 @@
body:has(.s-dashboard) {
background-color: var(--layer);
--field: var(--field-02);
--field-hover: var(--field-hover-02);
}
.input-group-addon label {
margin-bottom: 0px;
}
.vector-map {
height: 350px;
color: transparent;
}
.jvectormap-container {
height:100%;
width:100%;
}
.dashboard-widgets .sortable-placeholder {
background-color: #fff;
border: 1px dashed #4e5d9d;
flex-grow: 0;
}
.dashboard-widgets.cards {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
padding: 10px;
}
.dashboard-widgets .tile {
--layer: var(--background);
margin: 5px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
-moz-transition-property: background-color, box-shadow, -moz-transform, border;
-o-transition-property: background-color, box-shadow, text-shadow, -o-transform, border;
-webkit-transition-property: background-color, box-shadow, text-shadow, -webkit-transform, border;
transition-property: background-color, box-shadow, text-shadow, transform, border;
-moz-transition-duration: 0.2s;
-o-transition-duration: 0.2s;
-webkit-transition-duration: 0.2s;
transition-duration: 0.2s;
-moz-transition-timing-function: linear;
-o-transition-timing-function: linear;
-webkit-transition-timing-function: linear;
transition-timing-function: linear;
position: relative;
}
.dashboard-widgets .ui-sortable-helper .tile {
box-shadow: rgba(255, 255, 255, 0.1) 0px 1px 1px 0px inset, rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px;
max-width: 170px;
max-height: 170px;
overflow: hidden;
transform: rotate(1deg);
-webkit-animation: squareToCircle 1.5s .5s infinite alternate;
}
@-webkit-keyframes squareToCircle {
0% {border-radius: 0 0 0 0;}
100% {border-radius: 20px 20px 20px 20px;}
}
.dashboard-widgets .widget-overlay {
content: '';
width: 101%;
height: 101%;
position: absolute;
top: 0;
left: 0;
transition: var(--transition-all-expressive);
backdrop-filter: blur(0);
pointer-events: none;
opacity: 0;
}
.dashboard-widgets .ui-sortable-helper .widget-overlay {
backdrop-filter: blur(8px);
opacity: 1;
}
.dashboard-widgets h4 {
cursor: -moz-grab;
cursor: -webkit-grab;
cursor: grab;
}
.dashboard-widgets .dropdown-toggle .ri-more-2-fill {
display: inline-block;
font-size: 15px;
color: var(--icon-secondary);
transition: var(--transition-all-productive);
width: 30px;
margin: -10px;
text-align: center;
line-height: 30px;
border-radius: var(--border-radius-md);
}
.dashboard-widgets .card-header h4 {
width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-widgets .tile table {
table-layout: fixed;
}
.dashboard-widgets .tile table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-widgets .tile table td:last-child {
width: 30px;
}
.chart-wrapper {
position: relative;
}
.chart-wrapper .legend {
background: #000;
opacity: 0;
position: absolute;
bottom: -17px;
width: 100%;
-moz-border-radius: 0 0 4px 4px;
-webkit-border-radius: 0 0 4px 4px;
border-radius: 0 0 4px 4px;
color: #fff;
-webkit-transition: all 0.3s ease-in-out;
}
.chart-wrapper:hover .legend {
opacity: 0.8;
}
.chart-wrapper .legend ul {
list-style: none;
margin: 0;
padding: 5px 0;
}
.chart-wrapper .legend li {
margin: 0 10px;
padding: 5px 0;
}
.chart-wrapper .legend li span {
display: inline-block;
width: 14px;
height: 8px;
margin-right: 5px;
-moz-border-radius: 4px;
-webkit-border-radius: 4px;
border-radius: 4px;
}
@media (max-width: 700px) {
.dashboard-widgets .widget {
width: 100% !important;
}
}
#dashboard-widgets .spinner {
text-align: center;
line-height: 250px;
font-size: 24px;
}

View File

@@ -0,0 +1,339 @@
// DashboardBundle
// Use absolute path to keep dashboard working when app is in subdir
Mautic.widgetUrl = mauticBasePath + '/s/dashboard/widget/';
/**
* @type jQuery DOM element to be replaced with spinner
*/
Mautic.dashboardSubmitButton = false; // Button text, to be get and shown instead of spinner
/**
* Init dashboard events
* @param container
*/
Mautic.dashboardOnLoad = function (container) {
Mautic.loadWidgets();
};
/**
* Load all widgets on initial page render
*/
Mautic.loadWidgets = function () {
Mautic.dashboardFilterPreventSubmit();
jQuery('.widget').each(function() {
let widgetId = jQuery(this).attr('data-widget-id');
let container = jQuery('.widget[data-widget-id="'+widgetId+'"]');
jQuery.ajax({
url: Mautic.widgetUrl+widgetId+'?ignoreAjax=true',
}).done(function(response) {
Mautic.widgetOnLoad(container, response);
});
});
jQuery(document).ajaxComplete(function(){
Mautic.initDashboardFilter();
});
};
/**
* Init dashboard filter events after widget load
*/
Mautic.initDashboardFilter = function () {
let form = jQuery('form[name="daterange"]');
form.find('button')
.replaceWith(Mautic.dashboardSubmitButton);
form
.unbind('submit')
.on('submit', function(e){
e.preventDefault();
Mautic.dashboardFilterPreventSubmit();
jQuery('.widget').each(function() {
let widgetId = jQuery(this).attr('data-widget-id');
let element = jQuery('.widget[data-widget-id="' + widgetId + '"]');
jQuery.ajax({
type: 'POST',
url: Mautic.widgetUrl + widgetId + '?ignoreAjax=true',
data: form.serializeArray(),
success: function (response) {
Mautic.widgetOnLoad(element, response);
}
});
});
});
};
/**
* Prevent filter from submit, show spinner instead of send button
*/
Mautic.dashboardFilterPreventSubmit = function() {
let form = jQuery('form[name="daterange"]');
let button = form.find('button:first');
Mautic.dashboardSubmitButton = button.clone();
button.width(button.width()+'px'); // Keep button width
button.html('<i class="ri-loader-3-line ri-spin"></i>');
jQuery('.widget').find('.card-body').html('<div class="spinner"><i class="ri-loader-3-line ri-spin"></i></div>');
form
.unbind('submit')
.on('submit', function(e){
e.preventDefault();
});
};
Mautic.dashboardOnUnload = function(id) {
// Trash initialized dashboard vars on app content change.
mQuery('.jvectormap-tip').remove();
};
/**
* Render widget from XHR to DOM
*
* @param container
* @param response
*/
Mautic.widgetOnLoad = function(container, response) {
if (!response.widgetId) return;
// target in DOM
var widget = mQuery('.widget[data-widget-id="' + response.widgetId + '"]');
// source from response
var widgetHtml = mQuery(response.widgetHtml);
// initialize edit button modal again
widgetHtml.find("*[data-toggle='ajaxmodal']").on('click.ajaxmodal', function (event) {
event.preventDefault();
Mautic.ajaxifyModal(this, event);
});
// Create the new widget wrapper and add it to the 0 position if doesn't exist (probably a new one)
if (!widget.length) {
widget = mQuery('<div/>')
.addClass('widget')
.attr('data-widget-id', response.widgetId);
mQuery('#dashboard-widgets').prepend(widget);
}
widget.html(widgetHtml)
.css('width', response.widgetWidth + '%')
.css('height', response.widgetHeight + '%');
Mautic.renderCharts(widgetHtml);
const map = widgetHtml.find('.vector-map').first();
if (map.length && !map.hasClass('map-rendered')) {
Mautic.initMap(widgetHtml, 'regions');
}
Mautic.initWidgetRemoveEvents();
Mautic.initWidgetSorting();
Mautic.initDashboardFilter();
};
Mautic.initWidgetRemoveEvents = function () {
jQuery('.remove-widget')
.unbind('click')
.on('click', function(e) {
e.preventDefault();
element = jQuery(this);
let url = element.attr('href');
element.closest('.widget').remove();
jQuery.ajax({
url: url,
});
});
};
Mautic.initWidgetSorting = function () {
var widgetsWrapper = mQuery('#dashboard-widgets');
var bodyOverflow = {};
widgetsWrapper.sortable({
handle: '.card-header h4',
placeholder: 'sortable-placeholder',
items: '.widget',
opacity: 0.9,
scroll: true,
scrollSpeed: 10,
tolerance: "pointer",
cursor: 'move',
appendTo: '#dashboard-widgets',
helper: function(e, ui) {
// Ensure the draggable retains it's original size and that the margin doesn't cause things to bounce around
ui.children().each(function() {
mQuery(this).width(mQuery(this).width());
mQuery(this).height(mQuery(this).height());
});
// 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'
});
mQuery("#dashboard-widgets .widget").each(function(i) {
var item = mQuery(this);
var item_clone = item.clone();
var canvas = item.find('canvas').first();
if (canvas.length) {
// Copy the canvas
var destCanvas = item_clone.find('canvas').first();
var destCtx = destCanvas[0].getContext('2d');
destCtx.drawImage(canvas[0], 0, 0);
}
item.data("clone", item_clone);
var position = item.position();
item_clone
.css({
left: position.left,
top: position.top,
width: item.width(),
visibility: "visible",
position: "absolute",
zIndex: 1
});
item.css('visibility', 'hidden');
mQuery("#cloned-widgets").append(item_clone);
});
return ui;
},
start: function(e, ui) {
ui.helper.css('visibility', 'visible');
ui.helper.data("clone").hide();
},
sort: function(e, ui) {
var tile = ui.item.find('.tile').first();
// Prevent margin from pushing the elements out of the way
ui.placeholder.css({
marginTop: "5px",
marginBottom: "5px",
marginLeft: 0,
marginRight: 0
});
},
stop: function() {
// Restore original overflow
mQuery('body').css(bodyOverflow);
mQuery("#dashboard-widgets .widget.exclude-me").each(function() {
var item = mQuery(this);
var clone = item.data("clone");
var position = item.position();
clone.css("left", position.left);
clone.css("top", position.top);
clone.show();
item.removeClass("exclude-me");
});
mQuery("#dashboard-widgets .widget").css("visibility", "visible");
mQuery("#cloned-widgets .widget").remove();
Mautic.saveWidgetSorting();
},
change: function(e, ui) {
mQuery("#dashboard-widgets .widget:not(.exclude-me)").each(function() {
var item = mQuery(this);
var clone = item.data("clone");
clone.stop(true, false);
var position = item.position();
clone.animate({
left: position.left,
top: position.top
}, 200);
});
}
}).disableSelection();
}
Mautic.saveWidgetSorting = function () {
var widgetsWrapper = mQuery('#dashboard-widgets');
var widgets = widgetsWrapper.children();
var ordering = [];
widgets.each(function(index, value) {
ordering.push(mQuery(this).attr('data-widget-id'));
});
Mautic.ajaxActionRequest('dashboard:updateWidgetOrdering', {'ordering': ordering}, function(response) {
// @todo handle errors
});
}
Mautic.updateWidgetForm = function (element) {
Mautic.activateLabelLoadingIndicator('widget_type');
var formWrapper = mQuery(element).closest('form');
var WidgetFormValues = formWrapper.serializeArray();
Mautic.ajaxActionRequest('dashboard:updateWidgetForm', WidgetFormValues, function(response) {
if (response.formHtml) {
var formHtml = mQuery(response.formHtml);
formHtml.find('#widget_buttons').addClass('hide hidden');
formWrapper.html(formHtml.children());
Mautic.onPageLoad('#widget_params');
}
Mautic.removeLabelLoadingIndicator();
});
};
Mautic.exportDashboardLayout = function(text, baseUrl) {
var name = prompt(text, "");
if (name !== null) {
if (name) {
baseUrl = baseUrl + "?name=" + encodeURIComponent(name);
}
window.location = baseUrl;
}
};
Mautic.saveDashboardLayout = function(text) {
var name = prompt(text, "");
if (name) {
mQuery.ajax({
type: 'POST',
url: mauticBaseUrl+'s/dashboard/save',
data: {name: name}
});
}
};
Mautic.setDateRange = function(option) {
var today = new Date();
var fromDate, toDate;
switch(option) {
case 'today':
fromDate = today;
toDate = today;
break;
case 'yesterday':
fromDate = new Date(today.getTime() - (24 * 60 * 60 * 1000));
toDate = fromDate;
break;
default:
if (typeof option === 'number') {
fromDate = new Date(today.getTime() - (option * 24 * 60 * 60 * 1000));
toDate = today;
} else {
console.error('Invalid option');
return;
}
}
document.getElementById('daterange_date_from').value = Mautic.formatDate(fromDate);
document.getElementById('daterange_date_to').value = Mautic.formatDate(toDate);
document.getElementById('daterange_apply').click();
};
Mautic.formatDate = function(date) {
var monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
];
return monthNames[date.getMonth()] + " " + date.getDate() + ", " + date.getFullYear();
};

View File

@@ -0,0 +1,58 @@
<?php
return [
'routes' => [
'main' => [
'mautic_dashboard_index' => [
'path' => '/dashboard',
'controller' => 'Mautic\DashboardBundle\Controller\DashboardController::indexAction',
],
'mautic_dashboard_widget' => [
'path' => '/dashboard/widget/{widgetId}',
'controller' => 'Mautic\DashboardBundle\Controller\DashboardController::widgetAction',
],
'mautic_dashboard_action' => [
'path' => '/dashboard/{objectAction}/{objectId}',
'controller' => 'Mautic\DashboardBundle\Controller\DashboardController::executeAction',
],
],
'api' => [
'mautic_widget_types' => [
'path' => '/data',
'controller' => 'Mautic\DashboardBundle\Controller\Api\WidgetApiController::getTypesAction',
],
'mautic_widget_data' => [
'path' => '/data/{type}',
'controller' => 'Mautic\DashboardBundle\Controller\Api\WidgetApiController::getDataAction',
],
],
],
'menu' => [
'main' => [
'priority' => 100,
'items' => [
'mautic.dashboard.menu.index' => [
'route' => 'mautic_dashboard_index',
'iconClass' => 'ri-funds-fill',
],
],
],
],
'services' => [
'other' => [
'mautic.dashboard.widget' => [
'class' => Mautic\DashboardBundle\Dashboard\Widget::class,
'arguments' => [
'mautic.dashboard.model.dashboard',
'mautic.helper.user',
'request_stack',
],
],
],
],
'parameters' => [
'dashboard_import_dir' => '%mautic.application_dir%/app/assets/dashboards',
'dashboard_import_user_dir' => '%mautic.application_dir%/media/dashboards',
],
];

View File

@@ -0,0 +1,23 @@
<?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\\DashboardBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('Mautic\\DashboardBundle\\Entity\\', '../Entity/*Repository.php');
$services->alias('mautic.dashboard.model.dashboard', Mautic\DashboardBundle\Model\DashboardModel::class);
};

View File

@@ -0,0 +1,97 @@
<?php
namespace Mautic\DashboardBundle\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Form\Type\WidgetType;
use Mautic\DashboardBundle\Model\DashboardModel;
use Mautic\PageBundle\Entity\Hit;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class AjaxController extends CommonAjaxController
{
/**
* Count how many visitors are currently viewing a page.
*/
public function viewingVisitorsAction(EntityManagerInterface $entityManager): JsonResponse
{
$dataArray = ['success' => 0];
/** @var \Mautic\PageBundle\Entity\PageRepository $pageRepository */
$pageRepository = $entityManager->getRepository(Hit::class);
$dataArray['viewingVisitors'] = $pageRepository->countVisitors(60, true);
$dataArray['success'] = 1;
return $this->sendJsonResponse($dataArray);
}
/**
* Returns HTML of a new widget based on its values.
*/
public function updateWidgetFormAction(Request $request, FormFactoryInterface $formFactory): JsonResponse
{
$data = $request->request->all()['widget'] ?? [];
$dataArray = ['success' => 0];
// Clear params if type is not selected
if (empty($data['type'])) {
unset($data['params']);
}
$widget = new Widget();
$form = $formFactory->create(WidgetType::class, $widget);
$formHtml = $this->render('@MauticDashboard/Widget/form.html.twig',
['form' => $form->submit($data)->createView()]
)->getContent();
$dataArray['formHtml'] = $formHtml;
$dataArray['success'] = 1;
return $this->sendJsonResponse($dataArray);
}
/**
* Saves the new ordering of dashboard widgets.
*/
public function updateWidgetOrderingAction(Request $request): JsonResponse
{
$data = $request->request->all()['ordering'] ?? [];
$dashboardModel = $this->getModel('dashboard');
\assert($dashboardModel instanceof DashboardModel);
$repo = $dashboardModel->getRepository();
$repo->updateOrdering(array_flip($data), $this->user->getId());
$dataArray = ['success' => 1];
return $this->sendJsonResponse($dataArray);
}
/**
* Deletes the entity.
*/
public function deleteAction(Request $request): JsonResponse
{
$objectId = $request->request->get('widget');
$dataArray = ['success' => 0];
// @todo: build permissions
// if (!$this->security->isGranted('dashobard:widgets:delete')) {
// return $this->accessDenied();
// }
/** @var DashboardModel $model */
$model = $this->getModel('dashboard');
$entity = $model->getEntity($objectId);
if ($entity) {
$model->deleteEntity($entity);
$name = $entity->getName();
$dataArray['success'] = 1;
}
return $this->sendJsonResponse($dataArray);
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace Mautic\DashboardBundle\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\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\DashboardBundle\DashboardEvents;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Event\WidgetTypeListEvent;
use Mautic\DashboardBundle\Model\DashboardModel;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
/**
* @extends CommonApiController<Widget>
*/
class WidgetApiController extends CommonApiController
{
/**
* @var DashboardModel|null
*/
protected $model;
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)
{
$dashboardModel = $modelFactory->getModel('dashboard');
\assert($dashboardModel instanceof DashboardModel);
$this->model = $dashboardModel;
$this->entityClass = Widget::class;
$this->entityNameOne = 'widget';
$this->entityNameMulti = 'widgets';
$this->serializerGroups = [];
parent::__construct($security, $translator, $entityResultHelper, $router, $formFactory, $appVersion, $requestStack, $doctrine, $modelFactory, $dispatcher, $coreParametersHelper);
}
/**
* Obtains a list of available widget types.
*
* @return Response
*/
public function getTypesAction()
{
$dispatcher = $this->dispatcher;
$event = new WidgetTypeListEvent();
$event->setTranslator($this->translator);
$dispatcher->dispatch($event, DashboardEvents::DASHBOARD_ON_MODULE_LIST_GENERATE);
$view = $this->view(['success' => 1, 'types' => $event->getTypes()], Response::HTTP_OK);
return $this->handleView($view);
}
/**
* Obtains a list of available widget types.
*
* @param string $type of the widget
*
* @return Response
*/
public function getDataAction(Request $request, $type)
{
$start = microtime(true);
$timezone = InputHelper::clean($request->get('timezone', null));
$from = InputHelper::clean($request->get('dateFrom', null));
$to = InputHelper::clean($request->get('dateTo', null));
$dataFormat = InputHelper::clean($request->get('dataFormat', null));
$unit = InputHelper::clean($request->get('timeUnit', 'Y'));
$dataset = InputHelper::clean($request->query->all()['dataset'] ?? $request->request->all()['dataset'] ?? []);
$response = ['success' => 0];
try {
DateTimeHelper::validateMysqlDateTimeUnit($unit);
} catch (\InvalidArgumentException $e) {
return $this->returnError($e->getMessage(), Response::HTTP_BAD_REQUEST);
}
if ($timezone) {
$fromDate = new \DateTime($from, new \DateTimeZone($timezone));
$toDate = new \DateTime($to, new \DateTimeZone($timezone));
} else {
$fromDate = new \DateTime($from);
$toDate = new \DateTime($to);
}
$params = [
'timeUnit' => InputHelper::clean($request->get('timeUnit', 'Y')),
'dateFormat' => InputHelper::clean($request->get('dateFormat', null)),
'dateFrom' => $fromDate,
'dateTo' => $toDate,
'limit' => (int) $request->get('limit', null),
'filter' => InputHelper::clean($request->query->all()['filter'] ?? $request->request->all()['filter'] ?? []),
'dataset' => $dataset,
];
// Merge filters into the root array as well as that's how widget edit forms send them.
$params = array_merge($params, $params['filter']);
$cacheTimeout = (int) $request->get('cacheTimeout', 0);
$widgetHeight = (int) $request->get('height', 300);
$widget = new Widget();
$widget->setParams($params);
$widget->setType($type);
$widget->setHeight($widgetHeight);
$widget->setCacheTimeout($cacheTimeout);
$this->model->populateWidgetContent($widget);
$data = $widget->getTemplateData();
if (!$data) {
return $this->notFound();
}
if ('raw' == $dataFormat) {
if (isset($data['chartData']['labels']) && isset($data['chartData']['datasets'])) {
$rawData = [];
foreach ($data['chartData']['datasets'] as $dataset) {
$rawData[$dataset['label']] = [];
foreach ($dataset['data'] as $key => $value) {
$rawData[$dataset['label']][$data['chartData']['labels'][$key]] = $value;
}
}
$data = $rawData;
} elseif (isset($data['raw'])) {
$data = $data['raw'];
}
} else {
if (isset($data['raw'])) {
unset($data['raw']);
}
}
$response['cached'] = $widget->isCached();
$response['execution_time'] = microtime(true) - $start;
$response['success'] = 1;
$response['data'] = $data;
$view = $this->view($response, Response::HTTP_OK);
return $this->handleView($view);
}
}

View File

@@ -0,0 +1,562 @@
<?php
namespace Mautic\DashboardBundle\Controller;
use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\CoreBundle\Form\Type\DateRangeType;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Helper\PhpVersionHelper;
use Mautic\CoreBundle\Release\ThisRelease;
use Mautic\DashboardBundle\Dashboard\Widget as WidgetService;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Form\Type\UploadType;
use Mautic\DashboardBundle\Model\DashboardModel;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\RouterInterface;
use Twig\Environment;
class DashboardController extends AbstractFormController
{
/**
* Generates the default view.
*/
public function indexAction(Request $request, WidgetService $widget, FormFactoryInterface $formFactory, PathsHelper $pathsHelper, RouterInterface $urlGenerator): Response
{
$model = $this->getModel('dashboard');
\assert($model instanceof DashboardModel);
$widgets = $model->getWidgets();
// Apply the default dashboard if no widget exists
if (!count($widgets) && $this->user->getId()) {
return $this->applyDashboardFileAction($request, $pathsHelper, $urlGenerator, 'global.default');
}
$action = $this->generateUrl('mautic_dashboard_index');
$dateRangeFilter = $request->query->all()['daterange'] ?? $request->request->all()['daterange'] ?? [];
// Set new date range to the session
if ($request->isMethod(Request::METHOD_POST)) {
if (!empty($dateRangeFilter['date_from'])) {
$from = new \DateTime($dateRangeFilter['date_from']);
$request->getSession()->set('mautic.daterange.form.from', $from->format(DateTimeHelper::FORMAT_DB_DATE_ONLY));
}
if (!empty($dateRangeFilter['date_to'])) {
$to = new \DateTime($dateRangeFilter['date_to']);
$request->getSession()->set('mautic.daterange.form.to', $to->format(DateTimeHelper::FORMAT_DB_DATE_ONLY.' 23:59:59'));
}
$model->clearDashboardCache();
}
// Set new date range to the session, if present in POST
$widget->setFilter($request);
// Load date range from session
$filter = $model->getDefaultFilter();
// Set the final date range to the form
$dateRangeFilter['date_from'] = $filter['dateFrom']->format(WidgetService::FORMAT_HUMAN);
$dateRangeFilter['date_to'] = $filter['dateTo']->format(WidgetService::FORMAT_HUMAN);
$dateRangeForm = $formFactory->create(DateRangeType::class, $dateRangeFilter, ['action' => $action]);
$model->populateWidgetsContent($widgets, $filter);
$releaseMetadata = ThisRelease::getMetadata();
$model->populateWidgetPreviews($widgets);
return $this->delegateView([
'viewParameters' => [
'security' => $this->security,
'widgets' => $widgets,
'dateRangeForm' => $dateRangeForm->createView(),
'phpVersion' => [
'isOutdated' => version_compare(PHP_VERSION, $releaseMetadata->getShowPHPVersionWarningIfUnder(), 'lt'),
'version' => PhpVersionHelper::getCurrentSemver(),
],
],
'contentTemplate' => '@MauticDashboard/Dashboard/index.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_dashboard_index',
'mauticContent' => 'dashboard',
'route' => $this->generateUrl('mautic_dashboard_index'),
],
]);
}
public function widgetAction(Request $request, WidgetService $widgetService, Environment $twig, $widgetId): JsonResponse
{
if (!$request->isXmlHttpRequest()) {
throw new NotFoundHttpException('Not found.');
}
$widgetService->setFilter($request);
$widget = $widgetService->get((int) $widgetId);
if (!$widget) {
throw new NotFoundHttpException('Not found.');
}
$content = $twig->render(
'@MauticDashboard/Dashboard/widget.html.twig',
['widget' => $widget]
);
return new JsonResponse([
'success' => 1,
'widgetId' => $widgetId,
'widgetHtml' => $content,
'widgetWidth' => $widget->getWidth(),
'widgetHeight' => $widget->getHeight(),
]);
}
/**
* Generate new dashboard widget and processes post data.
*
* @return JsonResponse|RedirectResponse|Response
*/
public function newAction(Request $request, FormFactoryInterface $formFactory)
{
// retrieve the entity
$widget = new Widget();
$model = $this->getModel('dashboard');
\assert($model instanceof DashboardModel);
$action = $this->generateUrl('mautic_dashboard_action', ['objectAction' => 'new']);
// get the user form factory
$form = $model->createForm($widget, $formFactory, $action);
$closeModal = false;
$valid = false;
// /Check for a submitted form and process it
if ($request->isMethod(Request::METHOD_POST)) {
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$closeModal = true;
// form is valid so process the data
$model->saveEntity($widget);
}
} else {
$closeModal = true;
}
}
if ($closeModal) {
// just close the modal
$passthroughVars = [
'closeModal' => 1,
'mauticContent' => 'widget',
];
$filter = $model->getDefaultFilter();
$model->populateWidgetContent($widget, $filter);
if ($valid && !$cancelled) {
$passthroughVars['upWidgetCount'] = 1;
$passthroughVars['widgetHtml'] = $this->renderView('@MauticDashboard/Widget/detail.html.twig', [
'widget' => $widget,
]);
$passthroughVars['widgetId'] = $widget->getId();
$passthroughVars['widgetWidth'] = $widget->getWidth();
$passthroughVars['widgetHeight'] = $widget->getHeight();
}
return new JsonResponse($passthroughVars);
} else {
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
],
'contentTemplate' => '@MauticDashboard/Widget/form.html.twig',
]);
}
}
/**
* edit widget and processes post data.
*
* @return JsonResponse|RedirectResponse|Response
*/
public function editAction(Request $request, FormFactoryInterface $formFactory, $objectId)
{
$model = $this->getModel('dashboard');
\assert($model instanceof DashboardModel);
$widget = $model->getEntity($objectId);
$action = $this->generateUrl('mautic_dashboard_action', ['objectAction' => 'edit', 'objectId' => $objectId]);
// get the user form factory
$form = $model->createForm($widget, $formFactory, $action);
$closeModal = false;
$valid = false;
// /Check for a submitted form and process it
if ($request->isMethod(Request::METHOD_POST)) {
if (!$cancelled = $this->isFormCancelled($form)) {
if ($valid = $this->isFormValid($form)) {
$closeModal = true;
// form is valid so process the data
$model->saveEntity($widget);
}
} else {
$closeModal = true;
}
}
if ($closeModal) {
// just close the modal
$passthroughVars = [
'closeModal' => 1,
'mauticContent' => 'widget',
];
$filter = $model->getDefaultFilter();
$model->populateWidgetContent($widget, $filter);
if ($valid && !$cancelled) {
$passthroughVars['upWidgetCount'] = 1;
$passthroughVars['widgetHtml'] = $this->renderView('@MauticDashboard/Widget/detail.html.twig', [
'widget' => $widget,
]);
$passthroughVars['widgetId'] = $widget->getId();
$passthroughVars['widgetWidth'] = $widget->getWidth();
$passthroughVars['widgetHeight'] = $widget->getHeight();
}
return new JsonResponse($passthroughVars);
} else {
return $this->delegateView([
'viewParameters' => [
'form' => $form->createView(),
],
'contentTemplate' => '@MauticDashboard/Widget/form.html.twig',
]);
}
}
/**
* Deletes entity if exists.
*
* @param int $objectId
*/
public function deleteAction(Request $request, $objectId): Response
{
if (!$request->isXmlHttpRequest()) {
throw new BadRequestHttpException();
}
$flashes = [];
$success = 0;
/** @var DashboardModel $model */
$model = $this->getModel('dashboard');
$entity = $model->getEntity($objectId);
if ($entity) {
$model->deleteEntity($entity);
$name = $entity->getName();
$flashes[] = [
'type' => 'notice',
'msg' => 'mautic.core.notice.deleted',
'msgVars' => [
'%name%' => $name,
'%id%' => $objectId,
],
];
$success = 1;
} else {
$flashes[] = [
'type' => 'error',
'msg' => 'mautic.api.client.error.notfound',
'msgVars' => ['%id%' => $objectId],
];
}
return $this->postActionRedirect(
[
'success' => $success,
'flashes' => $flashes,
]
);
}
/**
* Saves the widgets of current user into a json and stores it for later as a file.
*
* @return Response
*/
public function saveAction(Request $request)
{
// Accept only AJAX POST requests because those are check for CSRF tokens
if (!$request->isMethod(Request::METHOD_POST) || !$request->isXmlHttpRequest()) {
return $this->accessDenied();
}
$name = $this->getNameFromRequest($request);
/** @var DashboardModel $dashboardModel */
$dashboardModel = $this->getModel('dashboard');
try {
$dashboardModel->saveSnapshot($name);
$type = 'notice';
$msg = $this->translator->trans('mautic.dashboard.notice.save', [
'%name%' => $name,
'%viewUrl%' => $this->generateUrl(
'mautic_dashboard_action',
[
'objectAction' => 'import',
]
),
], 'flashes');
} catch (IOException $e) {
$type = 'error';
$msg = $this->translator->trans('mautic.dashboard.error.save', [
'%msg%' => $e->getMessage(),
], 'flashes');
}
return $this->postActionRedirect(
[
'flashes' => [
[
'type' => $type,
'msg' => $msg,
],
],
]
);
}
/**
* Exports the widgets of current user into a json file and downloads it.
*/
public function exportAction(Request $request): JsonResponse
{
$dashboardModel = $this->getModel('dashboard');
\assert($dashboardModel instanceof DashboardModel);
$filename = InputHelper::filename($this->getNameFromRequest($request), 'json');
$response = new JsonResponse($dashboardModel->toArray($filename));
$response->setEncodingOptions($response->getEncodingOptions() | JSON_PRETTY_PRINT);
$response->headers->set('Content-Type', 'application/force-download');
$response->headers->set('Content-Type', 'application/octet-stream');
$response->headers->set('Content-Disposition', 'attachment; filename="'.$filename.'"');
$response->headers->set('Expires', '0');
$response->headers->set('Cache-Control', 'must-revalidate');
$response->headers->set('Pragma', 'public');
return $response;
}
/**
* Exports the widgets of current user into a json file.
*/
public function deleteDashboardFileAction(Request $request, PathsHelper $pathsHelper): RedirectResponse
{
$file = $request->get('file');
$parts = explode('.', $file);
$type = array_shift($parts);
$name = implode('.', $parts);
$dir = $pathsHelper->getSystemPath("dashboard.$type");
$path = $dir.'/'.$name.'.json';
if (file_exists($path) && is_writable($path)) {
unlink($path);
}
return $this->redirectToRoute('mautic_dashboard_action', ['objectAction' => 'import']);
}
/**
* Applies dashboard layout.
*
* @param string|null $file
*/
public function applyDashboardFileAction(Request $request, PathsHelper $pathsHelper, RouterInterface $urlGenerator, $file = null): RedirectResponse
{
if (!$file) {
$file = $request->get('file');
}
$parts = explode('.', $file);
$type = array_shift($parts);
$name = implode('.', $parts);
$dir = $pathsHelper->getSystemPath("dashboard.$type");
$path = $dir.'/'.$name.'.json';
if (!file_exists($path) || !is_readable($path)) {
$this->addFlashMessage('mautic.dashboard.upload.filenotfound', [], 'error', 'validators');
return $this->redirectToRoute('mautic_dashboard_action', ['objectAction' => 'import']);
}
$widgets = json_decode(file_get_contents($path), true);
if (isset($widgets['widgets'])) {
$widgets = $widgets['widgets'];
}
if ($widgets) {
/** @var DashboardModel $model */
$model = $this->getModel('dashboard');
$model->clearDashboardCache();
$currentWidgets = $model->getWidgets();
if (count($currentWidgets)) {
foreach ($currentWidgets as $widget) {
$model->deleteEntity($widget);
}
}
$filter = $model->getDefaultFilter();
foreach ($widgets as $widget) {
$widget = $model->populateWidgetEntity($widget);
$model->saveEntity($widget);
}
}
return $this->redirect($urlGenerator->generate('mautic_dashboard_index'));
}
public function importAction(Request $request, FormFactoryInterface $formFactory, PathsHelper $pathsHelper): Response
{
$preview = $request->get('preview');
/** @var DashboardModel $model */
$model = $this->getModel('dashboard');
$directories = [
'user' => $pathsHelper->getSystemPath('dashboard.user'),
'global' => $pathsHelper->getSystemPath('dashboard.global'),
];
$action = $this->generateUrl('mautic_dashboard_action', ['objectAction' => 'import']);
$form = $formFactory->create(UploadType::class, [], ['action' => $action]);
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isFormCancelled($form)) {
if ($this->isFormValid($form)) {
$fileData = $form['file']->getData();
if (!empty($fileData)) {
$extension = pathinfo($fileData->getClientOriginalName(), PATHINFO_EXTENSION);
if ('json' === $extension) {
$fileData->move($directories['user'], $fileData->getClientOriginalName());
} else {
$form->addError(
new FormError(
$this->translator->trans('mautic.core.not.allowed.file.extension', ['%extension%' => $extension], 'validators')
)
);
}
} else {
$form->addError(
new FormError(
$this->translator->trans('mautic.dashboard.upload.filenotfound', [], 'validators')
)
);
}
}
}
}
$dashboardFiles = ['user' => [], 'gobal' => []];
$dashboards = [];
if (is_readable($directories['user'])) {
// User specific layouts
chdir($directories['user']);
$dashboardFiles['user'] = glob('*.json');
}
if (is_readable($directories['global'])) {
// Global dashboards
chdir($directories['global']);
$dashboardFiles['global'] = glob('*.json');
}
foreach ($dashboardFiles as $type => $dirDashboardFiles) {
$tempDashboard = [];
foreach ($dirDashboardFiles as $dashId => $dashboard) {
$dashboard = str_replace('.json', '', $dashboard);
$config = json_decode(
file_get_contents($directories[$type].'/'.$dirDashboardFiles[$dashId]),
true
);
// Check for name, description, etc
$tempDashboard[$dashboard] = [
'type' => $type,
'name' => $config['name'] ?? $dashboard,
'description' => $config['description'] ?? '',
'widgets' => $config['widgets'] ?? $config,
];
}
// Sort by name
uasort($tempDashboard,
fn ($a, $b): int => strnatcasecmp($a['name'], $b['name'])
);
$dashboards = array_merge(
$dashboards,
$tempDashboard
);
}
if ($preview && isset($dashboards[$preview])) {
// @todo check is_writable
$widgets = $dashboards[$preview]['widgets'];
$filter = $model->getDefaultFilter();
$model->populateWidgetsContent($widgets, $filter);
} else {
$widgets = [];
}
return $this->delegateView(
[
'viewParameters' => [
'form' => $form->createView(),
'dashboards' => $dashboards,
'widgets' => $widgets,
'preview' => $preview,
],
'contentTemplate' => '@MauticDashboard/Dashboard/import.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_dashboard_index',
'mauticContent' => 'dashboardImport',
'route' => $this->generateUrl(
'mautic_dashboard_action',
[
'objectAction' => 'import',
]
),
],
]
);
}
/**
* Gets name from request and defaults it to the timestamp if not provided.
*
* @throws \Exception
*/
private function getNameFromRequest(Request $request): string
{
return $request->get('name', (new \DateTime())->format('Y-m-dTH:i:s'));
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Mautic\DashboardBundle\Dashboard;
use Mautic\CoreBundle\Helper\DateTimeHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\DashboardBundle\Model\DashboardModel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class Widget
{
public const FORMAT_HUMAN = 'M j, Y';
public function __construct(
private DashboardModel $dashboardModel,
private UserHelper $userHelper,
private RequestStack $requestStack,
) {
}
/**
* Get ready widget to populate in template.
*
* @return bool|\Mautic\DashboardBundle\Entity\Widget
*/
public function get(int $widgetId)
{
/** @var \Mautic\DashboardBundle\Entity\Widget $widget */
$widget = $this->dashboardModel->getEntity($widgetId);
if (null === $widget || !$widget->getId()) {
throw new NotFoundHttpException('Not found.');
}
if ($widget->getCreatedBy() !== $this->userHelper->getUser()->getId()) {
// Unauthorized access
throw new AccessDeniedException();
}
$filter = $this->dashboardModel->getDefaultFilter();
$this->dashboardModel->populateWidgetContent($widget, $filter);
return $widget;
}
/**
* Set filter from POST to session.
*
* @throws \Exception
*/
public function setFilter(Request $request): void
{
if (!$request->isMethod(Request::METHOD_POST)) {
return;
}
$dateRangeFilter = $request->query->all()['daterange'] ?? $request->request->all()['daterange'] ?? [];
if (!empty($dateRangeFilter['date_from'])) {
$from = new \DateTime($dateRangeFilter['date_from']);
$this->requestStack->getSession()->set('mautic.daterange.form.from', $from->format(DateTimeHelper::FORMAT_DB_DATE_ONLY));
}
if (!empty($dateRangeFilter['date_to'])) {
$to = new \DateTime($dateRangeFilter['date_to']);
$this->requestStack->getSession()->set('mautic.daterange.form.to', $to->format(DateTimeHelper::FORMAT_DB_DATE_ONLY));
}
$this->dashboardModel->clearDashboardCache();
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\DashboardBundle;
/**
* Events available for DashboardBundle.
*/
final class DashboardEvents
{
/**
* The mautic.dashboard_on_widget_list_generate event is dispatched when generating a list of available widget types.
*
* The event listener receives a
* Mautic\DashboardBundle\Event\WidgetTypeListEvent instance.
*
* @var string
*/
public const DASHBOARD_ON_MODULE_LIST_GENERATE = 'mautic.dashboard_on_widget_list_generate';
/**
* The mautic.dashboard_on_widget_form_generate event is dispatched when generating the form of a widget type.
*
* The event listener receives a
* Mautic\DashboardBundle\Event\WidgetFormEvent instance.
*
* @var string
*/
public const DASHBOARD_ON_MODULE_FORM_GENERATE = 'mautic.dashboard_on_widget_form_generate';
/**
* The mautic.dashboard_on_widget_detail_generate event is dispatched when generating the detail of a widget type.
*
* The event listener receives a
* Mautic\DashboardBundle\Event\WidgetDetailEvent instance.
*
* @var string
*/
public const DASHBOARD_ON_MODULE_DETAIL_GENERATE = 'mautic.dashboard_on_widget_detail_generate';
/**
* The mautic.dashboard_on_widget_detail_pre_load event is dispatched before detail of a widget type is generate.
*
* The event listener receives a
* Mautic\DashboardBundle\Event\WidgetDetailEvent instance.
*
* @var string
*/
public const DASHBOARD_ON_MODULE_DETAIL_PRE_LOAD = 'mautic.dashboard_on_widget_detail_pre_load';
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\DashboardBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticDashboardExtension 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,423 @@
<?php
namespace Mautic\DashboardBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\CoreBundle\Entity\FormEntity;
use Mautic\CoreBundle\Helper\InputHelper;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Mapping\ClassMetadata;
class Widget extends FormEntity
{
/**
* @var int
*/
private $id;
/**
* @var string
*/
private $name;
/**
* @var int
*/
private $width;
/**
* @var int
*/
private $height;
/**
* @var int|null
*/
private $ordering;
/**
* @var string
*/
private $type;
/**
* @var array
*/
private $params = [];
/**
* @var string
*/
private $template;
/**
* @var string
*/
private $errorMessage;
/**
* @var bool
*/
private $cached = false;
/**
* @var int
*/
private $loadTime = 0;
/**
* @var int|null (minutes)
*/
private $cacheTimeout;
/**
* @var array
*/
private $templateData = [];
public function __clone()
{
$this->id = null;
parent::__clone();
}
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('widgets');
$builder->setCustomRepositoryClass(WidgetRepository::class);
$builder->addIdColumns('name', false);
$builder->addField('type', Types::STRING);
$builder->addField('width', Types::INTEGER);
$builder->addField('height', Types::INTEGER);
$builder->addNullableField('cacheTimeout', Types::INTEGER, 'cache_timeout');
$builder->addNullableField('ordering', Types::INTEGER);
$builder->addNullableField('params', Types::ARRAY);
}
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('type', new NotBlank([
'message' => 'mautic.core.type.required',
]));
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* Set name.
*
* @param string $name
*
* @return Widget
*/
public function setName($name)
{
$this->name = InputHelper::string($name);
$this->isChanged('name', $this->name);
return $this;
}
/**
* Get name.
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Set type.
*
* @param string $type
*
* @return Widget
*/
public function setType($type)
{
$this->type = InputHelper::string($type);
$this->isChanged('type', $this->type);
return $this;
}
/**
* Get type.
*
* @return string
*/
public function getType()
{
return $this->type;
}
/**
* Set width.
*
* @param int $width
*
* @return Widget
*/
public function setWidth($width)
{
$this->width = (int) $width;
$this->isChanged('width', $this->width);
return $this;
}
/**
* Get width.
*
* @return int
*/
public function getWidth()
{
return $this->width;
}
/**
* Set height.
*
* @param int $height
*
* @return Widget
*/
public function setHeight($height)
{
$this->height = (int) $height;
$this->isChanged('height', $this->height);
return $this;
}
/**
* Get cache timeout.
*
* @return int (minutes)
*/
public function getCacheTimeout()
{
return $this->cacheTimeout;
}
/**
* Set cache timeout.
*
* @param int $cacheTimeout (minutes)
*
* @return Widget
*/
public function setCacheTimeout($cacheTimeout)
{
$this->isChanged('cacheTimeout', $cacheTimeout);
$this->cacheTimeout = $cacheTimeout;
return $this;
}
/**
* Get height.
*
* @return int
*/
public function getHeight()
{
return $this->height;
}
/**
* Set ordering.
*
* @param int $ordering
*
* @return Widget
*/
public function setOrdering($ordering)
{
$this->ordering = (int) $ordering;
$this->isChanged('ordering', $this->ordering);
return $this;
}
/**
* Get ordering.
*
* @return int
*/
public function getOrdering()
{
return $this->ordering;
}
/**
* Get params.
*
* @return array $params
*/
public function getParams()
{
return $this->params;
}
/**
* Set params.
*
* @return Widget
*/
public function setParams(array $params)
{
$this->isChanged('params', $params);
$this->params = $params;
return $this;
}
/**
* Set template.
*
* @param string $template
*
* @return Widget
*/
public function setTemplate($template)
{
$this->isChanged('template', $template);
$this->template = $template;
return $this;
}
/**
* Get template.
*
* @return string
*/
public function getTemplate()
{
return $this->template;
}
/**
* Get template data.
*
* @return array $templateData
*/
public function getTemplateData()
{
return $this->templateData;
}
/**
* Set template data.
*
* @return Widget
*/
public function setTemplateData(array $templateData)
{
$this->isChanged('templateData', $templateData);
$this->templateData = $templateData;
return $this;
}
/**
* Set errorMessage.
*
* @param string $errorMessage
*
* @return Widget
*/
public function setErrorMessage($errorMessage)
{
$this->errorMessage = $errorMessage;
return $this;
}
/**
* Get errorMessage.
*
* @return string
*/
public function getErrorMessage()
{
return $this->errorMessage;
}
/**
* Set cached flag.
*
* @param bool $cached
*
* @return Widget
*/
public function setCached($cached)
{
$this->cached = $cached;
return $this;
}
/**
* Get cached.
*
* @return bool
*/
public function isCached()
{
return $this->cached;
}
/**
* Set loadTime.
*
* @param string|float|int $loadTime
*
* @return Widget
*/
public function setLoadTime($loadTime)
{
$this->loadTime = $loadTime;
return $this;
}
/**
* Get loadTime.
*
* @return int
*/
public function getLoadTime()
{
return $this->loadTime;
}
public function toArray(): array
{
return [
'name' => $this->getName(),
'width' => $this->getWidth(),
'height' => $this->getHeight(),
'ordering' => $this->getOrdering(),
'type' => $this->getType(),
'params' => $this->getParams(),
'template' => $this->getTemplate(),
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Mautic\DashboardBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<Widget>
*/
class WidgetRepository extends CommonRepository
{
/**
* Update widget ordering.
*
* @param array $ordering
* @param int $userId
*/
public function updateOrdering($ordering, $userId): void
{
$widgets = $this->getEntities(
[
'filter' => [
'createdBy' => $userId,
],
]
);
foreach ($widgets as &$widget) {
if (isset($ordering[$widget->getId()])) {
$widget->setOrdering((int) $ordering[$widget->getId()]);
}
}
$this->saveEntities($widgets);
}
protected function getDefaultOrder(): array
{
return [
['w.ordering', 'ASC'],
];
}
public function getTableAlias(): string
{
return 'w';
}
}

View File

@@ -0,0 +1,390 @@
<?php
namespace Mautic\DashboardBundle\Event;
use Mautic\CacheBundle\Cache\CacheProviderTagAwareInterface;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\CoreBundle\Helper\CacheStorageHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Exception\CouldNotFormatDateTimeException;
use Symfony\Contracts\Translation\TranslatorInterface;
class WidgetDetailEvent extends CommonEvent
{
public const DASHBOARD_CACHE_TAG = 'dashboard_widget';
protected $type;
protected $template;
protected $templateData = [];
protected $errorMessage;
protected $uniqueId;
protected $cacheDir;
protected $uniqueCacheDir;
protected $cacheTimeout;
protected float $startTime;
protected $loadTime = 0;
private string $cacheKeyPath = 'dashboard.widget.';
private bool $isPreview = false;
public function __construct(private TranslatorInterface $translator, private CorePermissions $security, protected Widget $widget, private ?CacheProviderTagAwareInterface $cacheProvider = null)
{
$this->startTime = microtime(true);
$this->setWidget($widget);
}
/**
* Act as widget preview without data.
*/
public function setPreview(bool $isPreview): void
{
$this->isPreview = $isPreview;
}
/**
* Is preview without data?
*/
public function isPreview(): bool
{
return $this->isPreview;
}
/**
* Return unique key, uses legacy methods for BC.
*/
public function getCacheKey(): string
{
$cacheKey = [
$this->getUniqueWidgetId(),
];
$params = $this->getWidget()->getParams();
foreach (['dateTo', 'dateFrom'] as $dateParameter) {
if (isset($params[$dateParameter])) {
try {
$date = $this->castDateTimeToString($params[$dateParameter]);
$cacheKey[] = $date;
} catch (CouldNotFormatDateTimeException) {
}
}
}
// If there are no additional parameters we return uniqueWidgetId as a cache key
// Otherwise we return hashed $cacheKey value
$cacheKey = (1 == count($cacheKey)) ? $this->getUniqueWidgetId() : substr(md5(implode('', $cacheKey)), 0, 16);
return $this->cacheKeyPath.$cacheKey;
}
/**
* Set the cache dir.
*
* @param string $cacheDir
* @param mixed|null $uniqueCacheDir
*/
public function setCacheDir($cacheDir, $uniqueCacheDir = null): void
{
$this->cacheDir = $cacheDir;
$this->uniqueCacheDir = $uniqueCacheDir;
}
/**
* Set the cache timeout.
*
* @param string $cacheTimeout
*/
public function setCacheTimeout($cacheTimeout): void
{
$this->cacheTimeout = (int) $cacheTimeout;
}
/**
* Set the widget type.
*
* @param string $type
*/
public function setType($type): void
{
$this->type = $type;
}
/**
* Get the widget type.
*
* @return string $type
*/
public function getType()
{
return $this->type;
}
/**
* Set the widget entity.
*/
public function setWidget(Widget $widget): void
{
$this->widget = $widget;
$params = $widget->getParams();
// Set required params if undefined
if (!isset($params['timeUnit'])) {
$params['timeUnit'] = null;
}
if (!isset($params['amount'])) {
$params['amount'] = null;
}
if (!isset($params['dateFormat'])) {
$params['dateFormat'] = null;
}
if (!isset($params['filter'])) {
$params['filter'] = [];
}
$widget->setParams($params);
$this->setType($widget->getType());
$this->setCacheTimeout($widget->getCacheTimeout());
}
/**
* Returns the widget entity.
*
* @return Widget $widget
*/
public function getWidget()
{
return $this->widget;
}
/**
* Set the widget template.
*
* @param string $template
*/
public function setTemplate($template): void
{
$this->template = $template;
$this->widget->setTemplate($template);
}
/**
* Get the widget template.
*
* @return string $template
*/
public function getTemplate()
{
return $this->template;
}
/**
* Set the widget template data.
*
* @param bool|null $skipCache
*
* @throws \Psr\Cache\InvalidArgumentException
*/
public function setTemplateData(array $templateData, $skipCache = false): void
{
$this->templateData = $templateData;
$this->widget->setTemplateData($templateData);
$this->widget->setLoadTime(abs(microtime(true) - $this->startTime));
if ($this->usesLegacyCache()) {
// Store the template data to the cache
if (!$skipCache && $this->cacheDir && $this->widget->getCacheTimeout() > 0) {
$cache = new CacheStorageHelper(CacheStorageHelper::ADAPTOR_FILESYSTEM, $this->uniqueCacheDir, null, $this->cacheDir);
// must pass a DateTime object or a int of seconds to expire as 3rd attribute to set().
$expireTime = $this->widget->getCacheTimeout() * 60;
$cache->set($this->getUniqueWidgetId(), $templateData, (int) $expireTime);
}
}
$cItem = $this->cacheProvider->getItem($this->getCacheKey());
if ($this->widget->getCacheTimeout()) {
$cItem->expiresAfter((int) $this->widget->getCacheTimeout() * 60); // This is in minutes
}
$cItem->set($templateData);
$cItem->tag(self::DASHBOARD_CACHE_TAG);
$this->cacheProvider->save($cItem);
}
/**
* Get the widget template data.
*
* @return array<mixed> $templateData
*/
public function getTemplateData()
{
return $this->templateData;
}
/**
* Set en error message.
*
* @param string $errorMessage
*/
public function setErrorMessage($errorMessage): void
{
$this->errorMessage = $errorMessage;
$this->widget->setErrorMessage($errorMessage);
}
/**
* Get an error message.
*
* @return string $errorMessage
*/
public function getErrorMessage()
{
return $this->errorMessage;
}
/**
* Build a unique ID from type and widget params.
*
* @return string
*/
public function getUniqueWidgetId()
{
if ($this->uniqueId) {
return $this->uniqueId;
}
$params = $this->getWidget()->getParams();
// Unset dateFrom and dateTo since they constantly change
unset($params['dateFrom'], $params['dateTo']);
$uniqueSettings = [
'params' => $params,
'width' => $this->getWidget()->getWidth(),
'height' => $this->getWidget()->getHeight(),
'locale' => $this->translator->getLocale(),
];
return $this->uniqueId = $this->getType().'_'.substr(md5(json_encode($uniqueSettings)), 0, 16);
}
/**
* @throws \Psr\Cache\InvalidArgumentException
* Checks the cache for the widget data.
* If cache exists, it sets the TemplateData.
*/
public function isCached(): bool
{
if (!$this->cacheDir && $this->usesLegacyCache()) {
return false;
}
if ($this->usesLegacyCache()) {
$cache = new CacheStorageHelper(CacheStorageHelper::ADAPTOR_FILESYSTEM, $this->uniqueCacheDir, null, $this->cacheDir);
$data = $cache->get($this->getUniqueWidgetId(), $this->cacheTimeout);
if ($data) {
$this->widget->setCached(true);
$this->setTemplateData($data, true);
return true;
}
return false;
}
$cachedItem = $this->cacheProvider->getItem($this->getCacheKey());
if (!$cachedItem->isHit()) {
return false;
}
$this->widget->setCached(true);
$this->setTemplateData($cachedItem->get());
return true;
}
/**
* Get the Translator object.
*
* @return TranslatorInterface
*/
public function getTranslator()
{
return $this->translator;
}
/**
* Check if the user has at least one permission of defined array of permissions.
*/
public function hasPermissions(array $permissions): bool
{
if (!$this->security) {
return true;
}
$perm = $this->security->isGranted($permissions, 'RETURN_ARRAY');
return in_array(true, $perm);
}
/**
* Check if the user has defined permission to see the widgets.
*
* @param string $permission
*
* @return bool
*/
public function hasPermission($permission)
{
if (!$this->security) {
return true;
}
return $this->security->isGranted($permission);
}
/**
* Checks for cache type. This event should be created by factory thus not legacy approach.
*/
private function usesLegacyCache(): bool
{
return is_null($this->cacheProvider);
}
/**
* We need to cast DateTime objects to strings to use them in the cache key.
*
* @param mixed|null $value
*
* @throws CouldNotFormatDateTimeException
*/
private function castDateTimeToString($value): string
{
if ($value instanceof \DateTimeInterface) {
// We use RFC 2822 format because it includes timezone
return $value->format('r');
}
try {
$value = strval($value);
} catch (\Exception) {
throw new CouldNotFormatDateTimeException();
}
return $value;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Mautic\DashboardBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\DashboardBundle\Entity\Widget;
class WidgetFormEvent extends CommonEvent
{
protected $form;
protected $type;
/**
* Set the widget type.
*
* @param string $type
*/
public function setType($type): void
{
$this->type = $type;
}
/**
* Get the widget type.
*
* @return string $type
*/
public function getType()
{
return $this->type;
}
/**
* Set the widget form.
*
* @param string $form
*/
public function setForm($form): void
{
$this->form = $form;
}
/**
* Returns the widget edit form.
*/
public function getForm()
{
return $this->form;
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Mautic\DashboardBundle\Event;
use Mautic\CoreBundle\Event\CommonEvent;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DashboardBundle\Entity\Widget;
use Symfony\Contracts\Translation\TranslatorInterface;
class WidgetTypeListEvent extends CommonEvent
{
/**
* @var array
*/
protected $widgetTypes = [];
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @var CorePermissions
*/
protected $security;
/**
* Adds a new widget type to the widget types list.
*
* @param string $widgetType
* @param string $bundle name (widget category)
*/
public function addType($widgetType, $bundle = 'others'): void
{
$bundle = 'mautic.'.$bundle.'.dashboard.widgets';
$widgetTypeName = 'mautic.widget.'.$widgetType;
if ($this->translator) {
$bundle = $this->translator->trans($bundle);
$widgetTypeName = $this->translator->trans($widgetTypeName);
}
if (!isset($this->widgetTypes[$bundle])) {
$this->widgetTypes[$bundle] = [];
}
$this->widgetTypes[$bundle][$widgetType] = $widgetTypeName;
}
/**
* Set translator if you want the strings to be translated.
*/
public function setTranslator(TranslatorInterface $translator): void
{
$this->translator = $translator;
}
/**
* Set security object to check the perimissions.
*/
public function setSecurity(CorePermissions $security): void
{
$this->security = $security;
}
/**
* Check if the user has permission to see the widgets.
*/
public function hasPermissions(array $permissions): bool
{
if (!$this->security) {
return true;
}
$perm = $this->security->isGranted($permissions, 'RETURN_ARRAY');
return !in_array(false, $perm);
}
/**
* Returns the array of widget types.
*
* @return array $widgetTypes
*/
public function getTypes()
{
return $this->widgetTypes;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Mautic\DashboardBundle\EventListener;
use Mautic\DashboardBundle\DashboardEvents;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Mautic\DashboardBundle\Event\WidgetFormEvent;
use Mautic\DashboardBundle\Event\WidgetTypeListEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class DashboardSubscriber implements EventSubscriberInterface
{
/**
* Define the name of the bundle/category of the widget(s).
*
* @var string
*/
protected $bundle = 'others';
/**
* Define the widget(s).
*
* @var array
*/
protected $types = [];
/**
* Define permissions to see those widgets.
*
* @var array
*/
protected $permissions = [];
public static function getSubscribedEvents(): array
{
return [
DashboardEvents::DASHBOARD_ON_MODULE_LIST_GENERATE => ['onWidgetListGenerate', 0],
DashboardEvents::DASHBOARD_ON_MODULE_FORM_GENERATE => ['onWidgetFormGenerate', 0],
DashboardEvents::DASHBOARD_ON_MODULE_DETAIL_PRE_LOAD => ['onWidgetDetailPreLoad', 0],
DashboardEvents::DASHBOARD_ON_MODULE_DETAIL_GENERATE => ['onWidgetDetailGenerate', 0],
];
}
/**
* Generates widget preview without data.
*
* @throws \Psr\Cache\InvalidArgumentException
*/
public function onWidgetDetailPreLoad(WidgetDetailEvent $event): void
{
$event->setTemplate('@MauticCore/Helper/chart.html.twig');
$event->stopPropagation();
}
/**
* Adds widget new widget types to the list of available widget types.
*/
public function onWidgetListGenerate(WidgetTypeListEvent $event): void
{
if ($this->permissions && !$event->hasPermissions($this->permissions)) {
return;
}
$widgetTypes = array_keys($this->types);
foreach ($widgetTypes as $type) {
$event->addType($type, $this->bundle);
}
}
/**
* Set a widget edit form when needed.
*/
public function onWidgetFormGenerate(WidgetFormEvent $event): void
{
if (isset($this->types[$event->getType()])) {
$event->setForm($this->types[$event->getType()]);
$event->stopPropagation();
}
}
/**
* Set a widget detail when needed.
*/
public function onWidgetDetailGenerate(WidgetDetailEvent $event): void
{
}
/**
* Set a widget detail when needed.
*/
public function checkPermissions(WidgetDetailEvent $event): void
{
$widgetTypes = array_keys($this->types);
if ($this->permissions && !$event->hasPermissions($this->permissions) && in_array($event->getType(), $widgetTypes)) {
$translator = $event->getTranslator();
$event->setErrorMessage($translator->trans('mautic.dashboard.missing.permission', ['%section%' => $this->bundle]));
$event->stopPropagation();
return;
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Mautic\DashboardBundle\Exception;
class CouldNotFormatDateTimeException extends \Exception
{
public function __construct(
string $message = 'Can\'t format date object to string',
int $code = 0,
?\Throwable $throwable = null,
) {
parent::__construct($message, $code, $throwable);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mautic\DashboardBundle\Factory;
use Mautic\CacheBundle\Cache\CacheProviderTagAwareInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Symfony\Contracts\Translation\TranslatorInterface;
class WidgetDetailEventFactory
{
public function __construct(
private TranslatorInterface $translator,
private CacheProviderTagAwareInterface $cacheProvider,
private CorePermissions $corePermissions,
private UserHelper $userHelper,
private CoreParametersHelper $coreParametersHelper,
private PathsHelper $pathsHelper,
) {
}
public function create(Widget $widget): WidgetDetailEvent
{
$cacheDir = $this->coreParametersHelper->get('cached_data_dir', $this->pathsHelper->getSystemPath('cache', true));
$event = new WidgetDetailEvent($this->translator, $this->corePermissions, $widget, $this->cacheProvider);
$event->setCacheDir($cacheDir, $this->userHelper->getUser()->getId());
return $event;
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Mautic\DashboardBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<mixed>
*/
class UploadType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'file',
FileType::class,
[
'label' => 'mautic.lead.import.file',
'attr' => [
'accept' => '.json',
'class' => 'form-control',
],
]
);
$builder->add(
'start',
SubmitType::class,
[
'attr' => [
'class' => 'btn btn-primary',
'icon' => 'ri-upload-line',
'onclick' => "mQuery(this).prop('disabled', true); mQuery('form[name=\'dashboard_upload\']').submit();",
],
'label' => 'mautic.lead.import.upload',
]);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function getBlockPrefix(): string
{
return 'dashboard_upload';
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace Mautic\DashboardBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\DashboardBundle\DashboardEvents;
use Mautic\DashboardBundle\Event\WidgetFormEvent;
use Mautic\DashboardBundle\Event\WidgetTypeListEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
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\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
/**
* @extends AbstractType<mixed>
*/
class WidgetType extends AbstractType
{
public function __construct(protected EventDispatcherInterface $dispatcher, protected CorePermissions $security)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'name',
TextType::class,
[
'label' => 'mautic.dashboard.widget.form.name',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control form-control-widget'],
'required' => false,
]
);
$event = new WidgetTypeListEvent();
$event->setSecurity($this->security);
$this->dispatcher->dispatch($event, DashboardEvents::DASHBOARD_ON_MODULE_LIST_GENERATE);
$types = array_map(fn ($category): array => array_flip($category), $event->getTypes());
$builder->add(
'type',
ChoiceType::class,
[
'label' => 'mautic.dashboard.widget.form.type',
'choices' => $types,
'label_attr' => ['class' => 'control-label'],
'placeholder' => 'mautic.core.select',
'attr' => [
'class' => 'form-control form-control-widget',
'onchange' => 'Mautic.updateWidgetForm(this)',
],
]
);
$builder->add(
'width',
ChoiceType::class,
[
'label' => 'mautic.dashboard.widget.form.width',
'choices' => [
'25%' => '25',
'50%' => '50',
'75%' => '75',
'100%' => '100',
],
'empty_data' => '100',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control form-control-widget'],
'required' => false,
]
);
$builder->add(
'height',
ChoiceType::class,
[
'label' => 'mautic.dashboard.widget.form.height',
'choices' => [
'mautic.dashboard.widget.size.extra_small' => '215',
'mautic.dashboard.widget.size.small' => '330',
'mautic.dashboard.widget.size.medium' => '445',
'mautic.dashboard.widget.size.large' => '560',
'mautic.dashboard.widget.size.extra_large' => '675',
],
'empty_data' => '330',
'label_attr' => ['class' => 'control-label'],
'attr' => ['class' => 'form-control form-control-widget'],
'required' => false,
]
);
// function to add a form for specific widget type dynamically
$func = function (FormEvent $e): void {
$data = $e->getData();
$form = $e->getForm();
$event = new WidgetFormEvent();
$type = null;
$params = [];
// $data is object on load, array on save (??)
if (is_array($data)) {
if (isset($data['type'])) {
$type = $data['type'];
}
if (isset($data['params'])) {
$params = $data['params'];
}
} else {
$type = $data->getType();
$params = $data->getParams();
}
$event->setType($type);
$this->dispatcher->dispatch($event, DashboardEvents::DASHBOARD_ON_MODULE_FORM_GENERATE);
$widgetForm = $event->getForm();
$form->setData($params);
if (isset($widgetForm['formAlias'])) {
$form->add('params', $widgetForm['formAlias'], [
'label' => false,
]);
}
};
$builder->add(
'id',
HiddenType::class,
[
'mapped' => false,
]
);
$builder->add(
'buttons',
FormButtonsType::class,
[
'apply_text' => false,
'save_text' => 'mautic.core.form.save',
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
// Register the function above as EventListener on PreSet and PreBind
$builder->addEventListener(FormEvents::PRE_SET_DATA, $func);
$builder->addEventListener(FormEvents::PRE_SUBMIT, $func);
}
}

View File

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

View File

@@ -0,0 +1,322 @@
<?php
namespace Mautic\DashboardBundle\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CacheBundle\Cache\CacheProviderTagAwareInterface;
use Mautic\CoreBundle\Helper\CacheStorageHelper;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\Filesystem;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\FormModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\DashboardBundle\DashboardEvents;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Entity\WidgetRepository;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use Mautic\DashboardBundle\Factory\WidgetDetailEventFactory;
use Mautic\DashboardBundle\Form\Type\WidgetType;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends FormModel<Widget>
*/
class DashboardModel extends FormModel
{
public function __construct(
CoreParametersHelper $coreParametersHelper,
private PathsHelper $pathsHelper,
private WidgetDetailEventFactory $widgetEventFactory,
private Filesystem $filesystem,
private RequestStack $requestStack,
EntityManagerInterface $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
private CacheProviderTagAwareInterface $cacheProvider,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
public function getRepository(): WidgetRepository
{
return $this->em->getRepository(Widget::class);
}
public function getPermissionBase(): string
{
return 'dashboard:widgets';
}
/**
* Get a specific entity or generate a new one if id is empty.
*/
public function getEntity($id = null): ?Widget
{
if (null === $id) {
return new Widget();
}
return parent::getEntity($id);
}
/**
* Load widgets for the current user from database.
*
* @param bool $ignorePaginator
*
* @return array
*/
public function getWidgets($ignorePaginator = false)
{
return $this->getEntities([
'orderBy' => 'w.ordering',
'filter' => [
'force' => [
[
'column' => 'w.createdBy',
'expr' => 'eq',
'value' => $this->userHelper->getUser()->getId(),
],
],
],
'ignore_paginator' => $ignorePaginator,
]);
}
/**
* Creates an array that represents the dashboard and all its widgets.
* Useful for dashboard exports.
*
* @param string $name
*/
public function toArray($name): array
{
return [
'name' => $name,
'description' => $this->generateDescription(),
'widgets' => array_map(
fn ($widget) => $widget->toArray(),
$this->getWidgets(true)
),
];
}
/**
* Saves the dashboard snapshot to the user folder.
*
* @param string $name
*
* @throws IOException
*/
public function saveSnapshot($name): void
{
$dir = $this->pathsHelper->getSystemPath('dashboard.user');
$filename = InputHelper::filename($name, 'json');
$path = $dir.'/'.$filename;
$this->filesystem->dumpFile($path, json_encode($this->toArray($name)));
}
/**
* Generates a translatable description for a dashboard.
*/
public function generateDescription(): string
{
return $this->translator->trans(
'mautic.dashboard.generated_by',
[
'%name%' => $this->userHelper->getUser()->getName(),
'%date%' => (new \DateTime())->format('Y-m-d H:i:s'),
]
);
}
/**
* Fill widgets with their empty content.
*
* @param array<mixed> $widgets
*/
public function populateWidgetPreviews(&$widgets): void
{
if (count($widgets)) {
foreach ($widgets as &$widget) {
$this->populateWidgetPreview($widget);
}
}
}
/**
* Fill widgets with their content.
*
* @param array $widgets
* @param array $filter
*/
public function populateWidgetsContent(&$widgets, $filter = []): void
{
if (count($widgets)) {
foreach ($widgets as &$widget) {
if (!($widget instanceof Widget)) {
$widget = $this->populateWidgetEntity($widget);
}
$this->populateWidgetContent($widget, $filter);
}
}
}
/**
* Creates a new Widget object from an array data.
*/
public function populateWidgetEntity(array $data): Widget
{
$entity = new Widget();
foreach ($data as $property => $value) {
$method = 'set'.ucfirst($property);
if (method_exists($entity, $method)) {
$entity->$method($value);
}
unset($data[$property]);
}
return $entity;
}
/**
* Populate widget preview.
*/
public function populateWidgetPreview(Widget $widget): void
{
$event = $this->widgetEventFactory->create($widget);
$this->dispatcher->dispatch($event, DashboardEvents::DASHBOARD_ON_MODULE_DETAIL_PRE_LOAD);
}
/**
* Load widget content from the onWidgetDetailGenerate event.
*
* @param array $filter
*/
public function populateWidgetContent(Widget $widget, $filter = []): void
{
$defaultTimeout = $this->coreParametersHelper->get('cached_data_timeout');
// Timeout 0 will be interpreted as endless cache, so we set it to -1 which will be interpreted as no cache
if (0 === $defaultTimeout) {
$defaultTimeout = -1;
}
if (null === $widget->getCacheTimeout() || -1 === $widget->getCacheTimeout()) {
$widget->setCacheTimeout($defaultTimeout);
}
// Merge global filter with widget params
$widgetParams = $widget->getParams();
$resultParams = array_merge($widgetParams, $filter);
// Add the user timezone
if (empty($resultParams['timezone'])) {
$resultParams['timezone'] = $this->userHelper->getUser()->getTimezone();
}
// Clone the objects in param array to avoid reference issues if some subscriber changes them
foreach ($resultParams as &$param) {
if (is_object($param)) {
$param = clone $param;
}
}
$widget->setParams($resultParams);
$this->dispatcher->dispatch(
$this->widgetEventFactory->create($widget),
DashboardEvents::DASHBOARD_ON_MODULE_DETAIL_GENERATE
);
}
/**
* Clears the temporary widget cache.
*/
public function clearDashboardCache(): void
{
$cacheDir = $this->coreParametersHelper->get('cached_data_dir', $this->pathsHelper->getSystemPath('cache', true));
$cacheStorage = new CacheStorageHelper(CacheStorageHelper::ADAPTOR_FILESYSTEM, $this->userHelper->getUser()->getId(), null, $cacheDir);
$cacheStorage->clear();
$this->cacheProvider->invalidateTags([WidgetDetailEvent::DASHBOARD_CACHE_TAG]);
}
/**
* @param Widget $entity
* @param string|null $action
* @param array $options
*
* @return \Symfony\Component\Form\FormInterface<mixed>
*
* @throws MethodNotAllowedHttpException
*/
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
{
if (!$entity instanceof Widget) {
throw new MethodNotAllowedHttpException(['Widget'], 'Entity must be of class Widget()');
}
if (!empty($action)) {
$options['action'] = $action;
}
return $formFactory->create(WidgetType::class, $entity, $options);
}
/**
* Create/edit entity.
*
* @param object $entity
* @param bool $unlock
*
* @throws \Exception
*/
public function saveEntity($entity, $unlock = true): void
{
// Set widget name from widget type if empty
if (!$entity->getName()) {
$entity->setName($this->translator->trans('mautic.widget.'.$entity->getType()));
}
$entity->setDateModified(new \DateTime());
parent::saveEntity($entity, $unlock);
}
/**
* Generate default date range filter and time unit.
*/
public function getDefaultFilter(): array
{
$dateRangeDefault = $this->coreParametersHelper->get('default_daterange_filter', '-1 month');
$dateRangeStart = new \DateTime();
$dateRangeStart->modify($dateRangeDefault);
$session = $this->requestStack->getSession();
$today = new \DateTime();
$dateFrom = new \DateTime($session->get('mautic.daterange.form.from', $dateRangeStart->format('Y-m-d 00:00:00')));
$dateTo = new \DateTime($session->get('mautic.daterange.form.to', $today->format('Y-m-d 23:59:59')));
return [
'dateFrom' => $dateFrom,
'dateTo' => $dateTo->modify('23:59:59'), // till end of the 'to' date selected
];
}
}

View File

@@ -0,0 +1,73 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block headerTitle %}{{ 'mautic.dashboard.import'|trans }}{% endblock %}
{% block mauticContent %}dashboardImport{% endblock %}
{% block content %}
<div class="row">
{% if dashboards %}
<div class="col-sm-6">
<div class="ml-sm mt-sm pa-sm">
<div class="panel panel-info">
<div class="panel-heading">
<div class="panel-title">{{ 'mautic.dashboard.predefined'|trans }}</div>
</div>
<div class="panel-body">
<div class="list-group">
{% for dashboard, config in dashboards %}
<div class="list-group-item {{ (dashboard == preview) ? 'active' : '' }}">
<h4 class="list-group-item-heading">{{ config.name|purify }}</h4>
{% if config.description is not empty %}<p class="small">{{ config.description|purify }}</p>{% endif %}
<p class="list-group-item-heading">
<a href="{{ path('mautic_dashboard_action', {'objectAction': 'import', 'preview': dashboard}) }}">
{{ 'mautic.dashboard.preview'|trans }}
</a>&#183;
<a href="{{ path('mautic_dashboard_action', {'objectAction': 'applyDashboardFile', 'file': config.type~'.'~dashboard}) }}">
{{ 'mautic.core.form.apply'|trans }}
</a>{% if 'user' == config.type %}&#183;
<a href="{{ path('mautic_dashboard_action', {'objectAction': 'deleteDashboardFile', 'file': config.type~'.'~dashboard}) }}" data-toggle="confirmation" data-message="{{ 'mautic.dashboard.delete_layout'|trans|e }}" data-confirm-text="{{ 'mautic.core.form.delete'|trans|e }}" data-confirm-callback="executeAction" data-cancel-text="{{ 'mautic.core.form.cancel'|trans|e }}">
{{ 'mautic.core.form.delete'|trans }}
</a>{% endif %}
</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="col-sm-6">
<div class="mr-sm mt-sm pa-sm">
<div class="panel panel-info">
<div class="panel-heading">
<div class="panel-title">{{ 'mautic.dashboard.import.start.instructions'|trans }}</div>
</div>
<div class="panel-body">
{{ form_start(form) }}
<div class="input-group well mt-lg">
{{ form_widget(form.file) }}
<span class="input-group-btn">
{{ form_widget(form.start) }}
</span>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
</div>
{% if widgets %}
<div class="col-md-12">
<h2>{{ 'mautic.dashboard.widgets.preview'|trans }}</h2>
</div>
<div id="dashboard-widgets" class="dashboard-widgets cards">
{% for widget in widgets %}
<div class="card-flex widget" data-widget-id="{{ widget.id }}" style="width: {{ widget.width|default('100') }}%; height: {{ widget.height|default('300') }}px;">
{{ include('@MauticDashboard/Widget/detail.html.twig', {widget}) }}
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,120 @@
{% extends '@MauticCore/Default/content.html.twig' %}
{% block headerTitle %}{{ 'mautic.dashboard.header.index'|trans }}{% endblock %}
{% block mauticContent %}dashboard{% endblock %}
{% block actions %}
{{ include('@MauticCore/Helper/page_actions.html.twig', {
'routeBase': 'dashboard',
'langVar': 'dashboard',
'customButtons': [
{
'attr': {
'class': 'btn btn-primary btn-nospin',
'data-toggle': 'ajaxmodal',
'data-target': '#MauticSharedModal',
'href': path('mautic_dashboard_action', {'objectAction': 'new'}),
'data-header': 'mautic.dashboard.widget.add'|trans,
},
'iconClass': 'ri-add-fill',
'btnText': 'mautic.dashboard.widget.add',
'primary': true,
},
{
'attr': {
'class': 'btn btn-ghost btn-nospin',
'href': 'javascript:void(0)',
'onclick': "Mautic.saveDashboardLayout('"~'mautic.dashboard.confirmation_layout_name'|trans~"');",
'data-toggle': '',
},
'iconClass': 'ri-save-line',
'btnText': 'mautic.dashboard.save_as_predefined',
},
{
'attr': {
'class': 'btn btn-ghost btn-nospin',
'href': 'javascript:void(0)',
'onclick': "Mautic.exportDashboardLayout('"~'mautic.dashboard.confirmation_layout_name'|trans~"', '"~path('mautic_dashboard_action', {'objectAction': 'export'})~"');",
'data-toggle': '',
},
'iconClass': 'ri-export-line',
'btnText': 'mautic.dashboard.export.widgets',
},
{
'attr': {
'class': 'btn btn-ghost',
'href': path('mautic_dashboard_action', {'objectAction': 'import'}),
'data-header': 'mautic.dashboard.widget.import'|trans,
},
'iconClass': 'ri-import-line',
'btnText': 'mautic.dashboard.widget.import',
},
],
}) }}
{% endblock %}
{% block content %}
{% if true == phpVersion['isOutdated'] %}
<div class="pt-md pl-md col-md-12">
<div class="pt-md pl-md alert alert-warning">
<h3>{{ 'mautic.dashboard.phpversionwarning.title'|trans }}</h3>
<p>{{ 'mautic.dashboard.phpversionwarning.body'|trans({'%phpversion%': phpVersion['version']})|purify }}</p>
</div>
</div>
{% endif %}
<div class="row pt-md ml-0">
<div class="col-sm-12">
<div class="d-flex fd-row fw-nowrap gap-sm ai-center jc-space-between">
{{ include('@MauticCore/Helper/graph_dateselect.html.twig', {'dateRangeForm': dateRangeForm}) }}
<div class="dropdown">
<a href="#" class="btn btn-ghost btn-sm btn-nospin" data-toggle="dropdown" aria-expanded="false">
{{ 'mautic.core.quick_filters'|trans }}
<i class="ri-arrow-down-s-line"></i>
</a>
<ul class="dropdown-menu">
<li><a href="#" onclick="Mautic.setDateRange('today'); event.preventDefault();">{{ 'mautic.dashboard.date.today'|trans }}</a></li>
<li><a href="#" onclick="Mautic.setDateRange('yesterday'); event.preventDefault();">{{ 'mautic.dashboard.date.yesterday'|trans }}</a></li>
<li><a href="#" onclick="Mautic.setDateRange(7); event.preventDefault();">{{ 'mautic.dashboard.date.last_7_days'|trans }}</a></li>
<li><a href="#" onclick="Mautic.setDateRange(30); event.preventDefault();">{{ 'mautic.dashboard.date.last_30_days'|trans }}</a></li>
<li><a href="#" onclick="Mautic.setDateRange(90); event.preventDefault();">{{ 'mautic.dashboard.date.last_90_days'|trans }}</a></li>
</ul>
</div>
</div>
</div>
</div>
{% if widgets|length > 0 %}
<div id="dashboard-widgets" class="dashboard-widgets cards">
{% for widget in widgets %}
<div class="card-flex widget" data-widget-id="{{ widget.id }}" style="width: {{ widget.width|default(100) }}%; height: {{ widget.height|default(300) }}px">
<div class="spinner"><i class="ri-loader-3-line ri-spin"></i></div>
{{ include('@MauticDashboard/Dashboard/widget.html.twig', {'widget': widget}) }}
</div>
{% endfor %}
</div>
<div id="cloned-widgets" class="dashboard-widgets cards"></div>
{% else %}
<div class="well well col-md-6 col-md-offset-3 mt-md">
<div class="row">
<div class="mautibot-image col-xs-3 text-center">
<img class="img-responsive" style="max-height: 125px; margin-left: auto; margin-right: auto;" src="{{ mautibotGetImage('wave') }}" />
</div>
<div class="col-xs-9">
<h4><i class="ri-double-quotes-l"></i> {{ 'mautic.dashboard.nowidgets.tip.header'|trans }} <i class="ri-double-quotes-r"></i></h4>
<p class="mt-md">{{ 'mautic.dashboard.nowidgets.tip'|trans }}</p>
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: 'mautic.dashboard.apply_default',
variant: 'success',
href: path('mautic_dashboard_action', {'objectAction': 'applyDashboardFile', 'file': 'default.json'})
}
]
} %}
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% set system_user = 'mautic.core.system'|trans %}
{% if logs is defined and logs is iterable %}
<div class="pt-md pr-md pb-md pl-md">
<ul class="media-list media-list-feed">
{% for log in logs %}
<li class="media">
<div class="media-object pull-left">
<span class="figure featured {% if 'create' == log.action %}bg-success{% endif %}">
<span class="fa {{ icons[log.bundle]|default('') }}"></span>
</span>
</div>
<div class="media-body">
{% if log.userId is not defined or log.userId == 0 %}
{{ system_user }}
{% else %}
<a href="{{ path('mautic_user_action', {'objectAction': 'edit', 'objectId': log.userId}) }}" data-toggle="ajax">
{{ log.userName }}
</a>
{% endif %}
{{ ('mautic.dashboard.'~log.action~'.past.tense')|trans }}
{% if log.route is defined and log.route is not empty and log.userId != 0 %}
<a href="{{ log.route }}" data-toggle="ajax">
{{ log.objectName }}
</a>
{% elseif log.objectName is defined %}
{{ log.objectName }}
{% endif %}
<p class="fs-12 dark-sm"><small> {{ dateToFull(log.dateAdded) }}</small></p>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@@ -0,0 +1,45 @@
{% if upcomingEmails %}
<ul class="list-group mb-0 bdr-w-0">
{% for email in upcomingEmails %}
<li class="mb-md">
<div class="box-layout">
{% if icons.email is defined and icons.email is not empty %}
<div class="col-md-1 va-m">
<h3><span class="fa {{ icons.email }} fw-sb text-success"></span></h3>
</div>
{% endif %}
<div class="col-md-8 va-m">
<h5 class="fw-sb text-primary">
<a href="{{ path('mautic_campaign_action', {'objectAction': 'view', 'objectId': email.campaign_id}) }}" data-toggle="ajax">
{{ email.campaign_name }}
</a>
</h5>
<span class="text-white dark-sm">
{{ email.event_name }}
{{'mautic.core.send.email.to'|trans|lower}}
{% include '@MauticCore/Helper/button.html.twig' with {
buttons: [
{
label: email.lead_name ?: ('mautic.lead.lead'|trans ~ ' #' ~ email.lead_id),
variant: 'tertiary',
size: 'xs',
href: path('mautic_contact_action', {'objectAction': 'view', 'objectId': email.lead_id}),
attributes: {
'data-toggle': 'ajax',
'class': 'pr-sm pl-sm fs-12 ml-3'
}
}
]
} %}
</span>
</div>
<div class="col-md-4 va-m text-right small">
{{ dateToFull(email.trigger_date) }}
</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="alert alert-warning" role="alert">{{ 'mautic.note.no.upcoming.emails'|trans }}</div>
{% endif %}

View File

@@ -0,0 +1 @@
{{ include('@MauticDashboard/Widget/detail.html.twig', {'widget': widget}) }}

View File

@@ -0,0 +1,59 @@
<div class="tile" style="height: {{ widget.height|default('310') - 10 }}px;">
<div class="card-header d-flex jc-space-between ai-center">
<h4 class="fw-sb">{{ widget.name|purify }}</h4>
{% if widget.id %}
<div class="dropdown">
<a class="btn btn-ghost btn--icon-only btn-xs dropdown-toggle" data-toggle="dropdown" href="#" aria-haspopup="true" aria-expanded="false">
<i class="ri-more-2-fill"></i>
</a>
<ul class="dropdown-menu dropdown-menu-right">
<li class="small fw-sb text-secondary ma-md ellipsis">
{% if widget.isCached %}
<i class="ri-history-line mr-xs"></i>
{{ 'mautic.dashboard.widget.data.loaded.from.cache'|trans }}
{% else %}
<i class="ri-flashlight-line mr-xs"></i>
{{ 'mautic.dashboard.widget.data.loaded.from.database'|trans }}
{% endif %}
</li>
<li>
<a href="{{ url('mautic_dashboard_action', {'objectAction': 'edit', 'objectId': widget.id}) }}"
data-toggle="ajaxmodal"
data-target="#MauticSharedModal"
data-header="{{ 'mautic.dashboard.widget.header.edit'|trans }}">
<span>
<i class="ri-edit-line"></i>
{{'mautic.core.form.edit'|trans}}
</span>
</a>
</li>
<li>
<a href="{{ url('mautic_dashboard_action', {'objectAction': 'delete', 'objectId': widget.id}) }}"
data-header="{{ 'mautic.dashboard.widget.header.delete'|trans }}"
class="remove-widget danger">
<span>
<i class="ri-close-line"></i>
{{'mautic.core.remove'|trans}}
</span>
</a>
</li>
</ul>
</div>
{% endif %}
</div>
<hr class="bdr-b">
<div class="card-body d-flex fd-column fg-1">
{% if widget.errorMessage %}
<div class="alert alert-danger" role="alert">
{{ widget.errorMessage|trans }}
</div>
{% elseif widget.template %}
<!-- start: {{ widget.template }} -->
{{ include(widget.template, widget.templateData) }}
<!-- end: {{ widget.template }} -->
{% endif %}
</div>
<div class="widget-overlay d-flex ai-center jc-center">
<i class="ri-drag-drop-fill ri-4x text-interactive"></i>
</div>
</div>

View File

@@ -0,0 +1,25 @@
{{ form_start(form) }}
<div class="row form-group">
<div class="col-xs-6">
{{ form_label(form.name) }}
{{ form_widget(form.name) }}
<div class="has-error">{{ form_errors(form.name) }}</div>
</div>
<div class="col-xs-6">
{{ form_label(form.type) }}
{{ form_widget(form.type) }}
<div class="has-error">{{ form_errors(form.type) }}</div>
</div>
</div>
<div class="row form-group">
<div class="col-xs-6">
{{ form_label(form.width) }}
{{ form_widget(form.width) }}
</div>
<div class="col-xs-6">
{{ form_label(form.height) }}
{{ form_widget(form.height) }}
</div>
</div>
{{ form_row(form.buttons) }}
{{ form_end(form) }}

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace Mautic\DashboardBundle\Tests\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadList;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\ReportBundle\Entity\Report;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\Assert;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
class DashboardControllerFunctionalTest extends MauticMysqlTestCase
{
public function testWidgetWithReport(): void
{
$user = $this->em->getRepository(User::class)->findOneBy([]);
$report = new Report();
$report->setName('Lead and points');
$report->setSource('lead.pointlog');
$this->em->persist($report);
$this->em->flush();
$widget = new Widget();
$widget->setName('Line graph report');
$widget->setType('report');
$widget->setParams(['graph' => sprintf('%s:mautic.lead.graph.line.leads', $report->getId())]);
$widget->setWidth(100);
$widget->setHeight(200);
$widget->setCreatedBy($user);
$this->em->persist($widget);
$this->em->flush();
$this->em->detach($widget);
$this->client->xmlHttpRequest('GET', sprintf('/s/dashboard/widget/%s', $widget->getId()));
$this->assertResponseIsSuccessful();
$response = $this->client->getResponse();
self::assertResponseIsSuccessful();
$content = $response->getContent();
Assert::assertJson($content);
$data = json_decode($content, true);
Assert::assertIsArray($data);
Assert::assertArrayHasKey('success', $data);
Assert::assertSame(1, $data['success']);
Assert::assertArrayHasKey('widgetId', $data);
Assert::assertSame((string) $widget->getId(), $data['widgetId']);
Assert::assertArrayHasKey('widgetWidth', $data);
Assert::assertSame($widget->getWidth(), $data['widgetWidth']);
Assert::assertArrayHasKey('widgetHeight', $data);
Assert::assertSame($widget->getHeight(), $data['widgetHeight']);
Assert::assertArrayHasKey('widgetHtml', $data);
Assert::assertStringContainsString('View Full Report', $data['widgetHtml']);
}
public function testWidgetWithBestHours(): void
{
$user = $this->em->getRepository(User::class)->findOneBy([]);
$segment = $this->createSegment('A', 'a');
$widget = new Widget();
$widget->setName('Best email read hours');
$widget->setType('emails.best.hours');
$widget->setParams(['timeFormat' => 24, 'segmentId' => $segment->getId()]);
$widget->setWidth(100);
$widget->setHeight(200);
$widget->setCreatedBy($user);
$this->em->persist($widget);
$this->em->flush();
$this->em->detach($widget);
$this->client->xmlHttpRequest('GET', "/s/dashboard/widget/{$widget->getId()}");
$this->assertResponseIsSuccessful();
$content = $this->client->getResponse()->getContent();
Assert::assertJson($content);
$data = json_decode($content, true);
Assert::assertIsArray($data);
Assert::assertArrayHasKey('success', $data);
Assert::assertSame(1, $data['success']);
Assert::assertArrayHasKey('widgetId', $data);
Assert::assertSame((string) $widget->getId(), $data['widgetId']);
Assert::assertArrayHasKey('widgetWidth', $data);
Assert::assertSame($widget->getWidth(), $data['widgetWidth']);
Assert::assertArrayHasKey('widgetHeight', $data);
Assert::assertSame($widget->getHeight(), $data['widgetHeight']);
Assert::assertArrayHasKey('widgetHtml', $data);
Assert::assertStringContainsString('Best email read hours', $data['widgetHtml']);
}
public function testWidgetWithSegmentBuildTime(): void
{
$user = $this->em->getRepository(User::class)->findOneBy([]);
$this->createSegment('A', 'a', 3, $user);
$this->createSegment('B', 'b', 60, $user);
$this->createSegment('C', 'c', 66, $user);
$this->createSegment('D', 'd', 0.4, $user);
$widget = new Widget();
$widget->setName('Segments build time');
$widget->setType('segments.build.time');
$widget->setParams(['order' => 'DESC', 'segments' => []]);
$widget->setWidth(100);
$widget->setHeight(300);
$widget->setCreatedBy($user);
$this->em->persist($widget);
$this->em->flush();
$this->em->detach($widget);
$this->client->xmlHttpRequest('GET', sprintf('/s/dashboard/widget/%s', $widget->getId()));
$this->assertResponseIsSuccessful();
$response = $this->client->getResponse();
self::assertResponseIsSuccessful();
$content = $response->getContent();
Assert::assertJson($content);
$data = json_decode($content, true);
Assert::assertIsArray($data);
Assert::assertArrayHasKey('success', $data);
Assert::assertSame(1, $data['success']);
Assert::assertArrayHasKey('widgetHtml', $data);
$tableArray = $this->widgetHtmlWithTableToArray($data['widgetHtml']);
$this->assertSame([
['C', 'Admin User', '1 minute 6 seconds'],
['B', 'Admin User', '1 minute'],
['A', 'Admin User', '3 seconds'],
['D', 'Admin User', 'Less than 1 second'],
], $tableArray);
}
public function testAuditLogWidgetWithDeletedContact(): void
{
$user = $this->em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$widget = new Widget();
$widget->setName('Recent activity');
$widget->setType('recent.activity');
$widget->setWidth(100);
$widget->setHeight(300);
$widget->setCreatedBy($user);
$this->em->persist($widget);
$this->em->flush();
$contact = new Lead();
$contact->setFirstName('John');
$contactModel = self::getContainer()->get('mautic.lead.model.lead');
\assert($contactModel instanceof LeadModel);
$contactModel->saveEntity($contact);
$contactModel->deleteEntity($contact);
$this->em->clear();
$this->client->xmlHttpRequest(Request::METHOD_GET, "/s/dashboard/widget/{$widget->getId()}");
$this->assertResponseIsSuccessful();
$printResponse = fn () => print_r(json_decode($this->client->getResponse()->getContent(), true), true);
Assert::assertStringContainsString('created', $printResponse());
Assert::assertStringContainsString('deleted', $printResponse());
}
private function createSegment(string $name, string $alias, float $lastBuildTime = 0, ?User $user = null): LeadList
{
$segment = new LeadList();
$segment->setName($name);
$segment->setPublicName($name);
$segment->setAlias($alias);
$segment->setLastBuiltTime($lastBuildTime);
if ($user) {
$segment->setCreatedBy($user);
$segment->setCreatedByUser($user->getName());
}
$this->em->persist($segment);
return $segment;
}
/**
* @return array<int,array<int,string>>
*/
private function widgetHtmlWithTableToArray(string $widgetHtml): array
{
$doc = new \DOMDocument();
$doc->loadHTML($widgetHtml);
$crawler = new Crawler($doc);
$crawlerTable = $crawler->filter('table')->first();
return array_slice($crawlerTable->filter('tr')->each(fn ($tr) => $tr->filter('td')->each(fn ($td) => trim($td->text()))), 1);
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace Mautic\DashboardBundle\Tests\Controller;
use Doctrine\Persistence\ManagerRegistry;
use Mautic\CoreBundle\Factory\ModelFactory;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Service\FlashBag;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\DashboardBundle\Controller\DashboardController;
use Mautic\DashboardBundle\Dashboard\Widget;
use Mautic\DashboardBundle\Model\DashboardModel;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\RouterInterface;
use Twig\Environment;
class DashboardControllerTest extends \PHPUnit\Framework\TestCase
{
/**
* @var MockObject|Request
*/
private MockObject $requestMock;
/**
* @var MockObject|CorePermissions
*/
private MockObject $securityMock;
/**
* @var MockObject|Translator
*/
private MockObject $translatorMock;
/**
* @var MockObject|ModelFactory<DashboardModel>
*/
private MockObject $modelFactoryMock;
/**
* @var MockObject|DashboardModel
*/
private MockObject $dashboardModelMock;
/**
* @var MockObject|RouterInterface
*/
private MockObject $routerMock;
/**
* @var MockObject&FlashBag
*/
private MockObject $flashBagMock;
/**
* @var MockObject|Container
*/
private MockObject $containerMock;
private DashboardController $controller;
protected function setUp(): void
{
parent::setUp();
$this->requestMock = $this->createMock(Request::class);
$this->dashboardModelMock = $this->createMock(DashboardModel::class);
$this->routerMock = $this->createMock(RouterInterface::class);
$this->containerMock = $this->createMock(Container::class);
$doctrine = $this->createMock(ManagerRegistry::class);
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
$userHelper = $this->createMock(UserHelper::class);
$coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$this->translatorMock = $this->createMock(Translator::class);
$this->flashBagMock = $this->createMock(FlashBag::class);
$requestStack = new RequestStack();
$this->securityMock = $this->createMock(CorePermissions::class);
$requestStack->push($this->requestMock);
$this->controller = new DashboardController(
$doctrine,
$this->modelFactoryMock,
$userHelper,
$coreParametersHelper,
$dispatcher,
$this->translatorMock,
$this->flashBagMock,
$requestStack,
$this->securityMock
);
$this->controller->setContainer($this->containerMock);
}
public function testSaveWithGetWillCallAccessDenied(): void
{
$this->requestMock->expects($this->once())
->method('isMethod')
->willReturn(true);
$this->requestMock->expects(self::once())
->method('isXmlHttpRequest')
->willReturn(false);
$this->expectException(AccessDeniedHttpException::class);
$this->controller->saveAction($this->requestMock);
}
public function testSaveWithPostNotAjaxWillCallAccessDenied(): void
{
$this->requestMock->expects($this->once())
->method('isMethod')
->willReturn(true);
$this->requestMock->method('isXmlHttpRequest')
->willReturn(false);
$this->translatorMock->expects($this->once())
->method('trans')
->with('mautic.core.url.error.401');
$this->expectException(AccessDeniedHttpException::class);
$this->controller->saveAction($this->requestMock);
}
public function testSaveWithPostAjaxWillSave(): void
{
$this->requestMock->expects($this->once())
->method('isMethod')
->willReturn(true);
$this->requestMock->method('isXmlHttpRequest')->willReturn(true);
$this->requestMock->method('get')->willReturn('mockName');
$this->containerMock->expects($this->exactly(2))
->method('get')->willReturnCallback(function (...$parameters) {
$this->assertSame('router', $parameters[0]);
return $this->routerMock;
});
$this->routerMock->expects($this->any())
->method('generate')
->willReturn('https://some.url');
$this->modelFactoryMock->expects($this->once())
->method('getModel')
->with('dashboard')
->willReturn($this->dashboardModelMock);
$this->dashboardModelMock->expects($this->once())
->method('saveSnapshot')
->with('mockName');
$this->translatorMock->expects($this->once())
->method('trans')
->with('mautic.dashboard.notice.save');
$this->controller->saveAction($this->requestMock);
}
public function testSaveWithPostAjaxWillNotBeAbleToSave(): void
{
$this->requestMock->expects($this->once())
->method('isMethod')
->willReturn(true);
$this->requestMock->method('isXmlHttpRequest')
->willReturn(true);
$this->routerMock->expects($this->any())
->method('generate')
->willReturn('https://some.url');
$this->requestMock->method('get')->willReturn('mockName');
$this->containerMock->expects($this->once())
->method('get')
->with('router')
->willReturn($this->routerMock);
$this->modelFactoryMock->expects($this->once())
->method('getModel')
->with('dashboard')
->willReturn($this->dashboardModelMock);
$this->dashboardModelMock->expects($this->once())
->method('saveSnapshot')
->will($this->throwException(new IOException('some error message')));
$this->translatorMock->expects($this->once())
->method('trans')
->with('mautic.dashboard.error.save');
$this->controller->saveAction($this->requestMock);
}
public function testWidgetDirectRequest(): void
{
$this->requestMock->method('isXmlHttpRequest')
->willReturn(false);
$this->expectException(NotFoundHttpException::class);
$this->controller->widgetAction($this->requestMock, $this->createMock(Widget::class), $this->createMock(Environment::class), 1);
}
public function testWidgetNotFound(): void
{
$widgetId = '1';
$twig = $this->createMock(Environment::class);
$this->requestMock->method('isXmlHttpRequest')
->willReturn(true);
$widgetService = $this->createMock(Widget::class);
$widgetService->expects(self::once())
->method('setFilter')
->with($this->requestMock);
$widgetService->expects(self::once())
->method('get')
->with((int) $widgetId)
->willReturn(null);
$this->containerMock->expects(self::never())
->method('get');
$this->expectException(NotFoundHttpException::class);
$this->controller->widgetAction($this->requestMock, $widgetService, $twig, $widgetId);
}
public function testWidget(): void
{
$widgetId = '1';
$widget = new \Mautic\DashboardBundle\Entity\Widget();
$renderedContent = 'lfsadkdhfůasfjds';
$twig = $this->createMock(Environment::class);
$twig->expects(self::once())
->method('render')
->willReturn($renderedContent);
$this->requestMock->method('isXmlHttpRequest')
->willReturn(true);
$widgetService = $this->createMock(Widget::class);
$widgetService->expects(self::once())
->method('setFilter')
->with($this->requestMock);
$widgetService->expects(self::once())
->method('get')
->with((int) $widgetId)
->willReturn($widget);
$response = $this->controller->widgetAction($this->requestMock, $widgetService, $twig, $widgetId);
self::assertSame('{"success":1,"widgetId":"1","widgetHtml":"lfsadkdhf\u016fasfjds","widgetWidth":null,"widgetHeight":null}', $response->getContent());
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Mautic\DashboardBundle\Tests\Dashboard;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\DashboardBundle\Dashboard\Widget;
use Mautic\DashboardBundle\Entity\Widget as WidgetEntity;
use Mautic\DashboardBundle\Model\DashboardModel;
use Mautic\UserBundle\Entity\User;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class WidgetTest extends TestCase
{
private const USER_ID = 1;
/**
* @var DashboardModel&MockObject
*/
private MockObject $dashboardModel;
/**
* @var UserHelper&MockObject
*/
private MockObject $userHelper;
/**
* @var MockObject&RequestStack
*/
private MockObject $requestStack;
/**
* @var User&MockObject
*/
private MockObject $user;
private Widget $widget;
protected function setUp(): void
{
parent::setUp();
$this->dashboardModel = $this->createMock(DashboardModel::class);
$this->userHelper = $this->createMock(UserHelper::class);
$this->requestStack = $this->createMock(RequestStack::class);
$this->user = $this->createMock(User::class);
$this->user
->method('getId')
->willReturn(self::USER_ID);
$this->widget = new Widget(
$this->dashboardModel,
$this->userHelper,
$this->requestStack
);
}
public function testGetSuccess(): void
{
$widgetId = 2;
$widget = $this->createMock(WidgetEntity::class);
$widget->expects(self::once())
->method('getId')
->willReturn($widgetId);
$widget->expects(self::once())
->method('getCreatedBy')
->willReturn(self::USER_ID);
$widget->setCreatedBy(self::USER_ID);
$filter = [
'dateFrom' => new \DateTime(),
'dateTo' => new \DateTime(),
];
$this->dashboardModel->expects(self::once())
->method('getEntity')
->with($widgetId)
->willReturn($widget);
$this->userHelper->expects(self::once())
->method('getUser')
->willReturn($this->user);
$this->dashboardModel->expects(self::once())
->method('getDefaultFilter')
->willReturn($filter);
$this->dashboardModel->expects(self::once())
->method('populateWidgetContent')
->with($widget, $filter);
$this->widget->get($widgetId);
}
public function testGetNotFoundHttpException(): void
{
$widgetId = 2;
$widget = null;
$this->dashboardModel->expects(self::once())
->method('getEntity')
->with($widgetId)
->willReturn($widget);
$this->expectException(NotFoundHttpException::class);
$this->widget->get($widgetId);
}
public function testGetNotFoundHttpExceptionEmptyEntity(): void
{
$widgetId = 2;
$widget = $this->createMock(WidgetEntity::class);
$widget->expects(self::once())
->method('getId')
->willReturn(null);
$widget->setCreatedBy(self::USER_ID);
$this->dashboardModel->expects(self::once())
->method('getEntity')
->with($widgetId)
->willReturn($widget);
$this->expectException(NotFoundHttpException::class);
$this->widget->get($widgetId);
}
public function testGetAccessDeniedException(): void
{
$widgetId = 2;
$widget = $this->createMock(WidgetEntity::class);
$widget->expects(self::once())
->method('getId')
->willReturn($widgetId);
$widget->expects(self::once())
->method('getCreatedBy')
->willReturn(self::USER_ID + 1);
$widget->setCreatedBy(self::USER_ID);
$this->dashboardModel->expects(self::once())
->method('getEntity')
->with($widgetId)
->willReturn($widget);
$this->userHelper->expects(self::once())
->method('getUser')
->willReturn($this->user);
$this->expectException(AccessDeniedException::class);
$this->widget->get($widgetId);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Mautic\DashboardBundle\Tests\Entity;
use Mautic\DashboardBundle\Entity\Widget;
class WidgetTest extends \PHPUnit\Framework\TestCase
{
public function testWidgetNameXssAttempt(): void
{
$widget = new Widget();
$widget->setName('csrf<script>console.log(\'name\');</script>');
$this->assertEquals('csrfconsole.log(\'name\');', $widget->getName());
}
public function testWidgetWidthXssAttempt(): void
{
$widget = new Widget();
$widget->setWidth('100<script>console.log(\'yellow\');</script>');
$this->assertEquals(100, $widget->getWidth());
}
public function testWidgetHeightXssAttempt(): void
{
$widget = new Widget();
$widget->setHeight('100<script>console.log(\'yellow\');</script>');
$this->assertEquals(100, $widget->getHeight());
}
public function testWidgetOrderingSqliAttempt(): void
{
$widget = new Widget();
$widget->setOrdering('3;DROP grep;');
$this->assertEquals(3, $widget->getOrdering());
}
public function testWidgetTypeXssAttempt(): void
{
$widget = new Widget();
$widget->setType('map.of.leads<script>console.log(\'yellow\');</script>');
$this->assertEquals('map.of.leadsconsole.log(\'yellow\');', $widget->getType());
}
public function testToArrayEmpty(): void
{
$widget = new Widget();
$expected = [
'name' => null,
'width' => null,
'height' => null,
'ordering' => null,
'type' => null,
'params' => [],
'template' => null,
];
$this->assertEquals($expected, $widget->toArray());
}
public function testToArrayFilled(): void
{
$widget = new Widget();
$widget->setName('The itsy bitsy spider');
$widget->setWidth(4);
$widget->setHeight(5);
$widget->setOrdering(6);
$widget->setType('climed up');
$widget->setParams([]);
$widget->setTemplate('the water spout');
$expected = [
'name' => 'The itsy bitsy spider',
'width' => 4,
'height' => 5,
'ordering' => 6,
'type' => 'climed up',
'params' => [],
'template' => 'the water spout',
];
$this->assertEquals($expected, $widget->toArray());
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Mautic\DashboardBundle\Tests\Entity;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\DashboardBundle\Entity\Widget;
use Mautic\DashboardBundle\Event\WidgetDetailEvent;
use PHPUnit\Framework\MockObject\MockObject;
class WidgetDetailEventTest extends \PHPUnit\Framework\TestCase
{
private WidgetDetailEvent $widgetDetailEvent;
private MockObject $translator;
private MockObject $security;
private MockObject $widget;
protected function setUp(): void
{
parent::setUp();
$this->translator = $this->createMock(Translator::class);
$this->security = $this->createMock(CorePermissions::class);
$this->widget = $this->createMock(Widget::class);
$this->widgetDetailEvent = new WidgetDetailEvent(
$this->translator,
$this->security,
$this->widget
);
}
public function testGetCacheKey(): void
{
$this->widget
->method('getParams')
->willReturn(['dateFrom' => '', 'dateTo' => '']);
$this->translator->expects($this->once())
->method('getLocale')
->willReturn('en');
$this->assertStringContainsString('dashboard.widget.', $this->widgetDetailEvent->getCacheKey());
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Mautic\DashboardBundle\Tests\Model;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CacheBundle\Cache\CacheProviderTagAwareInterface;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\Filesystem;
use Mautic\CoreBundle\Helper\PathsHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\DashboardBundle\Factory\WidgetDetailEventFactory;
use Mautic\DashboardBundle\Model\DashboardModel;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class DashboardModelTest extends TestCase
{
private MockObject&CoreParametersHelper $coreParametersHelper;
private MockObject&Session $session;
private DashboardModel $model;
protected function setUp(): void
{
$this->coreParametersHelper = $this->createMock(CoreParametersHelper::class);
$this->session = $this->createMock(Session::class);
$requestStack = $this->createMock(RequestStack::class);
$requestStack->method('getSession')
->willReturn($this->session);
$this->model = new DashboardModel(
$this->coreParametersHelper,
$this->createMock(PathsHelper::class),
$this->createMock(WidgetDetailEventFactory::class),
$this->createMock(Filesystem::class),
$requestStack,
$this->createMock(EntityManagerInterface::class),
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(Translator::class),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CacheProviderTagAwareInterface::class),
);
}
public function testGetDefaultFilterFromSession(): void
{
$dateFromStr = '-1 month';
$dateFrom = new \DateTime($dateFromStr);
$dateTo = new \DateTime('23:59:59'); // till end of the 'to' date selected
$this->coreParametersHelper->expects(self::once())
->method('get')
->with('default_daterange_filter', $dateFromStr)
->willReturn($dateFromStr);
$this->session->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
$dateFrom->format(\DateTimeInterface::ATOM),
$dateTo->format(\DateTimeInterface::ATOM)
);
$filter = $this->model->getDefaultFilter();
Assert::assertSame(
$dateFrom->format(\DateTimeInterface::ATOM),
$filter['dateFrom']->format(\DateTimeInterface::ATOM)
);
Assert::assertSame(
$dateTo->format(\DateTimeInterface::ATOM),
$filter['dateTo']->format(\DateTimeInterface::ATOM)
);
}
}

View File

@@ -0,0 +1,2 @@
mautic.dashboard.notice.save="Dashboard snapshot '%name%'' is saved. <a href='%viewUrl%'>View all snapshots</a>."
mautic.dashboard.error.save="The dashboard snapshot could not be saved because %msg%"

View File

@@ -0,0 +1,71 @@
mautic.dashboard.confirmation_layout_name="Enter a name for this dashboard:"
mautic.dashboard.create.past.tense="created"
mautic.dashboard.delete_layout="Delete this layout?"
mautic.dashboard.delete.past.tense="deleted"
mautic.dashboard.generated_by="Generated by %name% on %date%"
mautic.dashboard.header.index="Dashboard"
mautic.dashboard.identified.past.tense="identified"
mautic.dashboard.ipadded.past.tense="added IP"
mautic.dashboard.label.downloads="Downloads"
mautic.dashboard.label.hits="Hits"
mautic.dashboard.label.lang="Lang"
mautic.dashboard.label.title="Title"
mautic.dashboard.apply_default="Apply the default dashboard"
mautic.dashboard.menu.index="Dashboard"
mautic.dashboard.update.past.tense="updated"
mautic.dashboard.date.today="Today"
mautic.dashboard.date.yesterday="Yesterday"
mautic.dashboard.date.last_7_days="Last 7 days"
mautic.dashboard.date.last_30_days="Last 30 days"
mautic.dashboard.date.last_90_days="Last 90 days"
mautic.note.no.upcoming.emails="No emails are scheduled to be sent."
mautic.dashboard.label.created.leads="Created leads"
mautic.dashboard.label.url="URL"
mautic.dashboard.label.unique.hit.count="Unique hit count"
mautic.dashboard.label.total.hit.count="Total hit count"
mautic.dashboard.label.email.id="Email ID"
mautic.dashboard.label.email.name="Email name"
mautic.dashboard.label.contact.id="Contact ID"
mautic.dashboard.label.contact.email.address="Contact email address"
mautic.dashboard.label.contact.open="Contact open"
mautic.dashboard.label.contact.click="Clicks"
mautic.dashboard.label.contact.links.clicked="Links clicked"
mautic.dashboard.label.segment.id="Segment ID"
mautic.dashboard.label.segment.name="Segment name"
mautic.dashboard.label.company.id="Company ID"
mautic.dashboard.label.company.name="Company name"
mautic.dashboard.label.campaign.id="Company ID"
mautic.dashboard.label.campaign.name="Campaign name"
mautic.dashboard.widget.add="Add widget"
mautic.dashboard.widget.no_data="No data found"
mautic.dashboard.export.widgets="Export"
mautic.dashboard.save_as_predefined="Save as pre-defined"
mautic.dashboard.widget.import="Import or select pre-defined"
mautic.dashboard.widget.form.name="Name"
mautic.dashboard.widget.form.type="Type"
mautic.dashboard.widget.form.width="Width"
mautic.dashboard.widget.form.height="Height"
mautic.dashboard.widget.form.ordering="Place before"
mautic.dashboard.widget.ordering.last="Last"
mautic.dashboard.widget.header.edit="Edit widget"
mautic.dashboard.widget.header.new="New widget"
mautic.dashboard.widget.header.delete="Delete widget"
mautic.dashboard.widget.data.loaded.from.cache="Showing previously stored data"
mautic.dashboard.widget.data.loaded.from.database="Just retrieved latest data"
mautic.dashboard.widget.size.extra_small="Extra small (215px)"
mautic.dashboard.widget.size.small="Small (330px)"
mautic.dashboard.widget.size.medium="Medium (445px)"
mautic.dashboard.widget.size.large="Large (560px)"
mautic.dashboard.widget.size.extra_large="Extra large (675px)"
mautic.dashboard.import="Import a pre-defined dashboard"
mautic.dashboard.predefined="Pre-defined dashboards"
mautic.dashboard.import.start.instructions="Upload a pre-defined dashboard"
mautic.dashboard.widgets.preview="Preview of the selected pre-defined dashboard"
mautic.dashboard.date.from="From"
mautic.dashboard.date.to="To"
mautic.dashboard.nowidgets.tip.header="Hello there!"
mautic.dashboard.nowidgets.tip="There are no widgets in your dashboard but don't panic! You can create the widgets with the \"Add widget\" button above, \"Import\" a dashboard from a friend or"
mautic.dashboard.missing.permission="You do not have the permission to see the data from %section% section."
mautic.dashboard.preview="Preview"
mautic.dashboard.phpversionwarning.title="Please update your PHP version"
mautic.dashboard.phpversionwarning.body="You are currently using PHP %phpversion%, an outdated and insecure version of PHP. Future versions of Mautic won't support this version anymore.<br>For an overview of supported PHP versions per Mautic version, please visit <a target=\"_blank\" href=\"https://www.mautic.org/download/requirements\">the Mautic website</a>."

View File

@@ -0,0 +1 @@
mautic.dashboard.upload.filenotfound="File not found"