DEMOS / Live Component

发票创建器

创建或编辑 Invoice 实体以及每个相关的 InvoiceItem 实体的子组件。

子组件发出事件与父组件通信,所有内容都保存在 saveInvoice LiveAction 方法中。

发票项目
产品 价格 数量  
小计 $0.00
税率
%
总计 $0.00

主组件

此主组件跟踪 Invoice(可能是新的)以及“发票项目”的列表 - 存储在名为 $lineItems 的 LiveProp 中。

由于 LiveProp 值需要(大部分)简单,$lineItems 存储为原始数据数组,当添加/删除行项目时,我们会向其中添加或从中删除。

此组件监听子 InvoiceCreatorLineItem 组件发出的多个事件。例如,当用户单击行项目上的“x”按钮时,子组件发出 removeLineItem 事件。这会触发此组件上的 removeLineItem() 方法,该方法从 $lineItems 数组中删除行项目。

// ... use 语句已隐藏 - 点击显示
use App\Entity\Invoice; use App\Entity\InvoiceItem; use App\Entity\Product; use App\Repository\ProductRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Validator\Constraints\Valid; 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 InvoiceCreator extends AbstractController
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp(writable: ['customerName', 'customerEmail', 'taxRate'])]
    #[Valid]
    public Invoice $invoice;

    #[LiveProp]
    public array $lineItems = [];

    /**
     * A temporary flag that we just saved.
     *
     * This doesn't need to be a LiveProp because it's set in a LiveAction,
     * rendered immediately, then we want it to be forgotten.
     */
    public bool $savedSuccessfully = false;
    public bool $saveFailed = false;

    public function __construct(private ProductRepository $productRepository)
    {
    }

    // add mount method
    public function mount(Invoice $invoice): void
    {
        $this->invoice = $invoice;
        $this->lineItems = $this->populateLineItems($invoice);
    }

    #[LiveAction]
    public function addLineItem(): void
    {
        $this->lineItems[] = [
            'productId' => null,
            'quantity' => 1,
            'isEditing' => true,
        ];
    }

    #[LiveListener('removeLineItem')]
    public function removeLineItem(#[LiveArg] int $key): void
    {
        unset($this->lineItems[$key]);
    }

    #[LiveListener('line_item:change_edit_mode')]
    public function onLineItemEditModeChange(#[LiveArg] int $key, #[LiveArg] $isEditing): void
    {
        $this->lineItems[$key]['isEditing'] = $isEditing;
    }

    #[LiveListener('line_item:save')]
    public function saveLineItem(#[LiveArg] int $key, #[LiveArg] int $product, #[LiveArg] int $quantity): void
    {
        if (!isset($this->lineItems[$key])) {
            // shouldn't happen
            return;
        }

        $this->lineItems[$key]['productId'] = $product;
        $this->lineItems[$key]['quantity'] = $quantity;
    }

    #[LiveAction]
    public function saveInvoice(EntityManagerInterface $entityManager)
    {
        $this->saveFailed = true;
        $this->validate();
        $this->saveFailed = false;

        // TODO: do we check for `isSaved` here... and throw an error?

        // remove any items that no longer exist
        foreach ($this->invoice->getInvoiceItems() as $key => $item) {
            if (!isset($this->lineItems[$key])) {
                // orphanRemoval will cause these to be deleted
                $this->invoice->removeInvoiceItem($item);
            }
        }

        foreach ($this->lineItems as $key => $lineItem) {
            $invoiceItem = $this->invoice->getInvoiceItems()->get($key);
            if (null === $invoiceItem) {
                // this is a new item! Welcome!
                $invoiceItem = new InvoiceItem();
                $entityManager->persist($invoiceItem);
                $this->invoice->addInvoiceItem($invoiceItem);
            }

            $product = $this->findProduct($lineItem['productId']);
            $invoiceItem->setProduct($product);
            $invoiceItem->setQuantity($lineItem['quantity']);
        }

        $isNew = null === $this->invoice->getId();
        $entityManager->persist($this->invoice);
        $entityManager->flush();

        if ($isNew) {
            // it's new! Let's redirect to the edit page
            $this->addFlash('live_demo_success', 'Invoice saved!');

            return $this->redirectToRoute('app_demo_live_component_invoice', [
                'id' => $this->invoice->getId(),
            ]);
        }

        // it's not new! We should already be on the edit page, so let's
        // just let the component stay rendered.
        $this->savedSuccessfully = true;

        // Keep the lineItems in sync with the invoice: new InvoiceItems may
        //      not have been given the same key as the original lineItems
        $this->lineItems = $this->populateLineItems($this->invoice);
    }

    public function getSubtotal(): float
    {
        $subTotal = 0;

        foreach ($this->lineItems as $lineItem) {
            if (!$lineItem['productId']) {
                continue;
            }

            $product = $this->findProduct($lineItem['productId']);

            $subTotal += ($product->getPrice() * $lineItem['quantity']);
        }

        return $subTotal / 100;
    }

    public function getTotal(): float
    {
        $taxMultiplier = 1 + ($this->invoice->getTaxRate() / 100);

        return $this->getSubtotal() * $taxMultiplier;
    }

    #[ExposeInTemplate]
    public function areAnyLineItemsEditing(): bool
    {
        foreach ($this->lineItems as $lineItem) {
            if ($lineItem['isEditing']) {
                return true;
            }
        }

        return false;
    }

    private function populateLineItems(Invoice $invoice): array
    {
        $lineItems = [];
        foreach ($invoice->getInvoiceItems() as $item) {
            $lineItems[] = [
                'productId' => $item->getProduct()->getId(),
                'quantity' => $item->getQuantity(),
                'isEditing' => false,
            ];
        }

        return $lineItems;
    }

    private function findProduct(int $id): Product
    {
        return $this->productRepository->find($id);
    }
}

主模板

该模板相当简单:使用 data-model 渲染表单字段以绑定到可写的 LiveProp 以及它们的验证错误。

最重要的是,它循环遍历 $lineItems 并为每个项目渲染 InvoiceCreatorLineItem 子组件,传递数据:productIdquantityisEditing。它还传递一个 key,这是 LiveComponents 跟踪哪一行是哪一行所必需的。

<div {{ attributes }}>
    <form data-action="live#action:prevent" data-live-action-param="saveInvoice">
        <div class="mb-3">
            <label for="customer-name">Customer name:</label>
            <input
                type="text"
                data-model="invoice.customerName"
                class="form-control {{ _errors.has('invoice.customerName') ? 'is-invalid' }}"
                id="customer-name"
            >
            {% if _errors.has('invoice.customerName') %}
                <div class="invalid-feedback">
                    {{ _errors.get('invoice.customerName') }}
                </div>
            {% endif %}
        </div>

        <div class="mb-3">
            <label for="customer-email">Billing Email:</label>
            <input
                type="email"
                data-model="invoice.customerEmail"
                class="form-control {{ _errors.has('invoice.customerEmail') ? 'is-invalid' }}"
                id="customer-email"
            >
            {% if _errors.has('invoice.customerEmail') %}
                <div class="invalid-feedback">
                    {{ _errors.get('invoice.customerEmail') }}
                </div>
            {% endif %}
        </div>

        <div class="card">
            <div class="card-header">Invoice Items</div>
            <div class="card-body">
                <table class="table">
                    <thead>
                        <tr>
                            <th>Product</th>
                            <th style="width: 100px;">Price</th>
                            <th style="width: 100px;">Quantity</th>
                            <th style="width: 100px;">&nbsp;</th>
                        </tr>
                    </thead>
                    <tbody>
                        {% for key, line in lineItems %}
                            <twig:InvoiceCreatorLineItem
                                key="{{ key }}"
                                productId="{{ line.productId }}"
                                quantity="{{ line.quantity }}"
                                isEditing="{{ line.isEditing }}"
                            />
                        {% endfor %}
                    </tbody>
                </table>

                <button
                    data-action="live#action"
                    data-live-action-param="addLineItem"
                    class="btn btn-sm btn-secondary"
                    type="button"
                ><twig:ux:icon name="plus" /> Add Item</button>
            </div>
        </div>

        <div class="col-4 offset-8 mt-4">
            <table class="table text-end">
                <tbody>
                    <tr>
                        <th>Subtotal:</th>
                        <td>{{ this.subtotal|format_currency('USD') }}</td>
                    </tr>
                    <tr>
                        <th>Tax rate:</th>
                        <td class="d-flex justify-content-end">
                            <div style="width: 110px;" class="input-group {{ _errors.has('invoice.taxRate') ? 'is-invalid' }}">
                                <input
                                    type="number"
                                    data-model="invoice.taxRate"
                                    class="form-control"
                                >
                                <span class="input-group-text">%</span>
                            </div>
                            {% if _errors.has('invoice.taxRate') %}
                                <div class="invalid-feedback">
                                    {{ _errors.get('invoice.taxRate') }}
                                </div>
                            {% endif %}
                        </td>
                    </tr>
                    <tr>
                        <th>Total:</th>
                        <td>{{ this.total|format_currency('USD') }}</td>
                    </tr>
                </tbody>
            </table>
        </div>

        <button
            class="btn btn-primary"
            {{ areAnyLineItemsEditing ? 'disabled' : '' }}
        >
            <span data-loading="action(saveInvoice)|show">
                <twig:ux:icon name="spinner" style="animation: spin 1s linear infinite;" />
            </span>
            {% if savedSuccessfully %}
                <twig:ux:icon name="circle-check" class="text-success" />
            {% endif %}
            {% if saveFailed %}
                <twig:ux:icon name="circle-exclamation" />
            {% endif %}
            Save Invoice
        </button>
        {% if saveFailed %}
            <small class="text-secondary">Check above for errors</small>
        {% endif %}
        {% if areAnyLineItemsEditing %}
            <small class="text-secondary">Save all line items before continuing.</small>
        {% endif %}
    </form>
</div>

子组件

每个“行项目”的子组件。这处理行项目的验证、保存和更改“编辑”状态。

但是所有行项目数据最终都需要存储在父组件上,以便我们可以等待将所有内容保存到数据库。此组件通过发出事件(例如 line_item:save)将新数据(或“编辑”状态更改)传递给父组件。

注意:直接在此组件上管理 isEditing 状态会更简单,而不是将其传递给父组件。这样做是为了让父组件可以知道有多少子组件当前处于“编辑”模式。

// ... use 语句已隐藏 - 点击显示
use App\Entity\Product; use App\Repository\ProductRepository; use Symfony\Component\Validator\Constraints as Assert; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\LiveResponder; use Symfony\UX\LiveComponent\ValidatableComponentTrait; use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent]
class InvoiceCreatorLineItem
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp]
    public int $key;

    #[LiveProp(writable: true)]
    #[Assert\NotNull]
    public ?Product $product = null;

    #[LiveProp(writable: true)]
    #[Assert\Positive]
    public int $quantity = 1;

    #[LiveProp]
    public bool $isEditing = false;

    public function __construct(private ProductRepository $productRepository)
    {
    }

    public function mount(?int $productId): void
    {
        if ($productId) {
            $this->product = $this->productRepository->find($productId);
        }
    }

    #[LiveAction]
    public function save(LiveResponder $responder): void
    {
        $this->validate();

        $responder->emitUp('line_item:save', [
            'key' => $this->key,
            'product' => $this->product->getId(),
            'quantity' => $this->quantity,
        ]);

        $this->changeEditMode(false, $responder);
    }

    #[LiveAction]
    public function edit(LiveResponder $responder): void
    {
        $this->changeEditMode(true, $responder);
    }

    #[ExposeInTemplate]
    public function getProducts(): array
    {
        return $this->productRepository->findAll();
    }

    private function changeEditMode(bool $isEditing, LiveResponder $responder): void
    {
        $this->isEditing = $isEditing;

        // emit to InvoiceCreator so it can track which items are being edited
        $responder->emitUp('line_item:change_edit_mode', [
            'key' => $this->key,
            'isEditing' => $this->isEditing,
        ]);
    }
}

