EasyCorp / EasyAdminBundle

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

ChoiceField with "Other, please specify" option #4607

Closed elvismdev closed 3 years ago

elvismdev commented 3 years ago

I need to create a form field with a set of choices with an extra text input which needs to be filled out if you choose the "Other, please specify" option:

Preferred communication method:
(*) Private messaging (WhatsApp, Signal, Telegram) 
( ) Email
( ) Other, please specify: [             ]

How can I accomplish this on EasyAdmin 3?

a-r-m-i-n commented 3 years ago

I think the easiest way is to provide an own FormType, which extends from ChoiceType, but allows additional values. And you need to extend the form widget template.

elvismdev commented 3 years ago

@a-r-m-i-n Thanks for you input. So, inspired by this answer in SO, this is a working solution that I have accomplished so far:

Peek 2021-08-29 18-29

Here my code:

CRUD Controller

// src/Controller/Admin/ServiceRequestCrudController.php
namespace App\Controller\Admin;

use App\Entity\ServiceRequest;
use App\Form\PreferredCommunicationMethodType;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\Field;

class ServiceRequestCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return ServiceRequest::class;
    }

    public function configureFields(string $pageName): iterable
    {
        return [
            // ...

            Field::new('preferredCommunicationMethod')
                ->setFormType(PreferredCommunicationMethodType::class)
                ->onlyOnForms(),

            // ...
        ];
    }

    // ...
}

Custom Form Type

// src/Form/PreferredCommunicationMethodType.php
namespace App\Form;

use App\Form\DataTransformer\ChoiceWithOtherTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;

class PreferredCommunicationMethodType extends AbstractType
{

    /**
     * @var array
     */
    private $choiceOptions = [
        'Private messaging (WhatsApp, Signal, Telegram)' => 'private_messaging',
        'Email' => 'email',
        'Other' => 'other',
    ];

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add(
                'choice',
                ChoiceType::class,
                [
                    'choices' => $this->choiceOptions,
                ]
            )
            ->add('other', TextType::class);

        $builder->addModelTransformer(
            new ChoiceWithOtherTransformer($this->choiceOptions)
        );
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(
            [
                // Configure your form options here
            ]
        );
    }
}

Data Transformer

// src/Form/DataTransformer/ChoiceWithOtherTransformer.php
namespace App\Form\DataTransformer;

use Symfony\Component\Form\DataTransformerInterface;

class ChoiceWithOtherTransformer implements DataTransformerInterface
{

    /**
     * @var array
     */
    private $choiceOptions;

    public function __construct(array $choiceOptions)
    {
        $this->choiceOptions = $choiceOptions;
    }

    public function transform($value)
    {
        if (null === $value) {
            return $value;
        }

        if (in_array($value, $this->choiceOptions)) {
            $value = [
                'choice' => $value,
                'other' => '',
            ];
        } else {
            $value = [
                'choice' => 'other',
                'other' => $value,
            ];
        }

        return $value;
    }

    public function reverseTransform($value)
    {
        if (isset($value['choice']) && $value['choice'] === 'other') {
            return $value['other'];
        }

        return $value['choice'];
    }
}

This does the job as I needed, but, what I'm not "liking", is that I'm defining the choice options inside my custom form type as you can see (in PreferredCommunicationMethodType.php).

Since I have other entity fields that would use this same choice with other field type approach, I feel I would like to make this more generic / decoupled, having my custom form type called ChoiceWithOtherType.php and have the options for the choice field to be defined in the CRUD controller and passed into ChoiceWithOtherType from there. e.g.

Field::new('preferredCommunicationMethod')
                ->setFormType(ChoiceWithOtherType::class)
                ->setOptions(
                    [
                        'Private messaging (WhatsApp, Signal, Telegram)' => 'private_messaging',
                        'Email' => 'email',
                        'Other' => 'other',
                    ]
                 )
                ->onlyOnForms()

At the same time, need to be able to pass these same options from ChoiceWithOtherType to ChoiceWithOtherTransformer to do the checks I'm doing in it's transform() method.

Could you advice what I might be missing, or doing wrong, in order to have this final solution as a more generic custom field type that I can re-use for other similar fields invoked from my CRUD controller?

Highly appreciated!

javiereguiluz commented 3 years ago

This is how I'd solve this, if you store this communication method in two different entity properties:

// ...
yield FormField::addPanel('Preferred Communication Method')
      ->setHelp('Choose one of the predefined method or type your own custom method');
yield ChoiceField::new('firstProperty')->setChoices('Private Messaging' => '...', 'Email' => '...')->setColumns(6);
yield TextField::new('secondProperty')->setColumns(6);

And then, in the entity, I'd validate that both methods are never selected:

class SomeEntity
{
    // ...

    #[Assert\Callback]
    public function validate(ExecutionContextInterface $context, $payload): void
    {
        if (null !== $this->firstProperty && null !== $this->secondProperty) {
            $context->buildViolation('Communication method must be either a built-in one or a custom one, but not both. Unset one of them.')
                ->addViolation();
        }
    }
}

If you are using a single entity property, then use an unmapped/virtual entity for the choice field:

// ...
yield FormField::addPanel('Preferred Communication Method')
      ->setHelp('Choose one of the predefined method or type your own custom method');
// this 'predefinedCommunicationMethod' property doesn't exist in the entity
yield ChoiceField::new('predefinedCommunicationMethod')->setChoices('Private Messaging' => '...', 'Email' => '...')->setColumns(6);
// this 'communicationMethod' property exists in the entity
yield TextField::new('communicationMethod')->setColumns(6);

And then, use one of the CRUD controller methods to update the value of the property (e.g. in persistEntity()).


Finally, my preferred solution would be this, which uses the "native HTML combobox" feature (see https://html.spec.whatwg.org/multipage/input.html#concept-input-list):

class SomeEntityCrudController extends AbstractCrudController
{
    // ...

    public function configureFields(string $pageName): iterable
    {
        // ...
        yield TextField::new('communicationMethod')
            ->addHtmlContentsToBody(<<<HTML
                <datalist id="built-in-communication-methods">
                  <option value="private_messaging">Private Messaging</option>
                  <option value="email">Email</option>
                </datalist>
            HTML
            )->setFormTypeOption('list', 'built-in-communication-methods');
    }
}
javiereguiluz commented 3 years ago

I'm closing this because several possible solutions were given. Thanks!

elvismdev commented 3 years ago

Gracias @javiereguiluz ! I finally decided to go with your first mentioned option. I might be overcomplicating too much myself and possibly introduce later conflicts trying to make it happen with a single custom field type for a single entity property.