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]
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;
#[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 BootstrapModal
component with another
component - NewCategoryForm
- inside of it. Opening the modal is done entirely
with normal Bootstrap logic: an a
tag with data-bs-toggle="modal"
and data-bs-target="#new-category-modal"
.
<div {{ attributes }}>
<form
data-action="live#action:prevent"
data-live-action-param="saveProduct"
>
<div class="row align-items-center">
<div class="col-2">
<label for="product-name">Product name:</label>
</div>
<div class="col-3">
<input
type="text"
data-model="name"
class="form-control {{ _errors.has('name') ? 'is-invalid' }}"
id="product-name"
>
{% if _errors.has('name') %}
<div class="invalid-feedback">
{{ _errors.get('name') }}
</div>
{% endif %}
</div>
</div>
<div class="row align-items-center mt-3">
<div class="col-2">
<label for="product-price">Price:</label>
</div>
<div class="col-3">
<input
type="text"
data-model="price"
class="form-control {{ _errors.has('price') ? 'is-invalid' }}"
id="product-price"
>
{% if _errors.has('price') %}
<div class="invalid-feedback">
{{ _errors.get('price') }}
</div>
{% endif %}
</div>
</div>
<div class="row align-items-center mt-3">
<div class="col-2">
<label for="product-category">Category:</label>
</div>
<div class="col-3">
<select
data-model="category"
id="product-category"
class="form-control {{ _errors.has('category') ? 'is-invalid' }}"
>
<option value="">Choose a category</option>
{% for category_option in categories %}
<option value="{{ category_option.id }}" {{ this.isCurrentCategory(category_option) ? 'selected' }}>{{ category_option.name }}</option>
{% endfor %}
</select>
{% if _errors.has('category') %}
<div class="invalid-feedback">
{{ _errors.get('category') }}
</div>
{% endif %}
</div>
<div class="col-auto">
<div class="form-text">
<a
type="button"
data-bs-toggle="modal"
data-bs-target="#new-category-modal"
>+ Add Category
</a>
</div>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Save Product</button>
</div>
</form>
{% component BootstrapModal with {id: 'new-category-modal'} %}
{% block modal_header %}
<h5>Add a Category</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
{% endblock %}
{% block modal_body %}
<twig:NewCategoryForm />
{% endblock %}
{% endcomponent %}
</div>
New Category Form
This component opens up in the modal! It has a #[LiveAction]
that saves
the new Category
to the database and then does two important things:
- Emits the
category:created
event with the newCategory
's id (seeNewProductForm.php
). - Dispatches a browser event called
modal:closed
to close the modal (seebootstrap-modal-controller.js
).
// ... use statements hidden - click to show
#[AsLiveComponent]
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();
}
}
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class BootstrapModal
{
public ?string $id = null;
}
<div {{ attributes.defaults({
class: 'modal fade',
tabindex: '-1',
'aria-hidden': 'true',
id: id ? id : false,
}) }}
data-controller="bootstrap-modal"
>
<div class="modal-dialog">
<div class="modal-content">
{% block modal_full_content %}
{% if block('modal_header') %}
<div class="modal-header">
{% block modal_header %}{% endblock %}
</div>
{% endif %}
<div class="modal-body">
{% block modal_body %}{% endblock %}
</div>
{% if block('modal_footer') %}
<div class="modal-footer">
{% block modal_footer %}{% endblock %}
</div>
{% endif %}
{% endblock %}
</div>
</div>
</div>
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
/**
* Allows you to dispatch a "modal:close" JavaScript event to close it.
*
* This is useful inside a LiveComponent, where you can emit a browser event
* to open or close the modal.
*
* See templates/components/BootstrapModal.html.twig to see how this is
* attached to Bootstrap modal.
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
modal = null;
connect() {
this.modal = Modal.getOrCreateInstance(this.element);
document.addEventListener('modal:close', () => this.modal.hide());
}
}