演示 / Live Component

产品表单 + 分类模态框

打开一个子模态框组件来创建新的分类。

新产品表单

带有表单、ValidatableComponentTraitsaveProduct() LiveAction 的 Live 组件,用于即时验证和 AJAX 提交。

真正的魔力来自 #[LiveListener('category:created')。这由 NewCategoryForm 组件(在模态框中打开)在新分类创建时发出。

注意:category:created 事件发出 category 作为整数。然后,由于 Category 类型提示 + Symfony 标准的 控制器参数行为,Symfony 使用该 ID 来查询 Category 对象。

// ... 隐藏 use 语句 - 点击显示
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]
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');
    }
}

新产品模板

在底部附近,这里使用另一个组件 - NewCategoryForm - 在其内部渲染了 BootstrapModal 组件。模态框的打开完全通过正常的 Bootstrap 逻辑完成:带有 data-bs-toggle="modal"data-bs-target="#new-category-modal"a 标签。

<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>

新分类表单

此组件在模态框中打开!它有一个 #[LiveAction],用于将新的 Category 保存到数据库,然后执行两个重要的操作

  1. 发出带有新 Category 的 ID 的 category:created 事件(参见 NewProductForm.php)。
  2. 调度一个名为 modal:closed 的浏览器事件来关闭模态框(参见 bootstrap-modal-controller.js)。
// ... 隐藏 use 语句 - 点击显示
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]
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());
    }
}
作者 weaverryan
发布日期 2023-04-20