Initial commit: CloudOps infrastructure platform
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
class ProjectSelectBox {
|
||||
constructor(selectElement) {
|
||||
this.$projectSelect = mQuery(selectElement);
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.$projectSelect.on('chosen:no_results', this.attachKeydownListener.bind(this));
|
||||
}
|
||||
|
||||
attachKeydownListener(event) {
|
||||
const $input = mQuery(event.target).next('.chosen-container').find('.chosen-search-input');
|
||||
|
||||
$input.off('keydown').on('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const newValue = $input.val().trim();
|
||||
|
||||
if (newValue) {
|
||||
// Add the new value to the select element as an option
|
||||
const $newOption = mQuery('<option>').val('project_to_create').text(newValue).prop('selected', true);
|
||||
this.$projectSelect.append($newOption).trigger('chosen:updated');
|
||||
|
||||
this.createProjects(event.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createProjects(el) {
|
||||
const newProjectNames = [];
|
||||
const existingProjectIds = [];
|
||||
const $projectSelect = mQuery(el);
|
||||
mQuery('#' + $projectSelect.attr('id') + ' :selected').each(function(i, selected) {
|
||||
const $option = mQuery(selected);
|
||||
const selectedId = $option.val();
|
||||
|
||||
if ('project_to_create' === selectedId) {
|
||||
newProjectNames.push($option.text());
|
||||
} else {
|
||||
existingProjectIds.push(selectedId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!newProjectNames.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mautic.activateLabelLoadingIndicator($projectSelect.attr('id'));
|
||||
|
||||
Mautic.ajaxActionRequest('project:addProjects', {newProjectNames: JSON.stringify(newProjectNames), existingProjectIds: JSON.stringify(existingProjectIds)}, function(response) {
|
||||
if (response.projects) {
|
||||
mQuery('#' + $projectSelect.attr('id')).html(response.projects).trigger('chosen:updated');
|
||||
}
|
||||
|
||||
Mautic.removeLabelLoadingIndicator();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for the 'chosen:no_results' event on all select elements
|
||||
mQuery(document).on('chosen:no_results', 'select', function (event) {
|
||||
const $select = mQuery(event.target);
|
||||
|
||||
// Check if the select element has the desired attribute
|
||||
if ($select.data('action') === 'createProject') {
|
||||
new ProjectSelectBox($select);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle entity selection modal opening for project details
|
||||
mQuery(document).on('change', '#project-entity-selector, #entity-type-selector', function(event) {
|
||||
const $select = mQuery(this);
|
||||
const $selectedOption = $select.find('option:selected');
|
||||
|
||||
if ($selectedOption.val() && $selectedOption.data('href')) {
|
||||
// Get the URL and header from data attributes
|
||||
const url = $selectedOption.data('href');
|
||||
const header = $selectedOption.data('header');
|
||||
|
||||
// Use Mautic's loadAjaxModal function
|
||||
Mautic.loadAjaxModal('#MauticSharedModal', url, 'GET', header);
|
||||
|
||||
// Reset the select to placeholder after opening modal
|
||||
$select.val('');
|
||||
|
||||
// Update chosen if it's a chosen select
|
||||
if ($select.hasClass('chosen-select')) {
|
||||
$select.trigger('chosen:updated');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'routes' => [
|
||||
'main' => [
|
||||
'mautic_project_index' => [
|
||||
'path' => '/projects/{page}',
|
||||
'controller' => 'Mautic\ProjectBundle\Controller\ProjectController::indexAction',
|
||||
],
|
||||
'mautic_project_action' => [
|
||||
'path' => '/projects/{objectAction}/{objectId}',
|
||||
'controller' => 'Mautic\ProjectBundle\Controller\ProjectController::executeAction',
|
||||
],
|
||||
],
|
||||
],
|
||||
'menu' => [
|
||||
'main' => [
|
||||
'project.menu.index' => [
|
||||
'id' => Mautic\ProjectBundle\Controller\ProjectController::ROUTE_INDEX,
|
||||
'route' => Mautic\ProjectBundle\Controller\ProjectController::ROUTE_INDEX,
|
||||
'access' => Mautic\ProjectBundle\Security\Permissions\ProjectPermissions::CAN_VIEW,
|
||||
'iconClass' => 'ri-archive-stack-fill',
|
||||
'priority' => 1,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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\\ProjectBundle\\', '../')
|
||||
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
|
||||
|
||||
$services->load('Mautic\\ProjectBundle\\Entity\\', '../Entity/*Repository.php')
|
||||
->tag(Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass::REPOSITORY_SERVICE_TAG);
|
||||
|
||||
$services->alias('mautic.project.model.project', Mautic\ProjectBundle\Model\ProjectModel::class);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Controller;
|
||||
|
||||
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
|
||||
use Mautic\CoreBundle\Controller\AjaxLookupControllerTrait;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Entity\ProjectRepository;
|
||||
use Mautic\ProjectBundle\Model\ProjectModel;
|
||||
use Mautic\ProjectBundle\Security\Permissions\ProjectPermissions;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
final class AjaxController extends CommonAjaxController
|
||||
{
|
||||
use AjaxLookupControllerTrait;
|
||||
|
||||
public function getLookupChoiceListAction(Request $request, ProjectModel $projectModel): JsonResponse
|
||||
{
|
||||
$entityType = $request->query->get('entityType');
|
||||
|
||||
if (empty($entityType)) {
|
||||
return new JsonResponse([]);
|
||||
}
|
||||
|
||||
$searchKey = $request->query->get('searchKey', '');
|
||||
$searchValue = $request->query->get($searchKey, '');
|
||||
$filter = $searchValue ?: $request->query->get('search', '');
|
||||
$limit = (int) $request->query->get('limit', '10');
|
||||
$start = (int) $request->query->get('start', '0');
|
||||
|
||||
$results = $projectModel->getLookupResults($entityType, $filter, $limit, $start);
|
||||
|
||||
// Format results to match AjaxLookupControllerTrait structure
|
||||
$dataArray = [];
|
||||
foreach ($results as $value => $text) {
|
||||
$dataArray[] = [
|
||||
'text' => $text,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse($dataArray);
|
||||
}
|
||||
|
||||
public function addProjectsAction(Request $request, ProjectModel $projectModel, ProjectRepository $projectRepository, CorePermissions $corePermissions): JsonResponse
|
||||
{
|
||||
if (!$corePermissions->isGranted(ProjectPermissions::CAN_ASSOCIATE)) {
|
||||
$this->accessDenied();
|
||||
}
|
||||
|
||||
$existingProjectIds = json_decode($request->request->get('existingProjectIds'), true);
|
||||
$newProjectNames = json_decode($request->request->get('newProjectNames'), true);
|
||||
|
||||
if ($corePermissions->isGranted(ProjectPermissions::CAN_CREATE)) {
|
||||
foreach ($newProjectNames as $projectName) {
|
||||
$project = new Project();
|
||||
$project->setName($projectName);
|
||||
$projectModel->saveEntity($project);
|
||||
$existingProjectIds[] = $project->getId();
|
||||
}
|
||||
}
|
||||
|
||||
// Get an updated list of projects
|
||||
$allProjects = $projectRepository->getSimpleList(null, [], 'name');
|
||||
$projectOptions = '';
|
||||
|
||||
foreach ($allProjects as $project) {
|
||||
$selected = in_array($project['value'], $existingProjectIds) ? ' selected="selected"' : '';
|
||||
$projectOptions .= '<option'.$selected.' value="'.$project['value'].'">'.$project['label'].'</option>';
|
||||
}
|
||||
|
||||
return $this->sendJsonResponse(['projects' => $projectOptions]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Controller;
|
||||
|
||||
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use Mautic\CoreBundle\Controller\AbstractFormController;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Form\Type\ProjectAddEntityType;
|
||||
use Mautic\ProjectBundle\Form\Type\ProjectEntityType;
|
||||
use Mautic\ProjectBundle\Model\ProjectModel;
|
||||
use Mautic\ProjectBundle\Security\Permissions\ProjectPermissions;
|
||||
use Mautic\ProjectBundle\Service\ProjectEntityLoaderService;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
final class ProjectController extends AbstractFormController
|
||||
{
|
||||
public const ROUTE_INDEX = 'mautic_project_index';
|
||||
private const ROUTE_ACTION = 'mautic_project_action';
|
||||
private const LINK_ID_INDEX = '#'.self::ROUTE_INDEX;
|
||||
private const TEMPLATE_INDEX = 'Mautic\ProjectBundle\Controller\ProjectController::indexAction';
|
||||
private const TEMPLATE_FORM = '@MauticProject/Project/form.html.twig';
|
||||
|
||||
public function indexAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader, int $page = 1): Response
|
||||
{
|
||||
$session = $request->getSession();
|
||||
|
||||
$permissions = $corePermissions->isGranted([
|
||||
ProjectPermissions::CAN_VIEW,
|
||||
ProjectPermissions::CAN_EDIT,
|
||||
ProjectPermissions::CAN_CREATE,
|
||||
ProjectPermissions::CAN_DELETE,
|
||||
], 'RETURN_ARRAY');
|
||||
|
||||
if (!$permissions[ProjectPermissions::CAN_VIEW]) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$this->setListFilters();
|
||||
|
||||
$limit = $session->get('mautic.project.limit', $this->coreParametersHelper->get('default_pagelimit'));
|
||||
$start = (1 === $page) ? 0 : (($page - 1) * $limit);
|
||||
if ($start < 0) {
|
||||
$start = 0;
|
||||
}
|
||||
|
||||
$search = $request->get('search', $session->get('mautic.projects.filter', ''));
|
||||
$session->set('mautic.projects.filter', $search);
|
||||
|
||||
$orderBy = $session->get('mautic.projects.orderby', 'p.dateModified');
|
||||
$orderByDir = $session->get('mautic.projects.orderbydir', 'DESC');
|
||||
$filter = '';
|
||||
|
||||
if ($search) {
|
||||
$filter = ['string' => $search];
|
||||
}
|
||||
|
||||
$tmpl = $request->isXmlHttpRequest() ? $request->get('tmpl', 'index') : 'index';
|
||||
$items = $projectModel->getEntities(
|
||||
[
|
||||
'start' => $start,
|
||||
'limit' => $limit,
|
||||
'filter' => $filter,
|
||||
'orderBy' => $orderBy,
|
||||
'orderByDir' => $orderByDir,
|
||||
]
|
||||
);
|
||||
|
||||
// Calculate entity counts for each project
|
||||
$entityTypes = $entityLoader->getEntityTypesWithViewPermissions();
|
||||
foreach ($items as $project) {
|
||||
$projectEntities = $entityLoader->getProjectEntities($project, $entityTypes);
|
||||
$totalCount = 0;
|
||||
foreach ($projectEntities as $entityData) {
|
||||
$totalCount += $entityData['count'];
|
||||
}
|
||||
$project->entitiesCount = $totalCount;
|
||||
}
|
||||
|
||||
$count = count($items);
|
||||
|
||||
if ($count && $count < ($start + 1)) {
|
||||
// the number of entities are now less then the current page so redirect to the last page
|
||||
if (1 === $count) {
|
||||
$lastPage = 1;
|
||||
} else {
|
||||
$lastPage = (ceil($count / $limit)) ?: 1;
|
||||
}
|
||||
$session->set('mautic.projects.page', $lastPage);
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $lastPage]);
|
||||
|
||||
return $this->postActionRedirect([
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => [
|
||||
'page' => $lastPage,
|
||||
'tmpl' => $tmpl,
|
||||
],
|
||||
'contentTemplate' => self::TEMPLATE_INDEX,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$session->set('mautic.project.page', $page);
|
||||
|
||||
return $this->delegateView([
|
||||
'viewParameters' => [
|
||||
'items' => $items,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'permissions' => $permissions,
|
||||
'security' => $corePermissions,
|
||||
'tmpl' => $tmpl,
|
||||
'currentUser' => $this->user,
|
||||
'searchValue' => $search,
|
||||
],
|
||||
'contentTemplate' => '@MauticProject/Project/list.html.twig',
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'route' => $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]),
|
||||
'mauticContent' => 'projects',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function newAction(Request $request, ProjectModel $projectModel, FormFactoryInterface $formFactory, CorePermissions $corePermissions): Response
|
||||
{
|
||||
if (!$corePermissions->isGranted(ProjectPermissions::CAN_CREATE)) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$project = new Project();
|
||||
$page = $request->getSession()->get('mautic.project.page', 1);
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
|
||||
$action = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'new']);
|
||||
|
||||
$form = $this->buildForm($project, $action, $formFactory);
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
$valid = $this->isFormValid($form);
|
||||
$cancelled = $this->isFormCancelled($form);
|
||||
if (!$cancelled && $valid) {
|
||||
$projectModel->saveEntity($project);
|
||||
$this->addFlashMessage('mautic.core.notice.created', [
|
||||
'%name%' => $project->getName(),
|
||||
'%menu_link%' => self::ROUTE_INDEX,
|
||||
'%url%' => $this->generateUrl(self::ROUTE_ACTION, [
|
||||
'objectAction' => 'edit',
|
||||
'objectId' => $project->getId(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($cancelled || ($valid && $this->getFormButton($form, ['buttons', 'save'])->isClicked())) {
|
||||
return $this->postActionRedirect([
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => ['page' => $page],
|
||||
'contentTemplate' => self::TEMPLATE_INDEX,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($valid) {
|
||||
return $this->editAction($project->getId(), $request, $projectModel, $formFactory, $corePermissions, true);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->delegateView([
|
||||
'viewParameters' => [
|
||||
'form' => $form->createView(),
|
||||
'entity' => $project,
|
||||
],
|
||||
'contentTemplate' => self::TEMPLATE_FORM,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'route' => $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'new']),
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function editAction(string|int $objectId, Request $request, ProjectModel $projectModel, FormFactoryInterface $formFactory, CorePermissions $corePermissions, bool $ignorePost = false): Response
|
||||
{
|
||||
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$postActionVars = $this->getPostActionVars($request, $objectId);
|
||||
|
||||
try {
|
||||
/** @var ?Project $project */
|
||||
$project = $projectModel->getEntity($objectId);
|
||||
|
||||
if (!$project instanceof Project) {
|
||||
throw new EntityNotFoundException(sprintf('Project with id %s not found.', $objectId));
|
||||
}
|
||||
|
||||
$action = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'edit', 'objectId' => $objectId]);
|
||||
$form = $this->buildForm($project, $action, $formFactory);
|
||||
|
||||
if (!$ignorePost && 'POST' === $request->getMethod()) {
|
||||
if ($this->isFormCancelled($form)) {
|
||||
return $this->postActionRedirect($postActionVars);
|
||||
}
|
||||
|
||||
if ($this->isFormValid($form)) {
|
||||
$projectModel->saveEntity($project, $this->getFormButton($form, ['buttons', 'save'])->isClicked());
|
||||
|
||||
$this->addFlashMessage('mautic.core.notice.updated', [
|
||||
'%name%' => $project->getName(),
|
||||
'%menu_link%' => self::ROUTE_INDEX,
|
||||
'%url%' => $this->generateUrl(self::ROUTE_ACTION, [
|
||||
'objectAction' => 'edit',
|
||||
'objectId' => $project->getId(),
|
||||
]),
|
||||
]);
|
||||
|
||||
if ($this->getFormButton($form, ['buttons', 'save'])->isClicked()) {
|
||||
$contentTemplate = self::TEMPLATE_FORM;
|
||||
$postActionVars['contentTemplate'] = $contentTemplate;
|
||||
$postActionVars['forwardController'] = false;
|
||||
$postActionVars['returnUrl'] = $this->generateUrl(self::ROUTE_ACTION, [
|
||||
'objectAction' => 'edit',
|
||||
'objectId' => $project->getId(),
|
||||
]);
|
||||
|
||||
// Re-create the form once more with the fresh project and action.
|
||||
// The alias was empty on redirect after cloning.
|
||||
$editAction = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'edit', 'objectId' => $project->getId()]);
|
||||
$form = $this->buildForm($project, $editAction, $formFactory);
|
||||
|
||||
$postActionVars['viewParameters'] = [
|
||||
'objectAction' => 'edit',
|
||||
'entity' => $project,
|
||||
'objectId' => $project->getId(),
|
||||
'form' => $form->createView(),
|
||||
];
|
||||
|
||||
return $this->postActionRedirect($postActionVars);
|
||||
}
|
||||
|
||||
// Redirect to view action after successful edit
|
||||
$viewUrl = $this->generateUrl(self::ROUTE_ACTION, [
|
||||
'objectAction' => 'view',
|
||||
'objectId' => $project->getId(),
|
||||
]);
|
||||
|
||||
return $this->redirect($viewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->delegateView([
|
||||
'viewParameters' => [
|
||||
'form' => $form->createView(),
|
||||
'entity' => $project,
|
||||
'currentProject' => $project->getId(),
|
||||
],
|
||||
'contentTemplate' => self::TEMPLATE_FORM,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'route' => $action,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
]);
|
||||
} catch (AccessDeniedException) {
|
||||
return $this->accessDenied();
|
||||
} catch (EntityNotFoundException) {
|
||||
return $this->postActionRedirect(
|
||||
array_merge($postActionVars, [
|
||||
'flashes' => [
|
||||
[
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.project.error.notfound',
|
||||
'msgVars' => ['%id%' => $objectId],
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function getPostActionVars(Request $request, string|int|null $objectId = null): array
|
||||
{
|
||||
if ($objectId) {
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'view', 'objectId' => $objectId]);
|
||||
$viewParameters = ['objectAction' => 'view', 'objectId' => $objectId];
|
||||
$contentTemplate = 'Mautic\ProjectBundle\Controller\ProjectController::viewAction';
|
||||
} else {
|
||||
$page = $request->getSession()->get('mautic.project.page', 1);
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
|
||||
$viewParameters = ['page' => $page];
|
||||
$contentTemplate = self::TEMPLATE_INDEX;
|
||||
}
|
||||
|
||||
return [
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => $viewParameters,
|
||||
'contentTemplate' => $contentTemplate,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function viewAction(string|int $objectId, Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader): Response
|
||||
{
|
||||
/** @var ?Project $project */
|
||||
$project = $projectModel->getEntity($objectId);
|
||||
|
||||
$page = $request->getSession()->get('mautic.project.page', 1);
|
||||
if (null === $project) {
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
|
||||
|
||||
return $this->postActionRedirect([
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => ['page' => $page],
|
||||
'contentTemplate' => self::TEMPLATE_INDEX,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
'flashes' => [
|
||||
[
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.project.error.notfound',
|
||||
'msgVars' => ['%id%' => $objectId],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$corePermissions->isGranted(ProjectPermissions::CAN_VIEW)) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$entityTypes = $entityLoader->getEntityTypesWithViewPermissions();
|
||||
$projectEntities = $entityLoader->getProjectEntities($project, $entityTypes);
|
||||
|
||||
return $this->delegateView([
|
||||
'returnUrl' => $this->generateUrl(self::ROUTE_ACTION, ['objectAction' => 'view', 'objectId' => $project->getId()]),
|
||||
'viewParameters' => [
|
||||
'project' => $project,
|
||||
'projectEntities' => $projectEntities,
|
||||
'entityTypes' => $entityTypes,
|
||||
],
|
||||
'contentTemplate' => '@MauticProject/Project/details.html.twig',
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteAction(string $objectId, Request $request, ProjectModel $projectModel, CorePermissions $corePermissions): Response
|
||||
{
|
||||
$page = $request->getSession()->get('mautic.project.page', 1);
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
|
||||
$flashes = [];
|
||||
|
||||
$postActionVars = [
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => ['page' => $page],
|
||||
'contentTemplate' => self::TEMPLATE_INDEX,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
];
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
/** @var ?Project $project */
|
||||
$project = $projectModel->getEntity($objectId);
|
||||
|
||||
if (null === $project) {
|
||||
$flashes[] = [
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.project.error.notfound',
|
||||
'msgVars' => ['%id%' => $objectId],
|
||||
];
|
||||
} elseif (!$corePermissions->isGranted(ProjectPermissions::CAN_DELETE)) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$projectModel->deleteEntity($project);
|
||||
|
||||
$flashes[] = [
|
||||
'type' => 'notice',
|
||||
'msg' => 'mautic.core.notice.deleted',
|
||||
'msgVars' => [
|
||||
'%name%' => $project->getName(),
|
||||
'%id%' => $objectId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
public function batchDeleteAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions): Response
|
||||
{
|
||||
$page = $request->getSession()->get('mautic.project.page', 1);
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_INDEX, ['page' => $page]);
|
||||
$flashes = [];
|
||||
|
||||
$postActionVars = [
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => ['page' => $page],
|
||||
'contentTemplate' => self::TEMPLATE_INDEX,
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
];
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
$ids = json_decode($request->query->get('ids', '{}'));
|
||||
$deleteIds = [];
|
||||
|
||||
// Loop over the IDs to perform access checks pre-delete
|
||||
foreach ($ids as $objectId) {
|
||||
$entity = $projectModel->getEntity($objectId);
|
||||
|
||||
if (null === $entity) {
|
||||
$flashes[] = [
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.project.error.notfound',
|
||||
'msgVars' => ['%id%' => $objectId],
|
||||
];
|
||||
} elseif (!$corePermissions->isGranted(ProjectPermissions::CAN_DELETE)) {
|
||||
$flashes[] = $this->accessDenied(true);
|
||||
} else {
|
||||
$deleteIds[] = $objectId;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete everything we are able to
|
||||
if (!empty($deleteIds)) {
|
||||
try {
|
||||
$entities = $projectModel->deleteEntities($deleteIds);
|
||||
} catch (ForeignKeyConstraintViolationException) {
|
||||
$flashes[] = [
|
||||
'type' => 'notice',
|
||||
'msg' => 'mautic.project.error.cannotbedeleted',
|
||||
];
|
||||
|
||||
return $this->postActionRedirect(
|
||||
array_merge($postActionVars, ['flashes' => $flashes])
|
||||
);
|
||||
}
|
||||
|
||||
$flashes[] = [
|
||||
'type' => 'notice',
|
||||
'msg' => 'mautic.project.notice.batch_deleted',
|
||||
'msgVars' => [
|
||||
'%count%' => count($entities),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
public function selectEntityTypeAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader): Response
|
||||
{
|
||||
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$projectId = $request->get('objectId');
|
||||
|
||||
/** @var ?Project $project */
|
||||
$project = $projectModel->getEntity($projectId);
|
||||
if (!$project instanceof Project) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
// Get available entity types
|
||||
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
|
||||
|
||||
return $this->delegateView([
|
||||
'viewParameters' => [
|
||||
'project' => $project,
|
||||
'entityTypes' => $entityTypes,
|
||||
],
|
||||
'contentTemplate' => '@MauticProject/Project/select_entity_type_modal.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
public function addEntityAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, FormFactoryInterface $formFactory, ProjectEntityLoaderService $entityLoader): Response
|
||||
{
|
||||
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$projectId = $request->get('objectId');
|
||||
$entityType = $request->get('entityType');
|
||||
|
||||
/** @var ?Project $project */
|
||||
$project = $projectModel->getEntity($projectId);
|
||||
if (!$project instanceof Project) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
// Validate entity type
|
||||
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
|
||||
if (!isset($entityTypes[$entityType])) {
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, [
|
||||
'objectAction' => 'view',
|
||||
'objectId' => $projectId,
|
||||
]);
|
||||
|
||||
return $this->postActionRedirect([
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => ['objectAction' => 'view', 'objectId' => $projectId],
|
||||
'contentTemplate' => 'Mautic\ProjectBundle\Controller\ProjectController::viewAction',
|
||||
'passthroughVars' => [
|
||||
'closeModal' => 1,
|
||||
'route' => false,
|
||||
],
|
||||
'flashes' => [
|
||||
[
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.project.error.invalid_entity_type',
|
||||
'msgVars' => ['%type%' => $entityType],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Generate the form action URL
|
||||
$action = $this->generateUrl('mautic_project_action', [
|
||||
'objectAction' => 'addEntity',
|
||||
'objectId' => $project->getId(),
|
||||
'entityType' => $entityType,
|
||||
]);
|
||||
|
||||
// Create the form
|
||||
$form = $formFactory->create(ProjectAddEntityType::class, [], [
|
||||
'action' => $action,
|
||||
'entityType' => $entityType,
|
||||
'projectId' => $project->getId(),
|
||||
]);
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
$flashes = [];
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, [
|
||||
'objectAction' => 'view',
|
||||
'objectId' => $projectId,
|
||||
]);
|
||||
|
||||
$postActionVars = [
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => ['objectAction' => 'view', 'objectId' => $projectId],
|
||||
'contentTemplate' => 'Mautic\ProjectBundle\Controller\ProjectController::viewAction',
|
||||
'passthroughVars' => [
|
||||
'closeModal' => 1,
|
||||
'route' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$isCancelled = $this->isFormCancelled($form);
|
||||
$isValid = $this->isFormValid($form);
|
||||
|
||||
if ($isCancelled || !$isValid) {
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
$data = $form->getData();
|
||||
$entityIds = $data['entityIds'] ?? [];
|
||||
|
||||
if (empty($entityIds)) {
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
// Get entity types configuration
|
||||
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
|
||||
if (!isset($entityTypes[$entityType])) {
|
||||
$flashes[] = [
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.core.error.badrequest',
|
||||
];
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
$entityConfig = $entityTypes[$entityType];
|
||||
$addedCount = 0;
|
||||
|
||||
foreach ($entityIds as $entityId) {
|
||||
$entity = $entityConfig->model->getEntity($entityId);
|
||||
if (!$entity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if entity is not already in project
|
||||
if ($entity->getProjects()->contains($project)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity->addProject($project);
|
||||
$this->doctrine->getManager()->persist($entity);
|
||||
++$addedCount;
|
||||
}
|
||||
|
||||
if ($addedCount > 0) {
|
||||
$this->doctrine->getManager()->flush();
|
||||
$flashes[] = [
|
||||
'type' => 'notice',
|
||||
'msg' => 'mautic.project.notice.entities_added',
|
||||
'msgVars' => [
|
||||
'%count%' => $addedCount,
|
||||
'%project%' => $project->getName(),
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$flashes[] = [
|
||||
'type' => 'notice',
|
||||
'msg' => 'mautic.project.notice.no_entities_added',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
return $this->delegateView([
|
||||
'viewParameters' => [
|
||||
'form' => $form->createView(),
|
||||
'project' => $project,
|
||||
'entityType' => $entityType,
|
||||
],
|
||||
'contentTemplate' => '@MauticProject/Project/add_entity_modal.html.twig',
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeAction(Request $request, ProjectModel $projectModel, CorePermissions $corePermissions, ProjectEntityLoaderService $entityLoader): Response
|
||||
{
|
||||
if (!$corePermissions->isGranted(ProjectPermissions::CAN_EDIT)) {
|
||||
return $this->accessDenied();
|
||||
}
|
||||
|
||||
$projectId = $request->get('objectId');
|
||||
$entityType = $request->get('entityType');
|
||||
$entityId = $request->get('entityId');
|
||||
$flashes = [];
|
||||
|
||||
$returnUrl = $this->generateUrl(self::ROUTE_ACTION, [
|
||||
'objectAction' => 'view',
|
||||
'objectId' => $projectId,
|
||||
]);
|
||||
|
||||
$postActionVars = [
|
||||
'returnUrl' => $returnUrl,
|
||||
'viewParameters' => ['objectAction' => 'view', 'objectId' => $projectId],
|
||||
'contentTemplate' => 'Mautic\ProjectBundle\Controller\ProjectController::viewAction',
|
||||
'passthroughVars' => [
|
||||
'activeLink' => self::LINK_ID_INDEX,
|
||||
'mauticContent' => 'project',
|
||||
],
|
||||
];
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
/** @var ?Project $project */
|
||||
$project = $projectModel->getEntity($projectId);
|
||||
if (!$project instanceof Project) {
|
||||
$flashes[] = [
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.project.error.notfound',
|
||||
'msgVars' => ['%id%' => $projectId],
|
||||
];
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
// Get entity types configuration
|
||||
$entityTypes = $entityLoader->getEntityTypesWithEditPermissions();
|
||||
if (!isset($entityTypes[$entityType])) {
|
||||
$flashes[] = [
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.core.error.badrequest',
|
||||
];
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
$entityConfig = $entityTypes[$entityType];
|
||||
$entity = $entityConfig->model->getEntity($entityId);
|
||||
|
||||
if (!$entity) {
|
||||
$flashes[] = [
|
||||
'type' => 'error',
|
||||
'msg' => 'mautic.core.error.notfound',
|
||||
];
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
// Remove the project from the entity's projects collection
|
||||
$entity->removeProject($project);
|
||||
$this->doctrine->getManager()->persist($entity);
|
||||
$this->doctrine->getManager()->flush();
|
||||
|
||||
$entityName = method_exists($entity, 'getName') ? $entity->getName() : (method_exists($entity, 'getTitle') ? $entity->getTitle() : $entity->getId());
|
||||
|
||||
$flashes[] = [
|
||||
'type' => 'notice',
|
||||
'msg' => 'mautic.project.notice.item_removed',
|
||||
'msgVars' => [
|
||||
'%name%' => $entityName,
|
||||
'%project%' => $project->getName(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->postActionRedirect(array_merge($postActionVars, ['flashes' => $flashes]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface<FormInterface>&FormInterface
|
||||
*/
|
||||
private function buildForm(Project $project, string $action, FormFactoryInterface $formFactory): FormInterface
|
||||
{
|
||||
return $formFactory->create(ProjectEntityType::class, $project, ['action' => $action]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\DTO;
|
||||
|
||||
final readonly class EntityTypeConfig
|
||||
{
|
||||
public function __construct(
|
||||
public string $entityClass,
|
||||
public string $label,
|
||||
public ?object $model = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\Extension;
|
||||
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
|
||||
|
||||
class MauticProjectExtension 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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata as OrmClassMetadata;
|
||||
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
|
||||
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
|
||||
use Mautic\CoreBundle\Entity\FormEntity;
|
||||
use Mautic\CoreBundle\Entity\UuidInterface;
|
||||
use Mautic\CoreBundle\Entity\UuidTrait;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Mapping\ClassMetadata;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('project:projects:view')"),
|
||||
new Post(security: "is_granted('project:projects:create')"),
|
||||
new Get(security: "is_granted('project:projects:view')"),
|
||||
new Put(security: "is_granted('project:projects:edit')"),
|
||||
new Patch(security: "is_granted('project:projects:edit')"),
|
||||
new Delete(security: "is_granted('project:projects:delete')"),
|
||||
],
|
||||
normalizationContext: [
|
||||
'groups' => ['project:read'],
|
||||
'swagger_definition_name' => 'Read',
|
||||
],
|
||||
denormalizationContext: [
|
||||
'groups' => ['project:write'],
|
||||
'swagger_definition_name' => 'Write',
|
||||
]
|
||||
)]
|
||||
class Project extends FormEntity implements UuidInterface
|
||||
{
|
||||
use UuidTrait;
|
||||
|
||||
public const TABLE_NAME = 'projects';
|
||||
|
||||
#[Groups(['project:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/**
|
||||
* @var mixed[]
|
||||
*/
|
||||
#[Groups(['project:read', 'project:write'])]
|
||||
private array $properties = [];
|
||||
|
||||
/**
|
||||
* Transient property to store the count of entities associated with this project.
|
||||
* This is not persisted to the database.
|
||||
*/
|
||||
public int $entitiesCount = 0;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->id = null;
|
||||
|
||||
parent::__clone();
|
||||
}
|
||||
|
||||
public static function loadMetadata(OrmClassMetadata $metadata): void
|
||||
{
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable(self::TABLE_NAME)
|
||||
->setCustomRepositoryClass(ProjectRepository::class)
|
||||
->addIndex(['name'], 'project_name');
|
||||
|
||||
$builder->addIdColumns();
|
||||
|
||||
$builder->addField('properties', Types::JSON);
|
||||
|
||||
static::addUuidField($builder);
|
||||
}
|
||||
|
||||
public static function loadApiMetadata(ApiMetadataDriver $metadata): void
|
||||
{
|
||||
$metadata->setGroupPrefix('project')
|
||||
->addListProperties(
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
]
|
||||
)
|
||||
->addProperties(
|
||||
[
|
||||
'description',
|
||||
'properties',
|
||||
]
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
public static function loadValidatorMetadata(ClassMetadata $metadata): void
|
||||
{
|
||||
$metadata->addPropertyConstraint(
|
||||
'name',
|
||||
new NotBlank(['message' => 'mautic.core.name.required'])
|
||||
);
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): void
|
||||
{
|
||||
$this->isChanged('description', $description);
|
||||
|
||||
$this->description = $description;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(?string $name): void
|
||||
{
|
||||
$this->isChanged('name', $name);
|
||||
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed[]
|
||||
*/
|
||||
public function getProperties(): array
|
||||
{
|
||||
return $this->properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $properties
|
||||
*/
|
||||
public function setProperties(array $properties): void
|
||||
{
|
||||
$this->isChanged('properties', $properties);
|
||||
|
||||
$this->properties = $properties;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Entity;
|
||||
|
||||
use Mautic\CoreBundle\Entity\CommonRepository;
|
||||
|
||||
class ProjectRepository extends CommonRepository
|
||||
{
|
||||
/**
|
||||
* @return array<string[]>
|
||||
*/
|
||||
protected function getDefaultOrder(): array
|
||||
{
|
||||
return [
|
||||
['p.date_modified', 'ASC'],
|
||||
];
|
||||
}
|
||||
|
||||
public function getTableAlias(): string
|
||||
{
|
||||
return 'p';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Entity;
|
||||
|
||||
use Doctrine\DBAL\Query\QueryBuilder;
|
||||
|
||||
trait ProjectRepositoryTrait
|
||||
{
|
||||
/**
|
||||
* @return array{0: string, 1: array<string, array<int|string>>}
|
||||
*/
|
||||
private function handleProjectFilter(QueryBuilder $queryBuilder, string $idColumn, string $xrefTable, string $parentTableAlias, string $projectName, bool $negation): array
|
||||
{
|
||||
$queryBuilder->select($idColumn);
|
||||
$queryBuilder->from(MAUTIC_TABLE_PREFIX.$xrefTable, 'projectxref');
|
||||
$queryBuilder->innerJoin(
|
||||
'projectxref',
|
||||
MAUTIC_TABLE_PREFIX.'projects',
|
||||
'project',
|
||||
'project.id = projectxref.project_id'
|
||||
);
|
||||
$queryBuilder->where($queryBuilder->expr()->eq('project.name', ':name'));
|
||||
$queryBuilder->setParameter('name', $projectName);
|
||||
$ids = $queryBuilder->executeQuery()->fetchFirstColumn() ?: [0];
|
||||
$ids = array_map(fn ($value) => "'$value'", $ids);
|
||||
|
||||
if ($negation) {
|
||||
$expr = $queryBuilder->expr()->notIn("{$parentTableAlias}.id", $ids);
|
||||
} else {
|
||||
$expr = $queryBuilder->expr()->in("{$parentTableAlias}.id", $ids);
|
||||
}
|
||||
|
||||
return [$expr, []];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Mautic\ApiBundle\Serializer\Driver\ApiMetadataDriver;
|
||||
|
||||
trait ProjectTrait
|
||||
{
|
||||
/**
|
||||
* @var Collection<int, Project>
|
||||
*/
|
||||
private Collection $projects;
|
||||
|
||||
private function initializeProjects(): void
|
||||
{
|
||||
$this->projects = new ArrayCollection();
|
||||
}
|
||||
|
||||
private static function addProjectsField(ClassMetadataBuilder $builder, string $tableName, string $columnName): void
|
||||
{
|
||||
$builder->createManyToMany('projects', Project::class)
|
||||
->setJoinTable($tableName)
|
||||
->addInverseJoinColumn('project_id', 'id', false, false, 'CASCADE')
|
||||
->addJoinColumn($columnName, 'id', false, false, 'CASCADE')
|
||||
->setOrderBy(['name' => 'ASC'])
|
||||
->setIndexBy('name')
|
||||
->fetchLazy()
|
||||
->cascadeMerge()
|
||||
->cascadePersist()
|
||||
->cascadeDetach()
|
||||
->build();
|
||||
}
|
||||
|
||||
private static function addProjectsInLoadApiMetadata(ApiMetadataDriver $metadata, string $groupPrefix): void
|
||||
{
|
||||
$metadata->setGroupPrefix($groupPrefix)->addProperties(['projects'])->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $prop
|
||||
* @param mixed $val
|
||||
*/
|
||||
protected function isChanged($prop, $val): void
|
||||
{
|
||||
if ('projects' === $prop) {
|
||||
if ($val instanceof Project) {
|
||||
$this->changes['projects']['added'][] = $val->getName();
|
||||
} else {
|
||||
$this->changes['projects']['removed'][] = $val;
|
||||
}
|
||||
} else {
|
||||
parent::isChanged($prop, $val);
|
||||
}
|
||||
}
|
||||
|
||||
public function addProject(Project $project): void
|
||||
{
|
||||
$this->isChanged('projects', $project);
|
||||
$this->projects[] = $project;
|
||||
}
|
||||
|
||||
public function removeProject(Project $project): bool
|
||||
{
|
||||
$this->isChanged('projects', $project->getName());
|
||||
|
||||
return $this->projects->removeElement($project);
|
||||
}
|
||||
|
||||
public function getProjects(): Collection
|
||||
{
|
||||
return $this->projects;
|
||||
}
|
||||
|
||||
public function setProjects(Collection $projects): self
|
||||
{
|
||||
$this->projects = $projects;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Event;
|
||||
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* Event dispatched to allow bundles to extend entity type to model key mappings.
|
||||
*/
|
||||
final class EntityTypeModelMappingEvent extends Event
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $mappings
|
||||
*/
|
||||
public function __construct(private array $mappings = [])
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a model key mapping.
|
||||
*/
|
||||
public function addMapping(string $entityType, string $modelKey): void
|
||||
{
|
||||
$this->mappings[$entityType] = $modelKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple model key mappings.
|
||||
*
|
||||
* @param array<string, string> $mappings
|
||||
*/
|
||||
public function addMappings(array $mappings): void
|
||||
{
|
||||
$this->mappings = array_merge($this->mappings, $mappings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all model key mappings.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getMappings(): array
|
||||
{
|
||||
return $this->mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model key for entity type or return entity type if no mapping exists.
|
||||
*/
|
||||
public function getModelKey(string $entityType): string
|
||||
{
|
||||
return $this->mappings[$entityType] ?? $entityType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Event;
|
||||
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* Event dispatched to allow bundles to extend entity type normalization mappings.
|
||||
*/
|
||||
final class EntityTypeNormalizationEvent extends Event
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $mappings
|
||||
*/
|
||||
public function __construct(private array $mappings = [])
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a normalization mapping.
|
||||
*/
|
||||
public function addMapping(string $from, string $to): void
|
||||
{
|
||||
$this->mappings[$from] = $to;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple normalization mappings.
|
||||
*
|
||||
* @param array<string, string> $mappings
|
||||
*/
|
||||
public function addMappings(array $mappings): void
|
||||
{
|
||||
$this->mappings = array_merge($this->mappings, $mappings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all normalization mappings.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getMappings(): array
|
||||
{
|
||||
return $this->mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized entity type or return original if no mapping exists.
|
||||
*/
|
||||
public function getNormalizedType(string $entityType): string
|
||||
{
|
||||
return $this->mappings[$entityType] ?? $entityType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\EventListener;
|
||||
|
||||
use Mautic\ApiBundle\Event\ApiInitializeEvent;
|
||||
use Mautic\ApiBundle\Serializer\Exclusion\FieldInclusionStrategy;
|
||||
use Mautic\AssetBundle\Entity\Asset;
|
||||
use Mautic\CampaignBundle\Entity\Campaign;
|
||||
use Mautic\ChannelBundle\Entity\Message;
|
||||
use Mautic\DynamicContentBundle\Entity\DynamicContent;
|
||||
use Mautic\EmailBundle\Entity\Email;
|
||||
use Mautic\FormBundle\Entity\Form;
|
||||
use Mautic\LeadBundle\Entity\Company;
|
||||
use Mautic\LeadBundle\Entity\LeadList;
|
||||
use Mautic\PageBundle\Entity\Page;
|
||||
use Mautic\PointBundle\Entity\Point;
|
||||
use Mautic\PointBundle\Entity\Trigger;
|
||||
use Mautic\SmsBundle\Entity\Sms;
|
||||
use Mautic\StageBundle\Entity\Stage;
|
||||
use MauticPlugin\MauticFocusBundle\Entity\Focus;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
final class ApiSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
ApiInitializeEvent::class=> ['onApiInitializeEvent', 0],
|
||||
];
|
||||
}
|
||||
|
||||
public function onApiInitializeEvent(ApiInitializeEvent $event): void
|
||||
{
|
||||
if (!in_array($event->getEntityClass(), [
|
||||
Asset::class,
|
||||
Campaign::class,
|
||||
Message::class,
|
||||
DynamicContent::class,
|
||||
Email::class,
|
||||
Form::class,
|
||||
Company::class,
|
||||
LeadList::class,
|
||||
Page::class,
|
||||
Point::class,
|
||||
Trigger::class,
|
||||
Sms::class,
|
||||
Stage::class,
|
||||
Focus::class,
|
||||
])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->addSerializerGroup('projectList');
|
||||
$event->addExclusionStrategy(new FieldInclusionStrategy(['id', 'name'], 1, 'projects'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Form\Type;
|
||||
|
||||
use Mautic\CoreBundle\Form\Type\FormButtonsType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<mixed>
|
||||
*/
|
||||
final class ProjectAddEntityType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add(
|
||||
'entityType',
|
||||
HiddenType::class,
|
||||
[
|
||||
'data' => $options['entityType'],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'projectId',
|
||||
HiddenType::class,
|
||||
[
|
||||
'data' => $options['projectId'],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'entityIds',
|
||||
ProjectListEntityType::class,
|
||||
[
|
||||
'label' => 'mautic.project.form.select_entities',
|
||||
'required' => true,
|
||||
'multiple' => true,
|
||||
'entityType' => $options['entityType'],
|
||||
'projectId' => $options['projectId'],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'buttons',
|
||||
FormButtonsType::class,
|
||||
[
|
||||
'apply_text' => false,
|
||||
'save_text' => 'mautic.core.form.add',
|
||||
'save_icon' => 'ri-add-line',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'entityType' => 'email',
|
||||
'projectId' => null,
|
||||
]);
|
||||
|
||||
$resolver->setRequired(['entityType', 'projectId']);
|
||||
$resolver->setAllowedTypes('entityType', 'string');
|
||||
$resolver->setAllowedTypes('projectId', ['int', 'string']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Form\Type;
|
||||
|
||||
use Mautic\CoreBundle\Form\Type\FormButtonsType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final class ProjectEntityType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('buttons', FormButtonsType::class);
|
||||
|
||||
$builder->add(
|
||||
'name',
|
||||
TextType::class,
|
||||
[
|
||||
'label' => 'mautic.core.name',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => ['class' => 'form-control'],
|
||||
]
|
||||
);
|
||||
|
||||
$builder->add(
|
||||
'description',
|
||||
TextareaType::class,
|
||||
[
|
||||
'required' => false,
|
||||
'label' => 'mautic.core.description',
|
||||
'label_attr' => ['class' => 'control-label'],
|
||||
'attr' => ['class' => 'form-control editor'],
|
||||
]
|
||||
);
|
||||
|
||||
if (!empty($options['action'])) {
|
||||
$builder->setAction($options['action']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Form\Type;
|
||||
|
||||
use Mautic\CoreBundle\Form\Type\EntityLookupType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* @extends AbstractType<mixed>
|
||||
*/
|
||||
final class ProjectListEntityType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'required' => false,
|
||||
'multiple' => false,
|
||||
'ajax_lookup_action' => fn (Options $options): string => 'project:getLookupChoiceList&'.http_build_query([
|
||||
'entityType' => $options['entityType'],
|
||||
'projectId' => $options['projectId'] ?? null,
|
||||
]),
|
||||
'modal_route' => false,
|
||||
'modal_route_parameters' => [],
|
||||
'model' => 'project',
|
||||
'model_lookup_method' => 'getLookupResults',
|
||||
'lookup_arguments' => fn (Options $options): array => [
|
||||
'type' => $options['entityType'],
|
||||
'filter' => '$data',
|
||||
'limit' => 100,
|
||||
'start' => 0,
|
||||
'options' => [
|
||||
'entityType' => $options['entityType'],
|
||||
'projectId' => $options['projectId'] ?? null,
|
||||
],
|
||||
],
|
||||
'entityType' => 'email',
|
||||
'projectId' => null,
|
||||
'label_parameters' => [],
|
||||
]);
|
||||
|
||||
$resolver->setRequired(['entityType']);
|
||||
$resolver->setAllowedTypes('entityType', 'string');
|
||||
$resolver->setAllowedTypes('projectId', ['int', 'string', 'null']);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return EntityLookupType::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Form\Type;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Security\Permissions\ProjectPermissions;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class ProjectType extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator,
|
||||
private CorePermissions $corePermissions,
|
||||
) {
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$attr = ['data-placeholder' => $this->translator->trans('mautic.project.mautic.project.select')];
|
||||
|
||||
if ($this->corePermissions->isGranted(ProjectPermissions::CAN_CREATE)) {
|
||||
$attr['data-placeholder'] = $this->translator->trans('mautic.project.select_or_create');
|
||||
$attr['data-action'] = 'createProject';
|
||||
$attr['data-no-results-text'] = $this->translator->trans('mautic.project.enter_to_create');
|
||||
$attr['data-allow-add'] = 'true';
|
||||
}
|
||||
|
||||
$resolver->setDefaults(
|
||||
[
|
||||
'label' => 'project.menu.index',
|
||||
'class' => Project::class,
|
||||
'query_builder' => fn (EntityRepository $er) => $er->createQueryBuilder('p')->orderBy('p.name', 'ASC'),
|
||||
'choice_label' => 'name',
|
||||
'multiple' => true,
|
||||
'required' => false,
|
||||
'disabled' => !$this->corePermissions->isGranted(ProjectPermissions::CAN_ASSOCIATE),
|
||||
'by_reference' => false,
|
||||
'attr' => $attr,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return EntityType::class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class MauticProjectBundle extends Bundle
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Model;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\UserHelper;
|
||||
use Mautic\CoreBundle\Model\AjaxLookupModelInterface;
|
||||
use Mautic\CoreBundle\Model\FormModel;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\CoreBundle\Translation\Translator;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Entity\ProjectRepository;
|
||||
use Mautic\ProjectBundle\Service\ProjectEntityLoaderService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
final class ProjectModel extends FormModel implements AjaxLookupModelInterface
|
||||
{
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
CorePermissions $security,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
UrlGeneratorInterface $router,
|
||||
Translator $translator,
|
||||
UserHelper $userHelper,
|
||||
LoggerInterface $logger,
|
||||
CoreParametersHelper $coreParametersHelper,
|
||||
private ProjectEntityLoaderService $entityLoaderService,
|
||||
) {
|
||||
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $logger, $coreParametersHelper);
|
||||
}
|
||||
|
||||
public function getRepository(): ProjectRepository
|
||||
{
|
||||
$repository = $this->em->getRepository(Project::class);
|
||||
\assert($repository instanceof ProjectRepository);
|
||||
|
||||
return $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param string $type
|
||||
* @param string $filter
|
||||
* @param int $limit
|
||||
* @param int $start
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @return array<int|string, string>
|
||||
*/
|
||||
public function getLookupResults($type, $filter = '', $limit = 10, $start = 0, array $options = []): array
|
||||
{
|
||||
// Convert filter to string if it's an array (happens when $data is replaced with actual data)
|
||||
if (is_array($filter)) {
|
||||
$filter = implode('|', $filter);
|
||||
}
|
||||
|
||||
// Extract projectId from options if provided
|
||||
$projectId = $options['projectId'] ?? null;
|
||||
|
||||
// Results are already in the correct format (id => name)
|
||||
return $this->entityLoaderService->getLookupResults($type, (string) $filter, (int) $limit, (int) $start, $projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% if item.projects is not empty %}
|
||||
<div class="project-group d-inline-flex gap-3 mb-xs">
|
||||
{% for project in item.projects %}
|
||||
|
||||
{% set projectPopover %}
|
||||
<header class="project-popover__header mt-xs">
|
||||
<span class="project-popover__icon" aria-hidden="true"><i class="ri-archive-stack-line"></i></span>
|
||||
<h2 class="project-popover__title type-heading-02 text-interactive">{{ project.name }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="project-popover__meta mb-md">
|
||||
{% if project.dateModified is not empty %}
|
||||
<span class="project-popover__meta-text type-label-01 text-secondary">
|
||||
{{ 'mautic.project.popover.updated.on'|trans({'%date%': project.dateModified.date|date}) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="project-popover__description type-body-compact-01 mb-md">
|
||||
{{ project.description|striptags|slice(0, 150) ~ (project.description|length > 150 ? '...' : '') }}
|
||||
</p>
|
||||
|
||||
<div class="project-popover__actions">
|
||||
{% set buttons = [] %}
|
||||
|
||||
{% if securityIsGranted('project:project:view') %}
|
||||
{% set buttons = buttons|merge([
|
||||
{
|
||||
label: 'mautic.project.popover.view.details'|trans,
|
||||
href: path('mautic_project_action', {'objectAction': 'view', 'objectId': project.id}),
|
||||
icon: 'ri-arrow-right-line',
|
||||
variant: 'tertiary',
|
||||
size: 'sm',
|
||||
wide: true,
|
||||
attributes: {
|
||||
class: 'project-popover__action mb-xs'
|
||||
}
|
||||
}
|
||||
]) %}
|
||||
{% endif %}
|
||||
|
||||
{% if securityIsGranted('project:project:edit') %}
|
||||
{% set buttons = buttons|merge([
|
||||
{
|
||||
label: 'mautic.project.popover.edit'|trans,
|
||||
href: path('mautic_project_action', {'objectAction': 'edit', 'objectId': project.id}),
|
||||
icon: 'ri-edit-line',
|
||||
variant: 'tertiary',
|
||||
size: 'sm',
|
||||
wide: true,
|
||||
attributes: {
|
||||
class: 'project-popover__action ml-0'
|
||||
}
|
||||
}
|
||||
]) %}
|
||||
{% endif %}
|
||||
|
||||
{% if buttons|length > 0 %}
|
||||
{% include '@MauticCore/Helper/button.html.twig' with {
|
||||
buttons: buttons
|
||||
} %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<footer class="project-popover__footer type-label-01 text-placeholder mt-lg">
|
||||
<span class="project-popover__footer-text">{{ 'mautic.project.popover.footer.label'|trans }}</span>
|
||||
</footer>
|
||||
{% endset %}
|
||||
|
||||
<div class="project-popover" data-toggle="popover" data-content='{{ projectPopover }}' data-html="true" data-container="body" data-placement="bottom" data-trigger="hover" data-delay='{"show": 300, "hide": 2000}'>
|
||||
{% include '@MauticCore/Helper/_tag.html.twig' with {
|
||||
tags: [
|
||||
{
|
||||
label: project.name,
|
||||
color: 'brand',
|
||||
size: 'sm',
|
||||
}
|
||||
]
|
||||
} %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% set hasEntities = false %}
|
||||
{% for entityType, entityData in projectEntities %}
|
||||
{% if entityData.count > 0 %}
|
||||
{% set hasEntities = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if hasEntities %}
|
||||
{% for entityType, entityData in projectEntities %}
|
||||
{% if entityData.count > 0 %}
|
||||
<div id="{{ entityType }}-list-wrapper" class="panel panel-default mt-md">
|
||||
<div class="panel-heading pa-0 toolbar--table-toolbar">
|
||||
<div class="d-flex ai-center jc-space-between list-toolbar toolbar--toolbar-content h-48">
|
||||
<h4 class="panel-title fw-light mb-0 pl-md">{{ entityData.label }}</h4>
|
||||
<a class="btn btn-ghost" data-toggle="ajaxmodal" data-target="#MauticSharedModal" href="{{ path('mautic_project_action', {'objectAction': 'addEntity', 'objectId': project.getId(), 'entityType': entityType}) }}" data-header="{{ 'mautic.project.add'|trans }} {{ entityData.label|trans }}" aria-label="{{ 'mautic.project.add'|trans }} {{ entityData.label|trans }}">
|
||||
<i class="ri-add-line" aria-hidden="true" focusable="false"></i>
|
||||
<span class="hidden-xs hidden-sm">{{ 'mautic.project.add'|trans }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="page-list">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover {{ entityType }}-list mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-actions"></th>
|
||||
<th class="col-name col-lg-5">{{ 'mautic.core.name'|trans }}</th>
|
||||
<th class="visible-lg col-dateAdded">{{ 'mautic.lead.import.label.dateAdded'|trans }}</th>
|
||||
<th class="visible-lg col-dateModified">{{ 'mautic.lead.import.label.dateModified'|trans }}</th>
|
||||
<th class="visible-lg col-createdBy">{{ 'mautic.core.createdby'|trans }}</th>
|
||||
<th class="visible-md visible-lg col-id">{{ 'mautic.core.id'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entity in entityData.entities %}
|
||||
<tr id="row_{{ entityType }}_{{ entity.getId() }}">
|
||||
<td class="col-xs-1">
|
||||
{{- include('@MauticCore/Helper/list_actions.html.twig', {
|
||||
'item': entity,
|
||||
'templateButtons': {
|
||||
'edit': false,
|
||||
'clone': false,
|
||||
'delete': false
|
||||
},
|
||||
'customButtons': {
|
||||
'remove': {
|
||||
'confirm': {
|
||||
'btnClass': false,
|
||||
'btnText': 'mautic.project.remove_from_project'|trans,
|
||||
'message': 'mautic.project.form.confirm_remove'|trans({'%name%': entity.getName(), '%project%': project.getName()}),
|
||||
'confirmAction': path('mautic_project_action', {'objectAction': 'remove', 'objectId': project.getId(), 'entityType': entityType, 'entityId': entity.getId()}),
|
||||
'template': 'delete'
|
||||
},
|
||||
'iconClass': 'ri-subtract-line',
|
||||
'priority': 100
|
||||
}
|
||||
},
|
||||
}) -}}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ path('mautic_' ~ entityType ~ '_action', {'objectAction': 'view', 'objectId': entity.getId()}) }}" data-toggle="ajax">
|
||||
{{ entity.getName()|purify }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="visible-lg" title="{{ entity.getDateAdded() ? dateToFullConcat(entity.getDateAdded()) : '' }}">
|
||||
{{ entity.getDateAdded() ? dateToDate(entity.getDateAdded()) : '' }}
|
||||
</td>
|
||||
<td class="visible-lg" title="{{ entity.getDateModified() ? dateToFullConcat(entity.getDateModified()) : '' }}">
|
||||
{{ entity.getDateModified() ? dateToDate(entity.getDateModified()) : '' }}
|
||||
</td>
|
||||
<td class="visible-lg">
|
||||
{{ entity.getCreatedByUser() ? entity.getCreatedByUser()|escape : '' }}
|
||||
</td>
|
||||
<td class="visible-md visible-lg">
|
||||
<span class="text-muted">{{ entity.getId() }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{- include('@MauticCore/Helper/noresults.html.twig', {
|
||||
'header': 'mautic.project.no_assigned_entities.header',
|
||||
'message': 'mautic.project.no_assigned_entities.message'
|
||||
}) -}}
|
||||
{% endif %}
|
||||
@@ -0,0 +1 @@
|
||||
{{ form(form) }}
|
||||
@@ -0,0 +1,101 @@
|
||||
{% extends '@MauticCore/Default/content.html.twig' %}
|
||||
|
||||
{% block mauticContent %}project
|
||||
{% endblock %}
|
||||
|
||||
{% block preHeader %}
|
||||
{{ include('@MauticCore/Helper/page_actions.html.twig', {
|
||||
'item': project,
|
||||
'templateButtons': {
|
||||
'close': true,
|
||||
},
|
||||
'routeBase': 'project',
|
||||
'langVar': 'project',
|
||||
'targetLabel': 'mautic.project.projects'|trans
|
||||
}) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block headerTitle %}
|
||||
{{ project.getName() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
{{ include('@MauticCore/Helper/page_actions.html.twig', {
|
||||
'item': project,
|
||||
'customButtons': [
|
||||
{
|
||||
'attr': {
|
||||
'data-toggle': 'ajaxmodal',
|
||||
'data-target': '#MauticSharedModal',
|
||||
'data-header': 'mautic.project.add_entity'|trans,
|
||||
'href': path('mautic_project_action', {'objectAction': 'selectEntityType', 'objectId': project.getId()})
|
||||
},
|
||||
'iconClass': 'ri-add-line',
|
||||
'btnText': 'mautic.project.add_entity',
|
||||
'primary': true
|
||||
}
|
||||
],
|
||||
'nameGetter': 'getName',
|
||||
'templateButtons': {
|
||||
'edit': securityIsGranted('project:project:edit'),
|
||||
'delete': securityIsGranted('project:project:delete'),
|
||||
},
|
||||
'routeBase': 'project'
|
||||
}) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="box-layout">
|
||||
<div class="col-md-12 height-auto">
|
||||
<div>
|
||||
<div class="pr-md pl-md pt-lg pb-lg">
|
||||
<div class="box-layout">
|
||||
<div class="col-xs-10">
|
||||
<div class="text-white dark-sm mb-0">{{ project.getDescription()|purify }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse" id="sms-details">
|
||||
<div class="pr-md pl-md pb-md">
|
||||
<div class="panel shd-none mb-0">
|
||||
<table class="table mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="20%">
|
||||
<span class="fw-b textTitle">{{ 'mautic.core.id'|trans }}</span>
|
||||
</td>
|
||||
<td>{{ project.getId() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hr-expand nm">
|
||||
<span data-toggle="tooltip" title="Detail">
|
||||
<a href="javascript:void(0)" class="arrow text-muted collapsed" data-toggle="collapse" data-target="#sms-details">
|
||||
<span class="caret"></span>
|
||||
{{ 'mautic.core.details'|trans }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Items Section -->
|
||||
<div id="project-items-section" class="mt-lg">
|
||||
<div class="pr-md pl-md pb-lg">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
{{ include('@MauticProject/Project/_entity_list.html.twig') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input name="entityId" id="entityId" type="hidden" value="{{ project.getId()|e }}"/>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends '@MauticCore/Default/content.html.twig' %}
|
||||
|
||||
{% block mauticContent 'project' %}
|
||||
|
||||
{% block headerTitle %}
|
||||
{% if form.vars.data.id %}
|
||||
{{ 'mautic.project.menu.edit'|trans({'%name%': entity.getName()}) }}
|
||||
{% else %}
|
||||
{{ 'mautic.project.menu.new'|trans }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form) }}
|
||||
|
||||
<div class="box-layout">
|
||||
<div class="col-md-9 height-auto">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<!-- start: tab-content -->
|
||||
<div class="tab-content pa-md">
|
||||
<div class="tab-pane fade in active bdr-w-0" id="details">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
{{ form_row(form.name) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
{{ form_row(form.description) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,173 @@
|
||||
{% set isIndex = 'index' == tmpl %}
|
||||
{% set tmpl = 'list' %}
|
||||
{% extends isIndex ? '@MauticCore/Default/content.html.twig' : '@MauticCore/Default/raw_output.html.twig' %}
|
||||
|
||||
{% block mauticContent 'projects' %}
|
||||
|
||||
{% block headerTitle 'project.menu.index'|trans %}
|
||||
|
||||
{% block content %}
|
||||
{% if isIndex %}
|
||||
<div id="page-list-wrapper" class="{% if items|length > 0 or searchValue is not empty %}panel {% endif %} panel-default">
|
||||
{{ include('@MauticCore/Helper/list_toolbar.html.twig', {
|
||||
'searchValue': searchValue,
|
||||
'action': currentRoute,
|
||||
'page_actions': {
|
||||
'templateButtons': {
|
||||
'new': permissions['project:project:create'],
|
||||
},
|
||||
'routeBase': 'project',
|
||||
'langVar' : 'project.list',
|
||||
},
|
||||
'bulk_actions': {
|
||||
'routeBase': 'project',
|
||||
'templateButtons': {
|
||||
'delete': permissions['project:project:delete'],
|
||||
},
|
||||
},
|
||||
'quickFilters': [
|
||||
{
|
||||
'search': 'mautic.core.searchcommand.ismine',
|
||||
'label': 'mautic.core.searchcommand.ismine.label',
|
||||
'tooltip': 'mautic.core.searchcommand.ismine.description',
|
||||
'icon': 'ri-user-line'
|
||||
}
|
||||
]
|
||||
}) }}
|
||||
<div class="page-list">
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
{% if items|length > 0 %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="projectsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ include('@MauticCore/Helper/tableheader.html.twig', {
|
||||
'checkall': true,
|
||||
'target': '#projectsTable',
|
||||
'langVar': 'project.project',
|
||||
'routeBase': 'project',
|
||||
'templateButtons': {
|
||||
'delete': permissions['project:project:delete']
|
||||
}
|
||||
}) }}
|
||||
|
||||
{{ include('@MauticCore/Helper/tableheader.html.twig', {
|
||||
'sessionVar': 'projects',
|
||||
'orderBy': 'p.name',
|
||||
'text': 'mautic.core.name',
|
||||
'class': 'col-project-name'
|
||||
}) }}
|
||||
|
||||
<th class="visible-md visible-lg col-project-entities-count">
|
||||
{{ 'mautic.project.list.thead.project.count'|trans }}
|
||||
</th>
|
||||
|
||||
{{ include('@MauticCore/Helper/tableheader.html.twig', {
|
||||
'sessionVar': 'projects',
|
||||
'orderBy': 'p.dateAdded',
|
||||
'text': 'mautic.core.date.added',
|
||||
'class': 'col-project-date-added'
|
||||
}) }}
|
||||
|
||||
{{ include('@MauticCore/Helper/tableheader.html.twig', {
|
||||
'sessionVar': 'projects',
|
||||
'orderBy': 'p.dateModified',
|
||||
'text': 'mautic.core.date.modified',
|
||||
'class': 'col-project-date-modified'
|
||||
}) }}
|
||||
|
||||
{{ include('@MauticCore/Helper/tableheader.html.twig', {
|
||||
'sessionVar': 'projects',
|
||||
'orderBy': 'p.id',
|
||||
'text': 'mautic.core.id',
|
||||
'class': 'visible-md visible-lg col-project-id'
|
||||
}) }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ include('@MauticCore/Helper/list_actions.html.twig', {
|
||||
'item': item,
|
||||
'templateButtons': {
|
||||
'edit': permissions['project:project:edit'],
|
||||
'delete': permissions['project:project:delete']
|
||||
},
|
||||
'routeBase': 'project',
|
||||
'langVar': 'project',
|
||||
'nameGetter': 'getName'
|
||||
}) }}
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
{% if permissions['project:project:edit'] %}
|
||||
<a href="{{ path('mautic_project_action', {'objectAction': 'view', 'objectId': item.getId()}) }}" data-toggle="ajax">
|
||||
{{ item.getName()|e }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ item.getName() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.getDescription() %}
|
||||
<div class="text-muted mt-4">
|
||||
<small>{{ item.getDescription()|purify }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="visible-md visible-lg">
|
||||
<a href="{{ path('mautic_project_action', {'objectAction': 'view', 'objectId': item.getId()}) }}" data-toggle="ajax" data-menu-link="mautic_project_index" size="sm" class="label label-gray" {% if 0 == item.entitiesCount|default(0) %}disabled="disabled"{% endif %}>
|
||||
{{- 'mautic.project.entity.count'|trans({'%count%': item.entitiesCount|default(0)}) -}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<abbr title="{{ dateToFull(item.getDateAdded) }}">
|
||||
{{ dateToText(item.getDateAdded) }}
|
||||
</abbr>
|
||||
</td>
|
||||
<td>
|
||||
<abbr title="{{ dateToFull(item.getDateModified) }}">
|
||||
{{ dateToText(item.getDateModified) }}
|
||||
</abbr>
|
||||
</td>
|
||||
<td class="visible-md visible-lg">{{ item.getId() }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{{ include('@MauticCore/Helper/pagination.html.twig', {
|
||||
'totalItems': items|length,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'baseUrl': path('mautic_project_index'),
|
||||
'sessionVar': 'project'
|
||||
}) }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if searchValue is not empty %}
|
||||
{{ include('@MauticCore/Helper/noresults.html.twig') }}
|
||||
{% else %}
|
||||
<div class="mt-80 col-md-offset-2 col-lg-offset-3 col-md-8 col-lg-5 height-auto">
|
||||
{% set childContainer %}
|
||||
<div class="mt-32 mb-md">
|
||||
{% include '@MauticCore/Components/pictogram.html.twig' with {
|
||||
'pictogram': 'asset-management',
|
||||
'size': '80'
|
||||
} %}
|
||||
</div>
|
||||
{% endset %}
|
||||
{{ include('@MauticCore/Components/content-block.html.twig', {
|
||||
heading: 'mautic.project.contentblock.heading'|trans,
|
||||
subheading: 'mautic.project.contentblock.subheading'|trans,
|
||||
copy: 'mautic.project.contentblock.copy'|trans,
|
||||
childContainer: childContainer,
|
||||
}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-lg">
|
||||
<p class="text-muted">{{ 'mautic.project.select_entity_type.description'|trans }}</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for entityType, entityData in entityTypes %}
|
||||
<div class="col-md-6 mb-sm">
|
||||
{% include '@MauticCore/Components/tile.html.twig' with {
|
||||
type: 'clickable',
|
||||
content: '<div class="d-flex jc-space-between ai-center"><div class="tile-title">' ~ entityData.label|trans ~ '</div></div>',
|
||||
href: path('mautic_project_action', {'objectAction': 'addEntity', 'objectId': project.getId(), 'entityType': entityType}),
|
||||
icon: 'ri-add-line',
|
||||
attributes: {
|
||||
'data-toggle': 'ajaxmodal',
|
||||
'data-target': '#MauticSharedModal',
|
||||
'data-header': entityData.label|trans
|
||||
}
|
||||
} %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Security\Permissions;
|
||||
|
||||
use Mautic\CoreBundle\Security\Permissions\AbstractPermissions;
|
||||
use Mautic\UserBundle\Form\Type\PermissionListType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final class ProjectPermissions extends AbstractPermissions
|
||||
{
|
||||
private const PERMISSION_BASE = 'project:project';
|
||||
public const CAN_VIEW = self::PERMISSION_BASE.':view';
|
||||
public const CAN_EDIT = self::PERMISSION_BASE.':edit';
|
||||
public const CAN_CREATE = self::PERMISSION_BASE.':create';
|
||||
public const CAN_DELETE = self::PERMISSION_BASE.':delete';
|
||||
public const CAN_ASSOCIATE = self::PERMISSION_BASE.':associate';
|
||||
|
||||
/**
|
||||
* @param mixed[] $params
|
||||
*/
|
||||
public function __construct($params)
|
||||
{
|
||||
parent::__construct($params);
|
||||
|
||||
$this->addStandardPermissions([$this->getName()], false);
|
||||
|
||||
// Add the associate permission directly to the permissions array
|
||||
$this->permissions[$this->getName()]['associate'] = 8;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'project';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $options
|
||||
* @param mixed[] $data
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface &$builder, array $options, array $data): void
|
||||
{
|
||||
$builder->add(
|
||||
self::PERMISSION_BASE,
|
||||
PermissionListType::class,
|
||||
[
|
||||
'choices' => [
|
||||
'mautic.core.permissions.view' => 'view',
|
||||
'mautic.core.permissions.associate' => 'associate',
|
||||
'mautic.core.permissions.edit' => 'edit',
|
||||
'mautic.core.permissions.create' => 'create',
|
||||
'mautic.core.permissions.delete' => 'delete',
|
||||
'mautic.core.permissions.full' => 'full',
|
||||
],
|
||||
'label' => 'mautic.project.permissions.project',
|
||||
'bundle' => $this->getName(),
|
||||
'level' => $this->getName(),
|
||||
'data' => (!empty($data[$this->getName()]) ? $data[$this->getName()] : []),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping\ClassMetadataInfo;
|
||||
use Mautic\CoreBundle\Factory\ModelFactory;
|
||||
use Mautic\CoreBundle\Model\FormModel;
|
||||
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
||||
use Mautic\ProjectBundle\DTO\EntityTypeConfig;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Event\EntityTypeModelMappingEvent;
|
||||
use Mautic\ProjectBundle\Event\EntityTypeNormalizationEvent;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class ProjectEntityLoaderService
|
||||
{
|
||||
/** @var array<string, EntityTypeConfig> */
|
||||
private array $entityTypesCache = [];
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private TranslatorInterface $translator,
|
||||
private ModelFactory $modelFactory,
|
||||
private CorePermissions $security,
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, EntityTypeConfig> $entityTypes
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function getProjectEntities(Project $project, array $entityTypes): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($entityTypes as $entityType => $config) {
|
||||
$repository = $config->model->getRepository();
|
||||
|
||||
$entities = $repository->createQueryBuilder('e')
|
||||
->join('e.projects', 'p')
|
||||
->where('p.id = :projectId')
|
||||
->setParameter('projectId', $project->getId())
|
||||
->orderBy('e.dateModified', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$results[$entityType] = [
|
||||
'label' => $config->label,
|
||||
'entities' => $entities,
|
||||
'count' => count($entities),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity types filtered by user view permissions.
|
||||
*
|
||||
* @return array<string, EntityTypeConfig>
|
||||
*/
|
||||
public function getEntityTypesWithViewPermissions(): array
|
||||
{
|
||||
return $this->filterEntityTypesByPermission('view');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity types filtered by user edit permissions.
|
||||
*
|
||||
* @return array<string, EntityTypeConfig>
|
||||
*/
|
||||
public function getEntityTypesWithEditPermissions(): array
|
||||
{
|
||||
return $this->filterEntityTypesByPermission('edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lookup results for entity type (used by EntityLookupType).
|
||||
*
|
||||
* @return array<int|string, string>
|
||||
*/
|
||||
public function getLookupResults(string $entityType, string $filter = '', int $limit = 10, int $start = 0, ?int $projectId = null): array
|
||||
{
|
||||
$entityTypes = $this->getEntityTypes();
|
||||
|
||||
if (!isset($entityTypes[$entityType])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entityConfig = $entityTypes[$entityType];
|
||||
|
||||
// Check permission before proceeding
|
||||
if (!$this->hasViewPermissionForEntityType($entityConfig)) {
|
||||
return [];
|
||||
}
|
||||
$repository = $entityConfig->model->getRepository();
|
||||
|
||||
// Get the label column for this entity type
|
||||
$labelColumn = $this->getEntityLabelColumn($entityType);
|
||||
|
||||
$qb = $repository->createQueryBuilder('e')
|
||||
->select('e.id, e.'.$labelColumn.' as name')
|
||||
->setFirstResult($start);
|
||||
|
||||
if ($limit > 0) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
|
||||
// Exclude entities already assigned to the specific project if projectId is provided
|
||||
if ($projectId) {
|
||||
// Use LEFT JOIN to find entities that are NOT in the specific project
|
||||
$qb->leftJoin('e.projects', 'p', 'WITH', 'p.id = :projectId')
|
||||
->andWhere('p.id IS NULL')
|
||||
->setParameter('projectId', $projectId);
|
||||
} else {
|
||||
// Exclude entities already assigned to any project using QueryBuilder
|
||||
// Use LEFT JOIN to find entities that are NOT in any project
|
||||
$qb->leftJoin('e.projects', 'p')
|
||||
->andWhere('p.id IS NULL');
|
||||
}
|
||||
|
||||
// Add filter if provided
|
||||
if (!empty($filter)) {
|
||||
$qb->andWhere('e.'.$labelColumn.' LIKE :filter')
|
||||
->setParameter('filter', '%'.$filter.'%');
|
||||
}
|
||||
|
||||
$results = $qb->getQuery()->getArrayResult();
|
||||
|
||||
// Format results for ProjectModel (id => name associative array)
|
||||
$choices = [];
|
||||
foreach ($results as $result) {
|
||||
$choices[$result['id']] = $result['name'];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has view permission for entity type config.
|
||||
*/
|
||||
public function hasViewPermissionForEntityType(EntityTypeConfig $config): bool
|
||||
{
|
||||
$permissionBase = $config->model->getPermissionBase();
|
||||
$permissions = [
|
||||
$permissionBase.':viewown',
|
||||
$permissionBase.':viewother',
|
||||
];
|
||||
|
||||
return $this->security->isGranted($permissions, 'MATCH_ONE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has edit permission for entity type config.
|
||||
*/
|
||||
public function hasEditPermissionForEntityType(EntityTypeConfig $config): bool
|
||||
{
|
||||
$permissionBase = $config->model->getPermissionBase();
|
||||
$permissions = [
|
||||
$permissionBase.':editown',
|
||||
$permissionBase.':editother',
|
||||
];
|
||||
|
||||
return $this->security->isGranted($permissions, 'MATCH_ONE');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, EntityTypeConfig>
|
||||
*/
|
||||
private function getEntityTypes(): array
|
||||
{
|
||||
if (!empty($this->entityTypesCache)) {
|
||||
return $this->entityTypesCache;
|
||||
}
|
||||
|
||||
$allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
|
||||
|
||||
foreach ($allMetadata as $metadata) {
|
||||
$entityClass = $metadata->getName();
|
||||
|
||||
foreach ($metadata->getAssociationMappings() as $association) {
|
||||
if (
|
||||
ClassMetadataInfo::MANY_TO_MANY === $association['type']
|
||||
&& Project::class === $association['targetEntity']
|
||||
) {
|
||||
$shortName = $metadata->getReflectionClass()->getShortName();
|
||||
$entityType = $this->normalizeEntityType(strtolower($shortName));
|
||||
|
||||
$this->entityTypesCache[$entityType] = new EntityTypeConfig(
|
||||
entityClass: $entityClass,
|
||||
label: $this->getEntityLabel($entityType),
|
||||
model: $this->findModelForEntityType($entityType),
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort entity types alphabetically by label
|
||||
uasort($this->entityTypesCache, fn (EntityTypeConfig $a, EntityTypeConfig $b) => strcasecmp($a->label, $b->label));
|
||||
|
||||
return $this->entityTypesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter entity types by permission type.
|
||||
*
|
||||
* @return array<string, EntityTypeConfig>
|
||||
*/
|
||||
private function filterEntityTypesByPermission(string $permissionType): array
|
||||
{
|
||||
$allEntityTypes = $this->getEntityTypes();
|
||||
$allowedEntityTypes = [];
|
||||
|
||||
foreach ($allEntityTypes as $entityType => $config) {
|
||||
$hasPermission = 'view' === $permissionType
|
||||
? $this->hasViewPermissionForEntityType($config)
|
||||
: $this->hasEditPermissionForEntityType($config);
|
||||
|
||||
if ($hasPermission) {
|
||||
$allowedEntityTypes[$entityType] = $config;
|
||||
}
|
||||
}
|
||||
|
||||
return $allowedEntityTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the label column name for an entity type.
|
||||
*/
|
||||
private function getEntityLabelColumn(string $entityType): string
|
||||
{
|
||||
return match ($entityType) {
|
||||
'asset', 'page' => 'title',
|
||||
default => 'name',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize entity type names for consistent usage.
|
||||
*/
|
||||
private function normalizeEntityType(string $entityType): string
|
||||
{
|
||||
// Create event with default mappings
|
||||
$event = new EntityTypeNormalizationEvent([
|
||||
'leadlist' => 'segment',
|
||||
'lead' => 'contact',
|
||||
'dynamiccontent' => 'dynamicContent',
|
||||
'trigger' => 'pointtrigger',
|
||||
]);
|
||||
|
||||
// Dispatch event to allow bundles to add their mappings
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
// Return normalized type or original if no mapping exists
|
||||
return $event->getNormalizedType($entityType);
|
||||
}
|
||||
|
||||
private function getEntityLabel(string $entityType): string
|
||||
{
|
||||
// Create the translation key
|
||||
$translationKeyString = "mautic.{$entityType}.{$entityType}";
|
||||
|
||||
// Get the translation
|
||||
$translated = $this->translator->trans($translationKeyString);
|
||||
|
||||
// If translation doesn't exist (returns the key itself), return capitalized entity type
|
||||
if ($translated === $translationKeyString) {
|
||||
return ucfirst($entityType);
|
||||
}
|
||||
|
||||
// Return the actual translation
|
||||
return $translated;
|
||||
}
|
||||
|
||||
private function findModelForEntityType(string $entityType): FormModel
|
||||
{
|
||||
// Create event with default model key mappings
|
||||
$event = new EntityTypeModelMappingEvent([
|
||||
'segment' => 'lead.list',
|
||||
'message' => 'channel.message',
|
||||
'company' => 'lead.company',
|
||||
'dynamicContent' => 'dynamicContent.dynamicContent',
|
||||
'pointtrigger' => 'point.trigger',
|
||||
]);
|
||||
|
||||
// Dispatch event to allow bundles to add their model mappings
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
// Get model key from event or use entity type as fallback
|
||||
$modelKey = $event->getModelKey($entityType);
|
||||
|
||||
$model = $this->modelFactory->getModel($modelKey);
|
||||
\assert($model instanceof FormModel);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Tests\Functional;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* This class should simplify writing functional tests for project search functionality on various entities.
|
||||
*/
|
||||
abstract class AbstractProjectSearchTestCase extends MauticMysqlTestCase
|
||||
{
|
||||
/**
|
||||
* @param string[] $expectedEntities
|
||||
* @param string[] $unexpectedEntities
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('searchDataProvider')]
|
||||
abstract public function testProjectSearch(string $searchTerm, array $expectedEntities, array $unexpectedEntities): void;
|
||||
|
||||
/**
|
||||
* @return \Generator<string, array{searchTerm: string, expectedEntities: array<string>, unexpectedEntities: array<string>}>
|
||||
*/
|
||||
abstract public static function searchDataProvider(): \Generator;
|
||||
|
||||
/**
|
||||
* Test and assert API as well as UI.
|
||||
*
|
||||
* @param string[] $expectedEntities
|
||||
* @param string[] $unexpectedEntities
|
||||
* @param string[] $routes
|
||||
*/
|
||||
protected function searchAndAssert(string $searchTerm, array $expectedEntities, array $unexpectedEntities, array $routes): void
|
||||
{
|
||||
foreach ($routes as $route) {
|
||||
$crawler = $this->client->request(Request::METHOD_GET, $route.'?search='.urlencode($searchTerm));
|
||||
$this->assertResponseIsSuccessful();
|
||||
$isApiRequest = str_starts_with($route, '/api/');
|
||||
|
||||
$content = $isApiRequest ? $this->client->getResponse()->getContent() : $crawler->filter('body')->text();
|
||||
|
||||
foreach ($expectedEntities as $expectedEntity) {
|
||||
Assert::assertStringContainsString($expectedEntity, $content);
|
||||
}
|
||||
|
||||
foreach ($unexpectedEntities as $unexpectedEntity) {
|
||||
Assert::assertStringNotContainsString($unexpectedEntity, $content);
|
||||
}
|
||||
|
||||
if ($isApiRequest) {
|
||||
Assert::assertJson($content, 'API response should be of type JSON.');
|
||||
$this->assertProjectDataInApiResponse(json_decode($content, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function createProject(string $name): Project
|
||||
{
|
||||
$project = new Project();
|
||||
$project->setName($name);
|
||||
$this->em->persist($project);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $data
|
||||
*/
|
||||
private function assertProjectDataInApiResponse(array $data): void
|
||||
{
|
||||
$projectData = $this->getProjectData($data);
|
||||
|
||||
if (null === $projectData) {
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::assertEqualsCanonicalizing(['id', 'name'], array_keys(reset($projectData)),
|
||||
'Project data should contain only "id" and "name".');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $data
|
||||
*
|
||||
* @return mixed[]|null
|
||||
*/
|
||||
private function getProjectData(array $data): ?array
|
||||
{
|
||||
foreach ($data as $key => $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('projects' === $key && $item) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
$projectData = $this->getProjectData($item);
|
||||
|
||||
if (null !== $projectData) {
|
||||
return $projectData;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Tests\Functional\Controller;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Model\ProjectModel;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
final class AjaxControllerTest extends MauticMysqlTestCase
|
||||
{
|
||||
public function testCreatingProjectViaMultiselectInput(): void
|
||||
{
|
||||
$projectNames = [
|
||||
'Yellow Project',
|
||||
'Blue Project',
|
||||
'Red Project',
|
||||
];
|
||||
|
||||
/** @var ProjectModel $projectModel */
|
||||
$projectModel = self::getContainer()->get(ProjectModel::class);
|
||||
|
||||
$projects = array_map(
|
||||
static function (string $projectName) use ($projectModel) {
|
||||
$project = new Project();
|
||||
$project->setName($projectName);
|
||||
$projectModel->saveEntity($project);
|
||||
|
||||
return $project;
|
||||
},
|
||||
$projectNames
|
||||
);
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
'/s/ajax?action=project:addProjects',
|
||||
[
|
||||
'newProjectNames' => json_encode(['Green Project']),
|
||||
'existingProjectIds' => json_encode([$projects[0]->getId(), $projects[1]->getId()]),
|
||||
]
|
||||
);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$payload = json_decode($this->client->getResponse()->getContent(), true);
|
||||
|
||||
Assert::assertArrayHasKey('projects', $payload);
|
||||
|
||||
// The options are orderec alphabetically by name.
|
||||
Assert::assertSame(
|
||||
// The Blue Project is selected as it was sent as part of the existingProjectIds.
|
||||
'<option selected="selected" value="'.$projects[1]->getId().'">'.$projects[1]->getName().'</option>'.
|
||||
// The Green Project is selected as it was sent as part of the newProjectNames and should have next ID as it was created as 4th.
|
||||
'<option selected="selected" value="'.($projects[2]->getId() + 1).'">Green Project</option>'.
|
||||
// The Red Project is NOT selected as it was not sent in the AJAX request but it is listed as unselected option.
|
||||
'<option value="'.$projects[2]->getId().'">'.$projects[2]->getName().'</option>'.
|
||||
// The Yellow Project is selected as it was sent as part of the existingProjectIds.
|
||||
'<option selected="selected" value="'.$projects[0]->getId().'">'.$projects[0]->getName().'</option>',
|
||||
$payload['projects']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Tests\Functional\Controller;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\EmailBundle\Entity\Email;
|
||||
use Mautic\EmailBundle\Model\EmailModel;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Model\ProjectModel;
|
||||
|
||||
final class ProjectAddEntityTest extends MauticMysqlTestCase
|
||||
{
|
||||
private Project $testProject;
|
||||
private Email $testEmail;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
/** @var ProjectModel $projectModel */
|
||||
$projectModel = self::getContainer()->get(ProjectModel::class);
|
||||
|
||||
// Create test project
|
||||
$this->testProject = new Project();
|
||||
$this->testProject->setName('Test Project for Add Entity');
|
||||
$this->testProject->setDescription('Test project for functional testing');
|
||||
$projectModel->saveEntity($this->testProject);
|
||||
|
||||
// Create test email
|
||||
/** @var EmailModel $emailModel */
|
||||
$emailModel = self::getContainer()->get(EmailModel::class);
|
||||
$this->testEmail = new Email();
|
||||
$this->testEmail->setName('Test Email for Project');
|
||||
$this->testEmail->setSubject('Test Email Subject');
|
||||
$this->testEmail->setEmailType('template');
|
||||
$this->testEmail->setTemplate('blank');
|
||||
$emailModel->saveEntity($this->testEmail);
|
||||
}
|
||||
|
||||
public function testSelectEntityTypeActionRendersModal(): void
|
||||
{
|
||||
$url = '/s/projects/selectEntityType/'.$this->testProject->getId();
|
||||
|
||||
$this->client->request('GET', $url);
|
||||
$response = $this->client->getResponse();
|
||||
$content = $response->getContent();
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$this->assertStringContainsString('entityType=email', $content);
|
||||
}
|
||||
|
||||
public function testSelectEntityTypeActionNotFound(): void
|
||||
{
|
||||
$this->client->followRedirects(false);
|
||||
$this->client->request('GET', '/s/projects/selectEntityType/99999');
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
$this->assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAddEntityActionGetRequest(): void
|
||||
{
|
||||
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
|
||||
|
||||
$this->client->request('GET', $url);
|
||||
$response = $this->client->getResponse();
|
||||
$content = $response->getContent();
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Should contain the form with proper structure
|
||||
$this->assertStringContainsString('name="project_add_entity"', $content);
|
||||
$this->assertStringContainsString('project_add_entity[entityType]', $content);
|
||||
$this->assertStringContainsString('project_add_entity[projectId]', $content);
|
||||
$this->assertStringContainsString('project_add_entity[entityIds][]', $content);
|
||||
}
|
||||
|
||||
public function testAddEntityActionPostWithValidData(): void
|
||||
{
|
||||
// Add email to project directly using the entity relationship
|
||||
$this->testEmail->addProject($this->testProject);
|
||||
$this->em->persist($this->testEmail);
|
||||
$this->em->flush();
|
||||
|
||||
// View project page to verify email was added
|
||||
$url = '/s/projects/view/'.$this->testProject->getId();
|
||||
$this->client->request('GET', $url);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
$content = $response->getContent();
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertStringContainsString($this->testProject->getName(), $content);
|
||||
$this->assertStringContainsString($this->testEmail->getName(), $content);
|
||||
}
|
||||
|
||||
public function testAddEntityActionPostWithEmptyData(): void
|
||||
{
|
||||
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
|
||||
|
||||
// Get the form
|
||||
$crawler = $this->client->request('GET', $url);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Submit form with no entities selected
|
||||
$form = $crawler->filter('form')->first()->form();
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testAddEntityActionPostWithCancelledForm(): void
|
||||
{
|
||||
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
|
||||
|
||||
// Get the form
|
||||
$crawler = $this->client->request('GET', $url);
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Submit form normally (simulating any button press)
|
||||
$form = $crawler->filter('form')->first()->form();
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testAddEntityActionWithInvalidEntityType(): void
|
||||
{
|
||||
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=invalid_type';
|
||||
|
||||
// Get request with invalid entity type should redirect with error
|
||||
$this->client->followRedirects();
|
||||
$this->client->request('GET', $url);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Check for error message in flashes
|
||||
$content = $response->getContent();
|
||||
$this->assertStringContainsString('Invalid entity type', $content);
|
||||
}
|
||||
|
||||
public function testAddEntityActionNotFound(): void
|
||||
{
|
||||
$this->client->followRedirects(false);
|
||||
$this->client->request('GET', '/s/projects/addEntity/99999?entityType=email');
|
||||
$response = $this->client->getResponse();
|
||||
|
||||
$this->assertSame(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAddEntityActionWithoutPermission(): void
|
||||
{
|
||||
$user = $this->createAndLoginUser();
|
||||
|
||||
$url = '/s/projects/addEntity/'.$this->testProject->getId().'?entityType=email';
|
||||
$this->client->request('GET', $url);
|
||||
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function createAndLoginUser(): \Mautic\UserBundle\Entity\User
|
||||
{
|
||||
// Create non-admin role
|
||||
$role = new \Mautic\UserBundle\Entity\Role();
|
||||
$role->setName('Test Role');
|
||||
$role->setIsAdmin(false);
|
||||
$this->em->persist($role);
|
||||
|
||||
// Create non-admin user
|
||||
$user = new \Mautic\UserBundle\Entity\User();
|
||||
$user->setFirstName('Test');
|
||||
$user->setLastName('User');
|
||||
$user->setUsername('testuser');
|
||||
$user->setEmail('test@example.com');
|
||||
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
|
||||
$user->setPassword($hasher->hash('password'));
|
||||
$user->setRole($role);
|
||||
$this->em->persist($user);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$this->loginUser($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\ProjectBundle\Tests\Functional\Controller;
|
||||
|
||||
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
|
||||
use Mautic\LeadBundle\Entity\LeadList;
|
||||
use Mautic\ProjectBundle\Entity\Project;
|
||||
use Mautic\ProjectBundle\Entity\ProjectRepository;
|
||||
use Mautic\ProjectBundle\Model\ProjectModel;
|
||||
use Mautic\UserBundle\Entity\Role;
|
||||
use Mautic\UserBundle\Entity\User;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
|
||||
|
||||
final class ProjectControllerTest extends MauticMysqlTestCase
|
||||
{
|
||||
public const USERNAME = 'johny';
|
||||
|
||||
private ProjectRepository $projectRepository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$projects = [
|
||||
'project1',
|
||||
'project2',
|
||||
'project3',
|
||||
'project4',
|
||||
];
|
||||
|
||||
/** @var ProjectModel $projectModel */
|
||||
$projectModel = self::getContainer()->get(ProjectModel::class);
|
||||
$this->projectRepository = $projectModel->getRepository();
|
||||
|
||||
foreach ($projects as $projectName) {
|
||||
$project = new Project();
|
||||
$project->setName($projectName);
|
||||
$projectModel->saveEntity($project);
|
||||
}
|
||||
}
|
||||
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('indexUrlsProvider')]
|
||||
public function testIndexActionDisplaysProjects(string $url): void
|
||||
{
|
||||
$this->client->request('GET', $url);
|
||||
$clientResponse = $this->client->getResponse();
|
||||
$clientResponseContent = $clientResponse->getContent();
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertStringContainsString('project1', $clientResponseContent, 'The return must contain project1');
|
||||
$this->assertStringContainsString('project2', $clientResponseContent, 'The return must contain project2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array<int, string>>
|
||||
*/
|
||||
public static function indexUrlsProvider(): iterable
|
||||
{
|
||||
yield 'non-existent page nuber' => ['/s/projects/999'];
|
||||
yield 'main index page with no number (meaning page=1)' => ['/s/projects'];
|
||||
}
|
||||
|
||||
public function testIndexActionWhenFiltered(): void
|
||||
{
|
||||
$this->client->request('GET', '/s/projects?search=project1');
|
||||
$clientResponse = $this->client->getResponse();
|
||||
$clientResponseContent = $clientResponse->getContent();
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertStringContainsString('project1', $clientResponseContent, 'The return must contain project1');
|
||||
$this->assertStringNotContainsString('project2', $clientResponseContent, 'The return must not contain project2');
|
||||
}
|
||||
|
||||
public function testProjectDeletion(): void
|
||||
{
|
||||
$project = $this->projectRepository->findOneBy([]);
|
||||
$segment = new LeadList();
|
||||
$segment->setName('Test segment');
|
||||
$segment->setPublicName('Test segment');
|
||||
$segment->setAlias('test-segment');
|
||||
$segment->addProject($project);
|
||||
|
||||
$this->em->persist($segment);
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
$projectId = $project->getId();
|
||||
|
||||
$this->client->request('POST', '/s/projects/delete/'.$projectId);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertSame($this->projectRepository->find($projectId), null, 'Assert that project is deleted');
|
||||
$this->assertCount(0, $this->em->find(LeadList::class, $segment->getId())->getProjects());
|
||||
}
|
||||
|
||||
public function testViewAction(): void
|
||||
{
|
||||
$project = $this->projectRepository->findOneBy([]);
|
||||
|
||||
$this->client->request('GET', '/s/projects/view/'.$project->getId());
|
||||
$clientResponse = $this->client->getResponse();
|
||||
$clientResponseContent = $clientResponse->getContent();
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertStringContainsString($project->getName(), $clientResponseContent, 'The return must contain project');
|
||||
}
|
||||
|
||||
public function testViewActionNotFound(): void
|
||||
{
|
||||
$this->client->followRedirects(false);
|
||||
$this->client->request('GET', '/s/projects/view/99999');
|
||||
$clientResponse = $this->client->getResponse();
|
||||
$this->assertTrue($clientResponse->isRedirection(), 'Must be redirect response.');
|
||||
}
|
||||
|
||||
public function testEditAction(): void
|
||||
{
|
||||
$projectName = 'Test project';
|
||||
$project = $this->projectRepository->findOneBy([]);
|
||||
$crawler = $this->client->request('GET', '/s/projects/edit/'.$project->getId());
|
||||
$clientResponse = $this->client->getResponse();
|
||||
$clientResponseContent = $clientResponse->getContent();
|
||||
$this->assertTrue($clientResponse->isOk(), 'Return code must be 200.');
|
||||
$this->assertStringContainsString('Edit project: '.$project->getName(), $clientResponseContent, 'The return must contain \'Edit project\' text');
|
||||
|
||||
$form = $crawler->selectButton('Save & Close')->form();
|
||||
$form['project_entity[name]']->setValue($projectName);
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertSame(1, $this->projectRepository->count(['name' => $projectName]));
|
||||
}
|
||||
|
||||
public function testEditActionNotFound(): void
|
||||
{
|
||||
$this->client->followRedirects(false);
|
||||
$this->client->request('GET', '/s/projects/edit/99999');
|
||||
$clientResponse = $this->client->getResponse();
|
||||
$this->assertTrue($clientResponse->isRedirection(), 'Must be redirect response.');
|
||||
}
|
||||
|
||||
public function testNewAction(): void
|
||||
{
|
||||
$projectName = 'Test project';
|
||||
$crawler = $this->client->request('GET', '/s/projects/new');
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('Save')->form();
|
||||
$form['project_entity[name]']->setValue($projectName);
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertSame(1, $this->projectRepository->count(['name' => $projectName]));
|
||||
}
|
||||
|
||||
public function testBatchDeleteAction(): void
|
||||
{
|
||||
$projects = $this->projectRepository->findAll();
|
||||
$projectsId = array_map(function (Project $project) {
|
||||
return $project->getId();
|
||||
}, $projects);
|
||||
$this->client->request('POST', '/s/projects/batchDelete?ids='.json_encode($projectsId));
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertEmpty($this->projectRepository->count([]), 'All projects must be deleted.');
|
||||
}
|
||||
|
||||
public function testEmptyProjectShouldThrowValidationError(): void
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/s/projects/new');
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$buttonCrawler = $crawler->selectButton('Save & Close');
|
||||
$form = $buttonCrawler->form();
|
||||
$form->setValues(['project_entity[name]' => '']);
|
||||
$this->client->submit($form);
|
||||
$this->assertResponseIsSuccessful();
|
||||
Assert::assertStringContainsString('A name is required.', $this->client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testEditProjectWithNoPermission(): void
|
||||
{
|
||||
$this->createAndLoginUser();
|
||||
$project = $this->projectRepository->findOneBy([]);
|
||||
$this->client->request(Request::METHOD_GET, '/s/projects/edit/'.$project->getId());
|
||||
$this->assertResponseStatusCodeSame(403, (string) $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
private function createAndLoginUser(): User
|
||||
{
|
||||
// Create non-admin role
|
||||
$role = $this->createRole();
|
||||
// Create non-admin user
|
||||
$user = $this->createUser($role);
|
||||
|
||||
$this->em->flush();
|
||||
$this->em->detach($role);
|
||||
|
||||
$this->loginUser($user);
|
||||
// $this->client->setServerParameter('PHP_AUTH_USER', self::USERNAME);
|
||||
// $this->client->setServerParameter('PHP_AUTH_PW', 'mautic');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function createRole(bool $isAdmin = false): Role
|
||||
{
|
||||
$role = new Role();
|
||||
$role->setName('Role');
|
||||
$role->setIsAdmin($isAdmin);
|
||||
|
||||
$this->em->persist($role);
|
||||
|
||||
return $role;
|
||||
}
|
||||
|
||||
private function createUser(Role $role): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setFirstName('Jhon');
|
||||
$user->setLastName('Doe');
|
||||
$user->setUsername(self::USERNAME);
|
||||
$user->setEmail('john.doe@email.com');
|
||||
$hasher = self::getContainer()->get('security.password_hasher_factory')->getPasswordHasher($user);
|
||||
\assert($hasher instanceof PasswordHasherInterface);
|
||||
$user->setPassword($hasher->hash('mautic'));
|
||||
$user->setRole($role);
|
||||
|
||||
$this->em->persist($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mautic.project.error.notfound="No project with an id of %id% was found!"
|
||||
mautic.project.notice.batch_deleted="Successfully deleted %count% projects!"
|
||||
mautic.project.error.cannotbedeleted="Only project without any contacts can be deleted."
|
||||
mautic.project.already.exists="<a href='%url%' data-toggle='ajax' data-menu-link='%menu_link%'><strong>%name%</strong></a> already exists!"
|
||||
mautic.project.notice.entities_added="%count% item(s) have been added to project '%project%'"
|
||||
mautic.project.notice.no_entities_added="No new items were added to the project"
|
||||
mautic.project.notice.item_removed="'%name%' has been removed from project '%project%'"
|
||||
mautic.project.error.invalid_entity_type="Invalid entity type '%type%'"
|
||||
@@ -0,0 +1,41 @@
|
||||
mautic.project.permissions.header="Project permissions"
|
||||
mautic.project.permissions.project="Project"
|
||||
project.menu.index="Projects"
|
||||
mautic.project.projects="Projects"
|
||||
mautic.project.header.index="Projects"
|
||||
mautic.project.lead.searchcommand.list="project"
|
||||
mautic.project.error.notfound="No project with an id of %id% was found!"
|
||||
mautic.project.menu.new="Create new project"
|
||||
mautic.project.menu.edit="Edit project: %name%"
|
||||
mautic.project.entity.count="{0} No Entities|{1} 1 Entity|]1,Inf[ %count% Entities"
|
||||
mautic.project.list.thead.project.count="# Entities"
|
||||
mautic.project.select_or_create="Select or type in a new project"
|
||||
mautic.project.select="Select a project"
|
||||
mautic.project.enter_to_create="Hit enter to create "
|
||||
mautic.project.searchcommand.name="project"
|
||||
mautic.project.form.confirmdelete="Are you sure you want to delete the project '%name%'? it will remove the references to this project from all the referenced entities. Please review before proceeding."
|
||||
mautic.core.permissions.associate="Associate with other entities"
|
||||
mautic.project.popover.updated.on="Updated on %date%"
|
||||
mautic.project.popover.view.details="View details"
|
||||
mautic.project.popover.edit="Edit"
|
||||
mautic.project.popover.footer.label="Project"
|
||||
mautic.project.associated_entities="Project entities"
|
||||
mautic.project.no_entities_found="No project entities found"
|
||||
mautic.project.no_assigned_entities.header="No assigned entities"
|
||||
mautic.project.no_assigned_entities.message="This project does not have any entities assigned to it yet."
|
||||
mautic.project.remove_from_project="Remove from project"
|
||||
mautic.project.form.confirm_remove="Are you sure you want to remove '%name%' from project '%project%'?"
|
||||
mautic.project.add_entity="Add Entities to Project"
|
||||
mautic.project.modal.add_entity_title="Add %type% to %project%"
|
||||
mautic.project.form.select_entities="Select entities to add to project"
|
||||
mautic.project.form.select_entity_placeholder="Choose a %type% to add..."
|
||||
mautic.project.form.loading_entities="Loading entities..."
|
||||
mautic.project.form.notes="Notes"
|
||||
mautic.project.form.notes_placeholder="Optional notes about this assignment..."
|
||||
mautic.project.form.add_to_project="Add to Project"
|
||||
mautic.project.add="Add new to project"
|
||||
mautic.project.select_entity_type.description="Choose the type of entity you want to add to this project."
|
||||
mautic.project.available_entity_types="Available Entity Types"
|
||||
mautic.project.contentblock.heading="Turn your marketing plans into action"
|
||||
mautic.project.contentblock.subheading="One goal. One folder. All your related resources."
|
||||
mautic.project.contentblock.copy="Projects are the simplest way to keep everything for a campaign in one spot. Streamline your workflow, manage items as a group, and see how all the pieces fit together."
|
||||
Reference in New Issue
Block a user