EasyCorp / EasyAdminBundle

EasyAdmin is a fast, beautiful and modern admin generator for Symfony applications.
MIT License
4.08k stars 1.03k forks source link

Flash message when SQL request failed #4106

Closed meiyasan closed 3 years ago

meiyasan commented 3 years ago

Hello,

I have a "user" table in my database that is modified by admins using easyadmin3. Sometimes admins are wrongly filling forms, which results in a Doctrine Exception (e.g. NotNullConstraintViolationException)

I would like to flash an error message on the form page and keeping the previously provided information. Is that possible in the current implementation ? At this time I only see an ugly error 500 and can't find anything about it in the documentation.

parijke commented 3 years ago

Certainly, I did it in a subscriber.... don't know itf this is the right way but it works

<?php

namespace App\EventSubscribers;

use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Exception\EntityRemoveException;
use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class PDOExceptionSubscriber implements EventSubscriberInterface {

    private $session;
    /**
     * @var AdminContextProvider
     */
    private AdminContextProvider $adminContextProvider;
    /**
     * @var AdminUrlGenerator
     */
    private AdminUrlGenerator $adminUrlGenerator;

    public function __construct(
        SessionInterface $session,
        AdminContextProvider $adminContextProvider,
        AdminUrlGenerator $adminUrlGenerator
    ) {
        $this->session = $session;
        $this->adminContextProvider = $adminContextProvider;
        $this->adminUrlGenerator = $adminUrlGenerator;
    }

    public static function getSubscribedEvents() {
        return [
            KernelEvents::EXCEPTION => [
                'handlePDOException',
                ],
            ];
    }

    public function handlePDOException(ExceptionEvent $event) {

        $exception =  $event->getThrowable();
        $message = $exception->getMessage();

        if (!$exception instanceof EntityRemoveException) {
            return;
        }

        $context = $this->adminContextProvider->getContext()->getCrud();

        $redirectUrl = $this->adminUrlGenerator
            ->setAction(Action::INDEX)
            ->setController($context->getControllerFqcn());

        $this->session->getFlashBag()->add('warning', 'PDO Exception :'.$message);

        $event->setResponse(new RedirectResponse($redirectUrl));
    }
}
parijke commented 3 years ago

any feedback to this hobby programmer is highly appreciated :-)

meiyasan commented 3 years ago

@parijke Many thanks, Paul ! I am not familiar enough with Symfony yet to tell, but it works like a charm :)

xabbuh commented 3 years ago

Is there a reason not to use validation constraints to ensure that invalid entities are never flushed at all?

meiyasan commented 3 years ago

Well, indeed this might be better.. if I knew how to do it :) So far I couldn't find how to input these validation constraints in a CrudController.

Anyhow, the ExceptionSubscriber +/- some adjustment is still good when I want to handle an unexpected issue. I will try to figure out these validation constraints and perhaps provide some working example for someone who might pass by this place.

javiereguiluz commented 3 years ago

@xKZL I recommend to do what Christian said. Add as many validation constraints as needed in your user entities to avoid attempting to persist entities with wrong information. See https://symfony.com/doc/current/validation.html#constraints-in-form-classes for details. I'm closing this issue for that reason. Thanks!

meiyasan commented 3 years ago

I just drop here the code I have been working on, today. Perhaps it will might help someone later. Many thanks @xabbuh for the tips about the basic Validators.

The documentation/details you provides @javiereguiluz looks kind of poor to me, but thanks. (e.g. buildForm is not an object I am working on here using EA)

I think these ones are more interesting: https://symfony.com/doc/current/validation/groups.html https://github.com/EasyCorp/EasyAdminBundle/issues/3690

So here is an example related to CrudController objects.

# src/Entity/User.php
<?php

namespace App\Entity;
use App\EntityListener\UserEntityListener;
use App\Repository\UserRepository;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass=UserRepository::class)
 */
class User implements UserInterface
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=180, unique=true)
     */
    private $username;

   // [...]
<?php
# src/Controller/Dashboard/UserCrudController.php
namespace App\Controller\Dashboard;

use App\Entity\User;
use App\Field\PasswordField;
use App\Field\LinkIdField;

use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;

use EasyCorp\Bundle\EasyAdminBundle\Field\CountryField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;

use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;

class UserCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return User::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            //->showEntityActionsAsDropdown()
            ->setPageTitle('index', 'User Management')
            ->setDefaultSort(['id' => 'DESC'])
            ->setFormOptions(
                ['validation_groups' => ['new']], // Crud::PAGE_NEW
                ['validation_groups' => ['edit']] // Crud::PAGE_EDIT
            );
    }

