Product Form + Category Modal
Open a child modal component to create a new 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
#[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:
- Emits the
category:createdevent with the newCategory's id (seeNewProductForm.php). - Dispatches a browser event called
modal:closeto close the dialog (theDialogcomponent listens for it withmodal:close->dialog#close).
This modal uses the UX Toolkit Dialog recipe (Shadcn UI), adapted to the site's Tailwind tokens.
// ... use statements hidden - click to show
#[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();
}
}