symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
783 stars 276 forks source link

[LiveComponent] Integrated demo Dependent Form Fields with an existing form #679

Open seb-jean opened 1 year ago

seb-jean commented 1 year ago

Hi,

I have a Car entity with year, color, brand and model as fields. I have CarController generated with make:crud and his templates. I would like integrated demo Dependent Form Fields with an existing form but I do not know how. When I select 'Audi' for the brand, the options for the model are 'A1', 'A4', 'A5', 'S4'. When I select 'Citroen' for the brand, the options for the model are 'AX', 'Berlingo', 'Jumpy', 'Saxo'. etc.

// src/Controller/CarController.php

<?php

namespace App\Controller;

use App\Entity\Car;
use App\Form\CarType;
use App\Repository\CarRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

#[Route('/car')]
class CarController extends AbstractController
{
    #[Route('/', name: 'app_car_index', methods: ['GET'])]
    public function index(CarRepository $carRepository): Response
    {
        return $this->render('car/index.html.twig', [
            'cars' => $carRepository->findAll(),
        ]);
    }

    #[Route('/new', name: 'app_car_new', methods: ['GET', 'POST'])]
    public function new(Request $request, CarRepository $carRepository): Response
    {
        $car = new Car();
        $form = $this->createForm(CarType::class, $car);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $carRepository->save($car, true);

            return $this->redirectToRoute('app_car_index', [], Response::HTTP_SEE_OTHER);
        }

        return $this->render('car/new.html.twig', [
            'car' => $car,
            'form' => $form,
        ]);
    }

    #[Route('/{id}', name: 'app_car_show', methods: ['GET'])]
    public function show(Car $car): Response
    {
        return $this->render('car/show.html.twig', [
            'car' => $car,
        ]);
    }

    #[Route('/{id}/edit', name: 'app_car_edit', methods: ['GET', 'POST'])]
    public function edit(Request $request, Car $car, CarRepository $carRepository): Response
    {
        $form = $this->createForm(CarType::class, $car);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $carRepository->save($car, true);

            return $this->redirectToRoute('app_car_index', [], Response::HTTP_SEE_OTHER);
        }

        return $this->render('car/edit.html.twig', [
            'car' => $car,
            'form' => $form,
        ]);
    }

    #[Route('/{id}', name: 'app_car_delete', methods: ['POST'])]
    public function delete(Request $request, Car $car, CarRepository $carRepository): Response
    {
        if ($this->isCsrfTokenValid('delete'.$car->getId(), $request->request->get('_token'))) {
            $carRepository->remove($car, true);
        }

        return $this->redirectToRoute('app_car_index', [], Response::HTTP_SEE_OTHER);
    }
}

// src/Form/CarType.php

<?php

namespace App\Form;

use App\Entity\Car;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CarType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name')
            ->add('color')
            ->add('brand')
            ->add('model')
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Car::class,
        ]);
    }
}

// src/Entity/Car.php

<?php

namespace App\Entity;

use App\Repository\CarRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: CarRepository::class)]
class Car
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\Column(length: 255)]
    private ?string $color = null;

    #[ORM\Column(length: 255)]
    private ?string $brand = null;

    #[ORM\Column(length: 255)]
    private ?string $model = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    public function getColor(): ?string
    {
        return $this->color;
    }

    public function setColor(string $color): self
    {
        $this->color = $color;

        return $this;
    }

    public function getBrand(): ?string
    {
        return $this->brand;
    }

    public function setBrand(string $brand): self
    {
        $this->brand = $brand;

        return $this;
    }

    public function getModel(): ?string
    {
        return $this->model;
    }

    public function setModel(string $model): self
    {
        $this->model = $model;

        return $this;
    }
}

{# templates/car/new.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}New Car{% endblock %}

{% block body %}
    <h1>Create new Car</h1>

    {{ include('car/_form.html.twig') }}

    <a href="{{ path('app_car_index') }}">back to list</a>
{% endblock %}

{# templates/car/_form.html.twig #}

{{ form_start(form) }}
    {{ form_widget(form) }}
    <button class="btn">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}
weaverryan commented 1 year ago

Hi there!

There are 2 steps to this:

1) You need to setup a few events in your form so that, when the brand field changes, model updates. This is 100% about the form component. It's well-documented, but tricky. You can see the MealPlannerForm.php tab on the demo page - https://ux.symfony.com/live-component/demos/dependent-form-fields - or check the docs https://symfony.com/doc/current/form/dynamic_form_modification.html#form-events-submitted-data

2) Once you have this, you'll next want to create a AsLiveComponent to render your form. There's really nothing special about this component, fortunately. You can look at the MealPlannerComponent.php file on the demo - https://ux.symfony.com/live-component/demos/dependent-form-fields - it's quite simple. To make the edit form also work, you'll include a LiveProp for Car and use that to create the form - something like this:

use App\Form\MealPlannerForm;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('car_form_component')]
class MealPlannerComponent extends AbstractController
{
    use ComponentWithFormTrait;
    use DefaultActionTrait;

    #[LiveProp(fieldName: 'formData')]
    public ?Car $car = null;

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(CarType::class, $this->car);
    }
}

Then, instead of {{ include('car/_form.html.twig') }}, you would render the component:

{{ component('car_form_component', {
    form: form,
    car: car.id ? car : null
}) }}

(that car.id ? car : null is needed currently to avoid passing a non-persisted entity to the component - but it won't be needed in the future).

Let me know if this helps. I'd also love to know what we should change on the demo site / docs to be more useful for people :)

Cheers!

seb-jean commented 1 year ago

Basically, the idea is to just put 2 fields which are dependent fields but not written form_start and form_end. I don't know if this can help you more for my problem.

weaverryan commented 1 year ago

Oh yes, that's totally allowed also, and it doesn't change much... but I think we don't have it documented. Everything would basically be the same (the component would likely look the same) but you would have the component responsible for rendering a bit less:

{# templates/car/_form.html.twig #}

{{ form_start(form) }}
    {{ form_row(form.field1) }}
    {{ form_row(form.field2) }}

    {{ component('car_model_fields_component', {
        form: form,
        car: car.id ? car : null
    }) }}

    <button class="btn">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

Then the component template would basically have form_row(form.brand) and form_row(form.model). So, though it's possible, there's not much "point" to having the component only render those 2 field specifically.

There might be a way we can add something to live components to make this simpler... or more automatic... or more "focused" on just the dependent fields (e.g. so that you could render normally, but a live component wraps itself around the 2 fields), but I'm not sure at the moment :)

seb-jean commented 1 year ago

Unfortunately, it doesn't work.

There might be a way we can add something to live components to make this simpler... or more automatic... or more "focused" on just the dependent fields (e.g. so that you could render normally, but a live component wraps itself around the 2 fields), but I'm not sure at the moment :)

It would be very interesting :)

alexis78-sym commented 1 year ago

I would like to do the exact same thing.

seb-jean commented 1 year ago

@weaverryan, How could we do this? 😄

There might be a way we can add something to live components to make this simpler... or more automatic... or more "focused" on just the dependent fields (e.g. so that you could render normally, but a live component wraps itself around the 2 fields), but I'm not sure at the moment :)

carsonbot commented 2 months ago

Thank you for this issue. There has not been a lot of activity here for a while. Has this been resolved?

carsonbot commented 1 month ago

Could I get a reply or should I close this?

carsonbot commented 1 month ago

Hey,

I didn't hear anything so I'm going to close it. Feel free to comment if this is still relevant, I can always reopen!