The lines about $crud::setFormOptions() referring to "new" and "edit" are related to the validation_groups introduced in the user entity. I am only using basic Constraints, but I will design later a UserEntityValidator including DoctrineInterface https://stackoverflow.com/questions/16398714/symfony-validator-with-doctrine-query Although, here I have to figure out how to mension the validation group. Not a big deal, IMO.

In case the validation constrains were not robust enough, I can now quickly catch SQL exceptions without being throw to my custom ExceptionController that displays an error 500. At least it helps keeping focus and reduces frustration of being thrown out of the dashboard. (many thanks again @parijke, I just added a fix about infinite recursive redirections)

<?php

namespace App\EventSubscriber\Dashboard;

use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Exception\EntityRemoveException;
use EasyCorp\Bundle\EasyAdminBundle\Provider\AdminContextProvider;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class DashboardExceptionSubscriber implements EventSubscriberInterface {

    /**
     * @var SessionInterface
     */
    private $session;
    /**
     * @var AdminContextProvider
     */
    private $adminContextProvider;
    /**
     * @var AdminUrlGenerator
     */
    private $adminUrlGenerator;

    public function __construct(SessionInterface $session, AdminContextProvider $adminContextProvider, AdminUrlGenerator $adminUrlGenerator
    ) {
        $this->session = $session;
        $this->adminContextProvider = $adminContextProvider;
        $this->adminUrlGenerator = $adminUrlGenerator;
    }

    public static function getSubscribedEvents() {
        return [ KernelEvents::EXCEPTION => ['onKernelException'] ];
    }

    public function sendFlashPrimary  ($title = "", $message = "") { return $this->sendFlash("primary",   $title, $message); }
    public function sendFlashSecondary($title = "", $message = "") { return $this->sendFlash("secondary", $title, $message); }
    public function sendFlashDark     ($title = "", $message = "") { return $this->sendFlash("dark",      $title, $message); }
    public function sendFlashLight    ($title = "", $message = "") { return $this->sendFlash("light",     $title, $message); }
    public function sendFlashSuccess  ($title = "", $message = "") { return $this->sendFlash("success",   $title, $message); }
    public function sendFlashInfo     ($title = "", $message = "") { return $this->sendFlash("info",      $title, $message); }
    public function sendFlashNotice   ($title = "", $message = "") { return $this->sendFlash("notice",    $title, $message); }
    public function sendFlashWarning  ($title = "", $message = "") { return $this->sendFlash("warning",   $title, $message); }
    public function sendFlashDanger   ($title = "", $message = "") { return $this->sendFlash("danger",    $title, $message); }

    public function sendFlash($type, $title = "", $message = "")
    {
        if($title instanceof ExceptionEvent) {

            $event     = $title;
            $exception = $event->getThrowable();

            $title   = get_class($exception)."<br/>";
            $title  .= "(".$exception->getFile().":".$exception->getLine().")";

            $message = $exception->getMessage();
        }

        if(!empty($title)) $title = "<b>".$title."</b><br/>";
        if(!empty($title.$message))
            $this->session->getFlashBag()->add($type, $title.$message);
    }

    public function onKernelException(ExceptionEvent $event)
    {
        // Check if exception happened in EasyAdmin (avoid warning outside EA)
        if(!$this->adminContextProvider) return;
        if(!$this->adminContextProvider->getContext()) return;

        // Get back exception & send flash message
        $this->sendFlashDanger($event);

        // Get back crud information
        $crud       = $this->adminContextProvider->getContext()->getCrud();
        if(!$crud) return;

        $controller = $crud->getControllerFqcn();
        $action     = $crud->getCurrentPage();

        // Avoid infinite redirection
        // - If exception happened in "index", redirect to dashboard
        // - If exception happened in an other section, redirect to index page first
        // - If exception happened after submitting a form, just redirect to the initial page
        $url = $this->adminUrlGenerator->unsetAll();
        switch($action) {
            case "index": break;
            default:
                $url = $url->setController($controller);
                if(isset($_POST) && !empty($_POST)) $url = $url->setAction($action);
        }

        $event->setResponse(new RedirectResponse($url));
    }
}

This looks like perfectly solving my initial issue. Thanks guys!

xabbuh commented 3 years ago

Do you actually have different constraints for creating and editing entities? Otherwise you don't need to use validation groups which also means no extra configuration in your EasyAdmin forms.

meiyasan commented 3 years ago

yeah I do, like plainPassword can be empty while editing if I don't want to change it. do you have an idea how to handle this better ?

xabbuh commented 3 years ago

In this case, that's probably the best solution. 👍

parijke commented 3 years ago

Can I ask something? Maybe I am doing it totally wrong, but I want to prevent deletion of a Customer if it has Invoices. So I made that constaint in the database. Then, when trying to delete the cutomer having invoices, I get a PDO Exception which I catch using the subscriber method.

Is there an easier way to inform a user that he cannot delete Customers with Invoices?