Invoice Creator
Create or edit an Invoice
entity along with child components for each related InvoiceItem
entity.
Children components emit events to communicate to the parent and everything is saved in a saveInvoice
LiveAction method.
Main Component
This main component keeps track of the Invoice
(which may be new) and
a list of the "invoice items" - stored on a LiveProp
called $lineItems
.
Because LiveProp
values need to be (mostly) simple, the $lineItems
are
stored as a raw array of data, which we add to or remove from when line
items are added/removed.
This components listens to several events that the child
InvoiceCreatorLineItem
components emits. For example, the child emits
removeLineItem
when the user clicks the "x" button on a line item.
This triggers the removeLineItem()
method on this component, which
removes the line item from the $lineItems
array.
// ... use statements hidden - click to show
#[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);
}
}
Main Template
The template is fairly simple: rendering form fields with data-model
to
bind to writable LiveProp
's along with their validation errors.
Most importantly, this loops over $lineItems
and renders the
InvoiceCreatorLineItem
child component for each one passing the
data: productId
, quantity
, and isEditing
. It also passes a key
,
which is needed so LiveComponents can track which row is which.
<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>
Child Component
The child component for each "line item". This handles validating, saving, and changing the "edit" state of the line item.
But all of the line item data ultimately needs to be stored on the parent
component so that we can wait to save everything to the database. This
component communicates the new data (or "edit" state change) to the parent
by emitting events - e.g. line_item:save
.
Note: it would be simpler to manage the
isEditing
state directly on this component, instead of passing it to the parent. It was done this way so that the parent component can know how many of its children are currently in "edit" mode.
// ... use statements hidden - click to show
#[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,
]);
}
}
Item template
Nothing too fancy here: some data-model
elements and data-action="live#action"
buttons.
The most interesting part is the "X" button to remove a line item: this uses
data-action="live#emitUp"
to emit the removeLineItem
event to the parent component.
In this case, instead of triggering a LiveAction
that then emits the event,
we emit the event directly.
<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>