项目模板

这里没什么特别的:一些 data-model 元素和 data-action="live#action" 按钮。

最有趣的部分是用于删除行项目的“X”按钮:它使用 data-action="live#emitUp" 向父组件发出 removeLineItem 事件。在这种情况下,我们不是触发一个然后发出事件的 LiveAction,而是直接发出事件。

<tr {{ attributes }}>
    <td>
        {% if isEditing %}
            <select
                data-model="product"
                aria-label="Choose a Product"
                class="form-control {{ _errors.has('product') ? 'is-invalid' }}"
            >
                <option value="" {{ not product ? 'selected' }}>Choose a Product</option>
                {% for product_option in products %}
                    <option
                        value="{{ product_option.id }}"
                        {% if product_option == product %}selected{% endif %}
                    >
                        {{ product_option.name }} ({{ product_option.priceInCents|format_currency('USD') }})
                    </option>
                {% endfor %}
            </select>
            {% if _errors.has('product') %}
                <div class="invalid-feedback">
                    {{ _errors.get('product') }}
                </div>
            {% endif %}

        {% else %}
            {{ product.name }}
        {% endif %}
    </td>

    <td>
        {% if not isEditing %}
            {{ product.priceInCents|format_currency('USD') }}
        {% endif %}
    </td>

    <td>
        {% if isEditing %}
            <input
                type="number"
                data-model="quantity"
                aria-label="Quantity"
                class="form-control {{ _errors.has('quantity') ? 'is-invalid' }}"
            >
            {% if _errors.has('quantity') %}
                <div class="invalid-feedback">
                    {{ _errors.get('quantity') }}
                </div>
            {% endif %}
        {% else %}
            {{ quantity }}
        {% endif %}
    </td>
    <td class="text-end text-nowrap">
        {% if isEditing %}
            <button
                data-action="live#action"
                data-live-action-param="save"
                class="btn btn-success btn-sm"
                type="button"
            >Save</button>
        {% else %}
            <button
                data-action="live#action"
                data-live-action-param="edit"
                class="btn btn-primary btn-sm"
                type="button"
            >Edit</button>
        {% endif %}

        <button
            data-action="live#emitUp"
            data-live-event-param="removeLineItem"
            data-live-key-param="{{ key }}"
            class="btn btn-link text-danger btn-sm ml-2"
            type="button"
        ><twig:ux:icon name="cross" /></button>
    </td>
</tr>
作者 weaverryan
发布于 2023-04-20