DEMOS / Live Component

Product Form + Category Modal

Open a child modal component to create a new Category.

Add a Category

New Product Form

Live component with a form, ValidatableComponentTrait and a saveProduct() LiveAction for instant validation & an AJAX submit.

The real magic comes from #[LiveListener('category:created'). This is emitted by the NewCategoryForm component (which opens in a modal) when a new category is created.

Note: the category:created event emits category as an integer. Then, thanks to the Category type-hint + Symfony's standard controller argument behavior, Symfony uses that id to query for the Category object.

// ... use statements hidden - click to show
use App\Entity\Category;
use App\Entity\Product;
use App\Repository\CategoryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent]
final class NewProductForm extends AbstractController
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    public function __construct(private CategoryRepository $categoryRepository)
    {
    }

    #[LiveProp(writable: true)]
    #[NotBlank]
    public string $name = '';

    #[LiveProp(writable: true)]
    public int $price = 0;

    #[LiveProp(writable: true)]
    #[NotBlank]
    public ?Category $category = null;

    /**
     * @return list<Category>
     */
    #[ExposeInTemplate]
    public function getCategories(): array
    {
        return $this->categoryRepository->findAll();
    }

    #[LiveListener('category:created')]
    public function onCategoryCreated(#[LiveArg] Category $category): void
    {
        // change category to the new one
        $this->category = $category;

        // the re-render will also cause the <select> to re-render with
        // the new option included
    }

    public function isCurrentCategory(Category $category): bool
    {
        return $this->category && $this->category === $category;
    }

    #[LiveAction]
    public function saveProduct(EntityManagerInterface $entityManager): Response
    {
        $this->validate();
        $product = new Product();
        $product->setName($this->name);
        $product->setPrice($this->price);
        $product->setCategory($this->category);
        $entityManager->persist($product);
        $entityManager->flush();

        $this->addFlash('live_demo_success', 'Product created! Add another one!');

        return $this->redirectToRoute('app_demo_live_component_product_form');
    }
}

New Product Template

Near the bottom, this renders the Dialog component (the UX Toolkit "dialog" recipe) with another component - NewCategoryForm - inside of it. Opening it is pure Stimulus on a native dialog element: the trigger button carries dialog#open, no Bootstrap JS involved.

<div {{ attributes }}>
    <form
        data-action="live#action:prevent"
        data-live-action-param="saveProduct"
    >
        <div class="grid grid-cols-12 gap-x-6 items-center">
            <div class="col-span-2">
                <twig:Form:Label for="product-name">Product name:</twig:Form:Label>
            </div>
            <div class="col-span-3">
                <twig:Form:Input type="text" id="product-name" data-model="name" hasError="{{ _errors.has('name') }}" />
                <twig:Form:Errors errors="{{ _errors.get('name') }}" />
            </div>
        </div>

        <div class="grid grid-cols-12 gap-x-6 items-center mt-4">
            <div class="col-span-2">
                <twig:Form:Label for="product-price">Price:</twig:Form:Label>
            </div>
            <div class="col-span-3">
                <twig:Form:Input type="text" id="product-price" data-model="price" hasError="{{ _errors.has('price') }}" />
                <twig:Form:Errors errors="{{ _errors.get('price') }}" />
            </div>
        </div>

        <div class="grid grid-cols-12 gap-x-6 items-center mt-4">
            <div class="col-span-2">
                <twig:Form:Label for="product-category">Category:</twig:Form:Label>
            </div>
            <div class="col-span-3">
                {# resolved here: inside <twig:Form:Select>'s slot `this` is the Select component, not this form #}
                {% set current_category = this.category %}
                <twig:Form:Select id="product-category" data-model="category" hasError="{{ _errors.has('category') }}">
                    <option value="">Choose a category</option>
                    {% for category_option in categories %}
                        <option value="{{ category_option.id }}" {{ category_option is same as(current_category) ? 'selected' }}>{{ category_option.name }}</option>
                    {% endfor %}
                </twig:Form:Select>
                <twig:Form:Errors errors="{{ _errors.get('category') }}" />
            </div>
            <div class="col-auto">
                {# `modal:close` is dispatched by NewCategoryForm after a successful save (bubbles up to this controller). #}
                <twig:Dialog id="new-category" data-action="modal:close->dialog#close">
                    <twig:Dialog:Trigger>
                        <twig:Button {{ ...dialog_trigger_attrs }} class="text-sm">+ Add Category</twig:Button>
                    </twig:Dialog:Trigger>
                    <twig:Dialog:Content>
                        <twig:Dialog:Header>
                            <twig:Dialog:Title>Add a Category</twig:Dialog:Title>
                        </twig:Dialog:Header>
                        <twig:NewCategoryForm />
                    </twig:Dialog:Content>
                </twig:Dialog>
            </div>
        </div>

        <div class="mt-4">
            <twig:Button variant="primary" type="submit">Save Product</twig:Button>
        </div>
    </form>
</div>

New Category Form

This component opens up in the dialog! It has a #[LiveAction] that saves the new Category to the database and then does two important things:

  1. Emits the category:created event with the new Category's id (see NewProductForm.php).
  2. Dispatches a browser event called modal:close to close the dialog (the Dialog component listens for it with modal:close->dialog#close).
Note

This modal uses the UX Toolkit Dialog recipe (Shadcn UI), adapted to the site's Tailwind tokens.

// ... use statements hidden - click to show
use App\Entity\Category;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\LiveResponder;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;

#[AsLiveComponent]
final class NewCategoryForm
{
    use ComponentToolsTrait;
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp(writable: true)]
    #[NotBlank]
    public string $name = '';

    #[LiveAction]
    public function saveCategory(EntityManagerInterface $entityManager, LiveResponder $liveResponder): void
    {
        $this->validate();

        $category = new Category();
        $category->setName($this->name);
        $entityManager->persist($category);
        $entityManager->flush();

        $this->dispatchBrowserEvent('modal:close');
        $this->emit('category:created', [
            'category' => $category->getId(),
        ]);

        // reset the fields in case the modal is opened again
        $this->name = '';
        $this->resetValidation();
    }
}
Author weaverryan
Published 2023-04-20