发票创建器
创建或编辑 Invoice
实体以及每个相关的 InvoiceItem
实体的子组件。
子组件发出事件与父组件通信,所有内容都保存在 saveInvoice
LiveAction 方法中。
主组件
此主组件跟踪 Invoice
(可能是新的)以及“发票项目”的列表 - 存储在名为 $lineItems
的 LiveProp 中。
由于 LiveProp 值需要(大部分)简单,$lineItems
存储为原始数据数组,当添加/删除行项目时,我们会向其中添加或从中删除。
此组件监听子 InvoiceCreatorLineItem
组件发出的多个事件。例如,当用户单击行项目上的“x”按钮时,子组件发出 removeLineItem
事件。这会触发此组件上的 removeLineItem()
方法,该方法从 $lineItems
数组中删除行项目。
// ... use 语句已隐藏 - 点击显示
#[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
子组件,传递数据:productId
、quantity
和 isEditing
。它还传递一个 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;"> </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 语句已隐藏 - 点击显示
#[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>