Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Turbo & Stimulus Powered JavaScript #6051

Closed
weaverryan opened this issue Dec 4, 2023 · 10 comments
Closed

Turbo & Stimulus Powered JavaScript #6051

weaverryan opened this issue Dec 4, 2023 · 10 comments

Comments

@weaverryan
Copy link
Contributor

Short description of what this feature will allow to do:
Hi everyone!

This is not really a request as it would take a bit of work and I am very willing to help with it. But I do think it is important, and people are asking about it more and more (I answered 2 questions about this on Symfonycasts tonight).

In short, it would be wonderful if the JavaScript used by EasyAdmin were powered by Stimulus, This would then allow us to add Turbo to EasyAdmin, giving it the SPA-like experience. Once these are done, it opens up a lot of other possibilities - like allowing any forms to be opened up in a modal natively (there is a simple strategy for this using Turbo Frames that I'll talk about in our LAST stack tutorial https://symfonycasts.com/screencast/last-stack ).

@javiereguiluz WDYT about this in principle? Have you gotten other similar requests or request for an even more modern frontend for EasyAdmin?

Thanks!

@b1rdex
Copy link
Contributor

b1rdex commented Dec 8, 2023

I have a little experience having these inside EasyAdmin application. The stack feels like magic, it's very impressive!

I have built a form that uses DynamicFormBuilder with dependent fields to implement a "pre creation step" where a user selects a preset for a CRUD form fields.
image

I'll provide here a little scenario, just in case someone would be brave to try this too.

  1. create a custom action inside a CRUD controller that simply renders a template
  2. the template should embed a twig live component plus an importmap() statement to include the magic
{% extends '@EasyAdmin/page/content.html.twig' %}

{% block main %}
    <twig:RouteTemplateSelector/>
    {{ importmap() }}
    <link rel="stylesheet" href="{{ asset('styles/app.css') }}">
{% endblock %}
  1. follow the demo code https://ux.symfony.com/demos/live-component/dependent-form-fields and create your form

As you can see, the scenario is not so obvious. It would be amazing to have a more "native" way to implement this.

@weaverryan
Copy link
Contributor Author

That's really cool @b1rdex! Inside RouteTemplateSelector, you're rendering a completely custom form type... and so... not using the form builder from EasyAdmin for this form? I know this is a little off topic from my original post, but I'd love to see the form details :). Thanks!

@b1rdex
Copy link
Contributor

b1rdex commented Dec 10, 2023

@weaverryan yes, the form is completely standalone and is built using ComponentWithFormTrait in the live component. However, I'm using EasyAdmin's AdminUrlGenerator inside component for form actions.

@mozkomor05
Copy link

Thanks @b1rdex, very cool! I tried something similar, but got stuck on not being able to get AdminContext from LiveComponent. Did you manage to do that? Because then it would be possible to render CRUD controller forms as well.

@b1rdex
Copy link
Contributor

b1rdex commented Jan 14, 2024

@mozkomor05 you can try getting it from request attributes. Or just create it manually from request.

Reference code for more details: \EasyCorp\Bundle\EasyAdminBundle\EventListener\AdminRouterSubscriber::onKernelRequest()

@mozkomor05
Copy link

mozkomor05 commented Jan 14, 2024

@b1rdex Thanks for the tips. After many hours of debugging I finally had success implementing LiveComponents for Easy Admin Forms. The trick was to save the AdminContext as a LiveProp, for which I wrote my own hydrator.

    #[LiveProp(hydrateWith: 'hydrateAdminContext', dehydrateWith: 'dehydrateAdminContext')]
    public AdminContext $context;

    public function hydrateAdminContext(array $data): AdminContext
    {
        $request = Request::create($data['requestUri']);
        $action = $data['action'];

        $dashboardController = $this->controllerFactory->getDashboardControllerInstance(
            $data['dashboardControllerFqcn'],
            $request
        );

        $crudController = $this->controllerFactory->getCrudControllerInstance(
            $data['crudControllerFqcn'],
            $action,
            $request
        );

        $context = $this->adminContextFactory->create($request, $dashboardController, $crudController);

        // Necessary as some EasyAdmin methods load context using AdminContextProvider
        $this->requestStack->getCurrentRequest()?->attributes->set(EA::CONTEXT_REQUEST_ATTRIBUTE, $context);

        return $context;
    }

    public function dehydrateAdminContext(AdminContext $context): array
    {
        return [
            'dashboardControllerFqcn' => $context->getDashboardControllerFqcn(),
            'crudControllerFqcn' => $context->getCrud()->getControllerFqcn(),
            'requestUri' => $context->getRequest()->getUri(),
            'action' => $context->getCrud()->getCurrentAction(),
        ];
    }

With AdminContext it is easy to instantiate EasyAdmin form:

    protected function instantiateForm(): FormInterface
    {
        $context = $this->context;
        $action = $context->getCrud()->getCurrentAction();
        $entityDto = $context->getEntity();
        $entityFqcn = $entityDto->getFqcn();

        $crudController = $this->controllerFactory->getCrudControllerInstance(
            $context->getCrud()->getControllerFqcn(),
            $context->getCrud()->getCurrentAction(),
            $context->getRequest()
        );

        switch ($action) {
            case Action::NEW:
                $entityDto->setInstance($crudController->createEntity($entityFqcn));
                $this->preprocessEntity($context, $crudController);

                return $crudController->createNewForm($entityDto, $context->getCrud()->getNewFormOptions(), $context);
            case Action::EDIT:
                $this->preprocessEntity($context, $crudController);

                return $crudController->createEditForm($entityDto, $context->getCrud()->getEditFormOptions(), $context);
            default:
                throw new \RuntimeException('LiveForm only supports new and edit actions.');
        }
    }

    private function preprocessEntity(AdminContext $context, CrudControllerInterface $crudController): void
    {
        $entityDto = $context->getEntity();
        $action = $context->getCrud()->getCurrentAction();

        $this->entityFactory->processFields($entityDto, FieldCollection::new($crudController->configureFields($action)));

        $fieldAssetsDto = new AssetsDto();
        $currentPageName = $context->getCrud()?->getCurrentPage();
        foreach ($entityDto->getFields() as $fieldDto) {
            $fieldAssetsDto = $fieldAssetsDto->mergeWith($fieldDto->getAssets()->loadedOn($currentPageName));
        }

        $context->getCrud()->setFieldAssets($fieldAssetsDto);
        $this->entityFactory->processActions($entityDto, $context->getCrud()->getActionsConfig());
    }

Usage:

{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{% extends '@EasyAdmin/crud/edit.html.twig' %}

{% block edit_form %}
    <twig:LiveForm :form="edit_form" :context="ea"/>
{% endblock %}

This can be easily combined with DynamicFormBuilder (https://ux.symfony.com/demos/live-component/dependent-form-fields) in create<Action>FormBuilder, or you can also implement the dynamic logic manually.

@iXscite
Copy link

iXscite commented Nov 6, 2024

@mozkomor05 I am very sorry but I am not very familar with twig-components. Could you share how the twig-component LiveForm needs to look like?

@iXscite
Copy link

iXscite commented Nov 23, 2024

I am using Symfony 7.2 and finally figured that the twig-component LiveForm.html.twig needs to look like

{# templates/components/LiveForm.html.twig #}
<{{ tag }} {{ attributes }}>
    {% form_theme form with ea.crud.formThemes only %}
    {{ form(form) }}
</{{ tag }}>

It should be called from a template like

{# templates/form/form_edit.html.twig #}
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{% extends '@EasyAdmin/crud/edit.html.twig' %}

{% block head_javascript %}
    {{ parent() }}
    {% block importmap %}{{ importmap() }}{% endblock %}
{% endblock head_javascript %}
    
{% block edit_form %}
    <twig:LiveForm :context="ea"/>
{% endblock %}

which is registered in a CrudController by

public function configureCrud(Crud $crud): Crud
{
    return parent::configureCrud($crud)
       ->overrideTemplates([
            'crud/edit' => 'form/form_edit.html.twig'
        ])
}

I am very grateful to @mozkomor05 for the code pubished here! It works just great and I have integrated it in the following abstract controller

<?php

namespace App\Twig\Components;

/**
 * Code adapted from https://github.com/EasyCorp/EasyAdminBundle/issues/6051
 * provided by David Moškoř
 *
 * @author Jens Simon <[email protected]>
 */

use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA;
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\CrudControllerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Dto\AssetsDto;
use EasyCorp\Bundle\EasyAdminBundle\Exception\ForbiddenActionException;
use EasyCorp\Bundle\EasyAdminBundle\Exception\InsufficientEntityPermissionException;
use EasyCorp\Bundle\EasyAdminBundle\Factory\AdminContextFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
use EasyCorp\Bundle\EasyAdminBundle\Security\Permission;
use ReflectionMethod;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;

abstract class AbstractEasyAdminLiveController extends AbstractController
{
    use ComponentWithFormTrait;

    private const EA_FORM = 'ea_form';

    #[LiveProp(hydrateWith: 'hydrateAdminContext', dehydrateWith: 'dehydrateAdminContext')]
    public AdminContext $context;

    public function hydrateAdminContext(array $data): AdminContext
    {
        $request = Request::create($data['requestUri']);
        $action = $data['action'];
        $dashboardController = $this->controllerFactory->getDashboardControllerInstance(
            $data['dashboardControllerFqcn'],
            $request
        );
        $crudController = $this->controllerFactory->getCrudControllerInstance(
            $data['crudControllerFqcn'],
            $action,
            $request
        );
        $context = $this->adminContextFactory->create($request, $dashboardController, $crudController);
        // Necessary as some EasyAdmin methods load context using AdminContextProvider
        $this->requestStack->getCurrentRequest()?->attributes->set(EA::CONTEXT_REQUEST_ATTRIBUTE, $context);
        return $context;
    }

    public function dehydrateAdminContext(AdminContext $context): array
    {
        return [
            'dashboardControllerFqcn' => $context->getDashboardControllerFqcn(),
            'crudControllerFqcn' => $context->getCrud()->getControllerFqcn(),
            'requestUri' => $context->getRequest()->getUri(),
            'action' => $context->getCrud()->getCurrentAction(),
        ];
    }

    public function __construct(
        private AdminContextFactory $adminContextFactory,
        private ControllerFactory $controllerFactory,
        private EntityFactory $entityFactory,
        private RequestStack $requestStack,
    ) {
    }

    private function getForm(FormBuilderInterface $builder): FormInterface
    {
        if ((new ReflectionMethod($this::class, 'buildForm'))->hasPrototype()) {
            $len = strlen(self::EA_FORM);
            $children = $builder->all();
            foreach ($children as $key => $child) {
                if (substr($key, 0, $len) === self::EA_FORM) {
                    $builder->remove($key);
                }
            }
        };
        $this->buildForm($builder);
        return $builder->getForm();
    }

    private function preprocessEntity(AdminContext $context, CrudControllerInterface $crudController): void
    {
        $entityDto = $context->getEntity();
        $action = $context->getCrud()->getCurrentAction();
        if (!$this->isGranted(Permission::EA_EXECUTE_ACTION, ['action' => $action, 'entity' => $entityDto])) {
            throw new ForbiddenActionException($context);
        }
        if (!$entityDto->isAccessible()) {
            throw new InsufficientEntityPermissionException($context);
        }
        $this->entityFactory->processFields($entityDto, FieldCollection::new($crudController->configureFields($action)));
        $fieldAssetsDto = new AssetsDto();
        $currentPageName = $context->getCrud()?->getCurrentPage();
        foreach ($entityDto->getFields() as $fieldDto) {
            $fieldAssetsDto = $fieldAssetsDto->mergeWith($fieldDto->getAssets()->loadedOn($currentPageName));
        }
        $context->getCrud()->setFieldAssets($fieldAssetsDto);
        $this->entityFactory->processActions($entityDto, $context->getCrud()->getActionsConfig());
    }

    protected function buildForm(FormBuilderInterface $builder, array $options =[]): void {
    }

    protected function instantiateForm(): FormInterface
    {
        $context = $this->context;
        $action = $context->getCrud()->getCurrentAction();
        $entityDto = $context->getEntity();
        $entityFqcn = $entityDto->getFqcn();
        $crudController = $this->controllerFactory->getCrudControllerInstance(
            $context->getCrud()->getControllerFqcn(),
            $context->getCrud()->getCurrentAction(),
            $context->getRequest()
        );
        foreach ($this->validatedFields as $validated => $value) {
            $property = explode('.', $value)[1];
            $this->validate($property, $this->formValues[$property]);
        }
        $this->validatedFields = [];
        switch ($action) {
            case Action::NEW:
                $entityDto->setInstance($crudController->createEntity($entityFqcn));
                $this->preprocessEntity($context, $crudController);
                return $this->getForm($crudController->createNewFormBuilder($entityDto, $context->getCrud()->getNewFormOptions(), $context));
            case Action::EDIT:
                $this->preprocessEntity($context, $crudController);
                return $this->getForm($crudController->createEditFormBuilder($entityDto, $context->getCrud()->getEditFormOptions(), $context));
            default:
                throw new \RuntimeException('LiveForm only supports new and edit actions.');
        }
    }

    abstract protected function validate(string $property, string $value): void;
}

This controller requires the function validate(string $property, string $value), which is meant to be a place to react on changes in a child controller. If you need you can also override the function buildForm(FormBuilderInterface $builder, array $options =[]), which is called after building the form defined by the function configureFields(string $pageName) in the CrudController calling the aforementioned template form_edit.html.twig. This buidlForm-function is the place to add dependent fields.

I would be very happy, if someone could provide a more obvious way than to call {{ importmap() }} as sugested by b1rdex above in order to invoke a child controller like the following as a LiveComponent.

<?php

namespace App\Twig\Components;

use App\Repository\Administration\EntityRepository;
use App\Repository\Administration\EntityShippingAddressRepository;
use App\Twig\Components\AbstractEasyAdminLiveController;
use EasyCorp\Bundle\EasyAdminBundle\Factory\AdminContextFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory;
use EasyCorp\Bundle\EasyAdminBundle\Factory\EntityFactory;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfonycasts\DynamicForms\DependentField;
use Symfonycasts\DynamicForms\DynamicFormBuilder;

#[AsLiveComponent('VoucherLiveController', template: 'components/VoucherLiveForm.html.twig')]
final class VoucherLiveController extends AbstractEasyAdminLiveController
{
    use ComponentWithFormTrait;
    use DefaultActionTrait;

    #[LiveAction]
    public function printInvoice()
    {
        
    }

    private array $billingAddressChoice = [];

    public string $tag = 'liveform';

    public function __construct(
        private AdminContextFactory $adminContextFactory,
        private ControllerFactory $controllerFactory,
        private EntityFactory $entityFactory,
        private EntityRepository $entityRepository,
        private EntityShippingAddressRepository $entityShippingAddressRepository,
        private RequestStack $requestStack,
    ) {
        parent::__construct($adminContextFactory, $controllerFactory, $entityFactory, $requestStack);
    }

    protected function buildForm(FormBuilderInterface $builder, array $options = []): void
    {
        $builder = new DynamicFormBuilder($builder);
        $builder
            ->addDependent('billingAddressId', ['debitorId', 'billingAddress'], function (DependentField $field, ?int $debitorId, ?string $billingAddress) {
                if (!$debitorId || $billingAddress) {
                    return;
                }
                $this->billingAddressChoice = [$this->entityRepository->getEntityBillingAddress($debitorId) => $debitorId];
                $field->add(ChoiceType::class, [
                    'autocomplete' => true,
                    'label' => 'Select',
                    'label_attr' => [
                        'lang' => 'en',
                        'style' => 'font-weight: bold',
                    ],
                    'choices' => $this->billingAddressChoice,
                    'mapped' => false,
                    'placeholder' => 'Choose a Billing Address',
                    'required' => false,
                ]);
            })
            ->addDependent('shippingAddressId', ['consigneeId', 'shippingAddress'], function (DependentField $field, ?int $consigneeId, ?string $shippingAddress) {
                if (!$consigneeId || $shippingAddress) {
                    return;
                }
                $field->add(ChoiceType::class, [
                    'autocomplete' => true,
                    'label' => 'Select',
                    'label_attr' => [
                        'lang' => 'en',
                        'style' => 'font-weight: bold',
                    ],
                    'choices' => array_merge($this->billingAddressChoice, $this->entityShippingAddressRepository->getChoicesOfEntityShippingAddresses($consigneeId)),
                    'mapped' => false,
                    'placeholder' => 'Choose a Shipping Address',
                    'required' => false,
                ]);
            });
    }

    protected function validate(string $property, string $value): void
    {
        switch ($property) {
            case 'billingAddressId':
                $address = $this->entityRepository->getEntityBillingAddress((int)$value);
                $this->formValues['billingAddress'] = $address;
                break;
            case 'shippingAddressId':
                $address = $this->entityShippingAddressRepository->getEntityShippingAddress((int)$value);
                $this->formValues['shippingAddress'] = $address;
                break;
        }
    }
 }

@javiereguiluz
Copy link
Collaborator

Closing because we're working on this already. It won't be available very soon, but we've already introduced several Twig components and the end goal is to build something very dynamic using UX Turbo. Thanks!

@iXscite
Copy link

iXscite commented Jan 12, 2025 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants