diff --git a/app/src/Controller/Admin/AddRolesAminAction.php b/app/src/Controller/Admin/AddRolesAminAction.php new file mode 100644 index 00000000..4587988a --- /dev/null +++ b/app/src/Controller/Admin/AddRolesAminAction.php @@ -0,0 +1,39 @@ +execute( + $objectActionExecutioner, + $request, + AddRolesForm::class, + null, + function (User $user, array $data) use ($objectActionExecutioner): void { + $roles = $data['roles']; + + $missingRoles = array_diff($roles, $user->getRoles()); + + if (\count($missingRoles) > 0) { + $user->setRoles(array_merge($user->getRoles(), $missingRoles)); + $objectActionExecutioner->getAdmin()->update($user); + } else { + $objectActionExecutioner->skip('all-roles-already-set'); + } + } + ); + } +} diff --git a/app/src/Controller/Admin/MakeAdminAction.php b/app/src/Controller/Admin/MakeAdminAction.php index b5464a14..20782069 100644 --- a/app/src/Controller/Admin/MakeAdminAction.php +++ b/app/src/Controller/Admin/MakeAdminAction.php @@ -6,13 +6,11 @@ use Draw\Bundle\SonataExtraBundle\ActionableAdmin\Event\ExecutionErrorEvent; use Draw\Bundle\SonataExtraBundle\ActionableAdmin\ObjectActionExecutioner; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Notifier\NotifierInterface; class MakeAdminAction { public function __invoke( - ObjectActionExecutioner $objectActionExecutioner, - NotifierInterface $notifier + ObjectActionExecutioner $objectActionExecutioner ): Response { return $objectActionExecutioner ->execute( diff --git a/app/src/Entity/User.php b/app/src/Entity/User.php index b8b41e96..dc920cbc 100644 --- a/app/src/Entity/User.php +++ b/app/src/Entity/User.php @@ -204,7 +204,12 @@ public function getRoles(): array public function setRoles(array $roles): static { - $this->roles = $roles; + $this->roles = array_values( + array_filter( + $roles, + fn ($role) => \is_string($role) && '' !== $role && 'ROLE_USER' !== $role + ) + ); return $this; } diff --git a/app/src/Form/AddRolesForm.php b/app/src/Form/AddRolesForm.php new file mode 100644 index 00000000..34b49b82 --- /dev/null +++ b/app/src/Form/AddRolesForm.php @@ -0,0 +1,33 @@ +add( + 'roles', + ChoiceType::class, + [ + 'choices' => [ + 'ROLE_ADMIN' => 'ROLE_ADMIN', + 'ROLE_USER' => 'ROLE_USER', + ], + 'multiple' => true, + 'expanded' => true, + ] + ) + ->add( + 'submit', + SubmitType::class, + ['label' => 'submit'] + ); + } +} diff --git a/app/src/Sonata/Admin/UserAdmin.php b/app/src/Sonata/Admin/UserAdmin.php index 5fe29967..2d94de84 100644 --- a/app/src/Sonata/Admin/UserAdmin.php +++ b/app/src/Sonata/Admin/UserAdmin.php @@ -2,6 +2,7 @@ namespace App\Sonata\Admin; +use App\Controller\Admin\AddRolesAminAction; use App\Controller\Admin\MakeAdminAction; use App\Entity\Tag; use App\Entity\User; @@ -83,6 +84,7 @@ protected function configureListFields(ListMapper $list): void ->add('childObject2') ->add('userTags', 'list') ->add('tags', 'list') + ->add('roles', 'list') ->add('isLocked', 'boolean', ['inverse' => true]); } @@ -209,6 +211,9 @@ public function getActions(): array ->setController(MakeAdminAction::class) ->setIcon('fa fa-user-plus') ->setBatchController(MakeAdminAction::class), + 'addRoles' => (new AdminAction('addRoles', true)) + ->setController(AddRolesAminAction::class) + ->setBatchController(AddRolesAminAction::class), ]; } } diff --git a/packages/sonata-extra-bundle/ActionableAdmin/AdminActionLoader.php b/packages/sonata-extra-bundle/ActionableAdmin/AdminActionLoader.php new file mode 100644 index 00000000..62de35cf --- /dev/null +++ b/packages/sonata-extra-bundle/ActionableAdmin/AdminActionLoader.php @@ -0,0 +1,43 @@ + + */ + public function getActions(AdminInterface $admin): array + { + if (!\array_key_exists($admin->getCode(), $this->actions)) { + $actions = $admin instanceof ActionableAdminInterface + ? $admin->getActions() + : []; + + foreach ($admin->getExtensions() as $extension) { + if (!$extension instanceof ActionableAdminExtensionInterface) { + continue; + } + + $actions = $extension->getActions($actions); + } + + array_walk( + $actions, + function (AdminAction $adminAction) use ($admin): void { + // Set default translation domain + $adminAction->setTranslationDomain($adminAction->getTranslationDomain() ?? $admin->getTranslationDomain()); + } + ); + + $this->actions[$admin->getCode()] = $actions; + } + + return $this->actions[$admin->getCode()]; + } +} diff --git a/packages/sonata-extra-bundle/ActionableAdmin/ExecutionNotifier.php b/packages/sonata-extra-bundle/ActionableAdmin/ExecutionNotifier.php index f3a6f240..e0590ca9 100644 --- a/packages/sonata-extra-bundle/ActionableAdmin/ExecutionNotifier.php +++ b/packages/sonata-extra-bundle/ActionableAdmin/ExecutionNotifier.php @@ -14,7 +14,8 @@ class ExecutionNotifier implements ResetInterface public function __construct( private ?NotifierInterface $notifier, - private TranslatorInterface $translator + private TranslatorInterface $translator, + private AdminActionLoader $actionLoader, ) { } @@ -32,9 +33,9 @@ public function notifyExecution(ObjectActionExecutioner $objectActionExecutioner ), ]; - if ($objectActionExecutioner->isBatch()) { - $count = $objectActionExecutioner->getProcessedCount(); + $count = $objectActionExecutioner->getProcessedCount(); + if ($objectActionExecutioner->isBatch() || $count > 0) { $subject = $this->translator ->trans( 'execution.notification.processed', @@ -115,19 +116,17 @@ private function getActionLabel(AdminInterface $admin, string $action): string if (!isset($this->actionLabels[$adminCode][$action])) { $label = null; - if ($admin instanceof ActionableAdminInterface) { - foreach ($admin->getActions() as $adminAction) { - if ($adminAction->getName() === $action) { - $label = $this->translator - ->trans( - $adminAction->getLabel(), - [], - $adminAction->getTranslationDomain() - ); - break; - } - } + $adminAction = $this->actionLoader->getActions($admin)[$action] ?? null; + + if ($adminAction instanceof AdminAction) { + $label = $this->translator + ->trans( + $adminAction->getLabel(), + [], + $adminAction->getTranslationDomain() + ); } + $label ??= $action; $this->actionLabels[$adminCode][$action] = $label; diff --git a/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php b/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php index eff1551f..bcfa1e40 100644 --- a/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php +++ b/packages/sonata-extra-bundle/ActionableAdmin/Extension/ActionableAdminExtension.php @@ -2,8 +2,7 @@ namespace Draw\Bundle\SonataExtraBundle\ActionableAdmin\Extension; -use Draw\Bundle\SonataExtraBundle\ActionableAdmin\ActionableAdminInterface; -use Draw\Bundle\SonataExtraBundle\ActionableAdmin\AdminAction; +use Draw\Bundle\SonataExtraBundle\ActionableAdmin\AdminActionLoader; use Sonata\AdminBundle\Admin\AbstractAdminExtension; use Sonata\AdminBundle\Admin\AdminInterface; use Sonata\AdminBundle\Datagrid\ListMapper; @@ -19,11 +18,14 @@ )] class ActionableAdminExtension extends AbstractAdminExtension { - private array $actions = []; + public function __construct( + private AdminActionLoader $actionLoader + ) { + } public function configureRoutes(AdminInterface $admin, RouteCollectionInterface $collection): void { - foreach ($this->loadActions($admin) as $action) { + foreach ($this->actionLoader->getActions($admin) as $action) { $defaults = [ '_actionableAdmin' => [ 'action' => $action->getName(), @@ -51,7 +53,7 @@ public function getAccessMapping(AdminInterface $admin): array { $accessMapping = []; - foreach ($this->loadActions($admin) as $adminAction) { + foreach ($this->actionLoader->getActions($admin) as $adminAction) { $accessMapping[$adminAction->getName()] = $adminAction->getAccess(); } @@ -60,7 +62,7 @@ public function getAccessMapping(AdminInterface $admin): array public function configureBatchActions(AdminInterface $admin, array $actions): array { - foreach ($this->loadActions($admin) as $adminAction) { + foreach ($this->actionLoader->getActions($admin) as $adminAction) { if (!$batchController = $adminAction->getBatchController()) { continue; } @@ -81,7 +83,7 @@ public function configureBatchActions(AdminInterface $admin, array $actions): ar public function configureListFields(ListMapper $list): void { - foreach ($this->loadActions($list->getAdmin()) as $adminAction) { + foreach ($this->actionLoader->getActions($list->getAdmin()) as $adminAction) { if (!$adminAction->getForEntityListAction()) { continue; } @@ -115,7 +117,7 @@ public function configureActionButtons( string $action, ?object $object = null ): array { - foreach ($this->loadActions($admin) as $adminAction) { + foreach ($this->actionLoader->getActions($admin) as $adminAction) { if (!$adminAction->isForAction($action)) { continue; } @@ -141,36 +143,4 @@ public function configureActionButtons( return $list; } - - /** - * @return array - */ - private function loadActions(AdminInterface $admin): array - { - if (!\array_key_exists($admin->getCode(), $this->actions)) { - $actions = $admin instanceof ActionableAdminInterface - ? $admin->getActions() - : []; - - foreach ($admin->getExtensions() as $extension) { - if (!$extension instanceof ActionableAdminExtensionInterface) { - continue; - } - - $actions = $extension->getActions($actions); - } - - array_walk( - $actions, - function (AdminAction $adminAction) use ($admin): void { - // Set default translation domain - $adminAction->setTranslationDomain($adminAction->getTranslationDomain() ?? $admin->getTranslationDomain()); - } - ); - - $this->actions[$admin->getCode()] = $actions; - } - - return $this->actions[$admin->getCode()]; - } } diff --git a/packages/sonata-extra-bundle/ActionableAdmin/GenericFormHandler.php b/packages/sonata-extra-bundle/ActionableAdmin/GenericFormHandler.php new file mode 100644 index 00000000..769cd227 --- /dev/null +++ b/packages/sonata-extra-bundle/ActionableAdmin/GenericFormHandler.php @@ -0,0 +1,143 @@ + $executions] : $executions; + + $previousExecution = $executions['execution']; + + $executions['execution'] = function (object $object) use ($objectActionExecutioner, $previousExecution): void { + $previousExecution($object, $objectActionExecutioner->options['form.data']); + }; + + $form = $this->getForm($objectActionExecutioner, $formClass, $data, $request); + + $mayBeSubmitted = !$objectActionExecutioner->isBatch() || $request->request->has('fromGenericFormHandler'); + + $duplicatedRequest = $this->cleanRequest($request); + + if ($mayBeSubmitted) { + $form->handleRequest($duplicatedRequest); + } + + if (!$form->isSubmitted() || !$form->isValid()) { + $action = $objectActionExecutioner->getAction(); + $admin = $objectActionExecutioner->getAdmin(); + + return new Response( + $this->twig->render( + '@DrawSonataExtra/Action/generic_form.html.twig', + [ + 'admin' => $admin, + 'base_template' => $admin->getTemplateRegistry()->getTemplate('layout'), + 'form' => $form->createView(), + 'action' => $action, + 'adminAction' => $this->adminActionLoader->getActions($admin)[$action], + ] + ) + ); + } + + $objectActionExecutioner->options['form.data'] = $objectActionExecutioner->isBatch() + ? $form->get('form')->getData() + : $form->getData(); + + return $objectActionExecutioner + ->execute($executions); + } + + /** + * This is to remove parameters from sonata batch action. + */ + private function cleanRequest(Request $request): Request + { + $duplicatedRequest = $request->duplicate(); + foreach (['_sonata_csrf_token', 'filter', 'idx', 'action', 'all_elements'] as $key) { + $duplicatedRequest->request->remove($key); + } + + return $duplicatedRequest; + } + + private function getForm( + ObjectActionExecutioner $objectActionExecutioner, + string $formClass, + mixed $data, + Request $request, + ): FormInterface { + $action = $objectActionExecutioner->getAction(); + $admin = $objectActionExecutioner->getAdmin(); + + if (!$objectActionExecutioner->isBatch()) { + return $this->formFactory + ->createBuilder($formClass, $data) + ->setAction($admin->generateObjectUrl($action, $objectActionExecutioner->getSubject())) + ->getForm(); + } + + $formBuilder = $this->formFactory + ->createNamedBuilder('', data: ['form' => $data]) + ->add('fromGenericFormHandler', HiddenType::class, ['data' => true]) + ->add( + 'form', + $formClass, + ['label' => false] + ) + ->setAction($admin->generateUrl('batch', ['filter' => $admin->getFilterParameters()])) + ->add( + 'data', + HiddenType::class, + [ + 'data' => json_encode([ + 'idx' => $request->get('idx'), + 'all_elements' => $request->get('all_elements'), + 'action' => $action, + ]), + ] + ) + ->add( + 'confirmation', + HiddenType::class, + [ + 'data' => 'ok', + ] + ); + + if ($this->csrfTokenManager) { + $formBuilder->add( + '_sonata_csrf_token', + HiddenType::class, + [ + 'data' => $this->csrfTokenManager->getToken('sonata.batch')->getValue(), + ] + ); + } + + return $formBuilder->getForm(); + } +} diff --git a/packages/sonata-extra-bundle/ActionableAdmin/ObjectActionExecutioner.php b/packages/sonata-extra-bundle/ActionableAdmin/ObjectActionExecutioner.php index 930099c3..16ee6154 100644 --- a/packages/sonata-extra-bundle/ActionableAdmin/ObjectActionExecutioner.php +++ b/packages/sonata-extra-bundle/ActionableAdmin/ObjectActionExecutioner.php @@ -5,9 +5,11 @@ use Draw\Bundle\SonataExtraBundle\ActionableAdmin\Event\ExecutionErrorEvent; use Draw\Bundle\SonataExtraBundle\ActionableAdmin\Event\PostExecutionEvent; use Psr\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; use Sonata\AdminBundle\Admin\AdminInterface; use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery; use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; use Symfony\Component\HttpFoundation\Response; /** @@ -15,7 +17,21 @@ * * @template T of object */ -#[Autoconfigure(shared: false)] +#[ + Autoconfigure(shared: false), + AutoconfigureTag( + 'monolog.logger', + attributes: [ + 'channel' => 'sonata_admin', + ] + ), + AutoconfigureTag( + 'logger.decorate', + attributes: [ + 'message' => '[ObjectActionExecutioner] {message}', + ] + ) +] class ObjectActionExecutioner { private ?ProxyQuery $query = null; @@ -42,8 +58,10 @@ class ObjectActionExecutioner 'insufficient-access' => 0, ]; - public function __construct(private EventDispatcherInterface $eventDispatcher) - { + public function __construct( + private EventDispatcherInterface $eventDispatcher, + private ?LoggerInterface $logger + ) { } public function initialize(object $target, AdminInterface $admin, string $action): self @@ -184,6 +202,15 @@ public function execute( try { $response = $execution($object); } catch (\Throwable $error) { + $this->logger?->error( + 'An error occurred during the execution of {action}.', + [ + 'action' => $this->action, + 'error' => $error, + 'objectId' => $this->admin->id($object), + 'object' => $object::class, + ] + ); $this->skip('error'); $event = new ExecutionErrorEvent($error, $object, $this); diff --git a/packages/sonata-extra-bundle/Resources/translations/DrawSonataExtraBundle.en.yaml b/packages/sonata-extra-bundle/Resources/translations/DrawSonataExtraBundle.en.yaml index 17e578f0..38d6c52b 100644 --- a/packages/sonata-extra-bundle/Resources/translations/DrawSonataExtraBundle.en.yaml +++ b/packages/sonata-extra-bundle/Resources/translations/DrawSonataExtraBundle.en.yaml @@ -1,4 +1,4 @@ -execution.notification.error: "An error has occurred during execution of [%action%] for element [%object%]:
%error%" +execution.notification.error: "An error has occurred during execution of [%action%] for element [%object%]:
%error%" execution.notification.processed: "{0} During [%action%] execution, [%count%/%total%] element were processed.|{1} During [%action%] execution, [%count%/%total%] element was processed.|[2,Inf[ During [%action%] execution, [%count%/%total%] elements were processed." execution.notification.skipped: "{1} During [%action%] execution, [%count%/%total%] element was skipped due to [%reason%].|[2,Inf[ During [%action%] execution, [%count%/%total%] elements were skipped due to [%reason%]." diff --git a/packages/sonata-extra-bundle/Resources/views/Action/generic_form.html.twig b/packages/sonata-extra-bundle/Resources/views/Action/generic_form.html.twig new file mode 100644 index 00000000..0c232dac --- /dev/null +++ b/packages/sonata-extra-bundle/Resources/views/Action/generic_form.html.twig @@ -0,0 +1,33 @@ +{% extends base_template %} + +{%- block actions -%} + {% include '@SonataAdmin/CRUD/action_buttons.html.twig' %} +{%- endblock -%} + +{%- block tab_menu -%} + {{ knp_menu_render(admin.sidemenu(action), { + 'currentClass': 'active', + 'template': get_global_template('tab_menu_template') + }, 'twig') }} +{%- endblock -%} + +{% block sonata_admin_content %} +
+
+
+
+

+ {{ adminAction|translate_label }} +

+
+
+
+ {{ form_start(form) }} + {{ form_errors(form) }} + {{ form_end(form) }} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 5472e028..41bfc39b 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1 +1,2 @@ makeAdmin: 'Make Admin' +addRoles: 'Add Roles'