Booking Entry (editable LaraGrid demo — nothing is written to the database)
Type to search a resort (rate auto-fills from its tariff) · Enter advances serpentine · typing overwrites a cell · F2 edits in place · Delete clears · Shift+Delete / F7 removes the row · Insert adds a row · Ctrl+D fills down · Ctrl+C copies TSV · Tab past the last cell lands on Save.
No rows to display.
Save captures the cleaned rows below and reseeds the grid.
<?php
namespace App\Livewire;
use App\Models\Resort;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
use LaraGrid\Aggregate;
use LaraGrid\Columns\DateColumn;
use LaraGrid\Columns\DecimalColumn;
use LaraGrid\Columns\FormulaColumn;
use LaraGrid\Columns\IntegerColumn;
use LaraGrid\Columns\SearchSelectColumn;
use LaraGrid\Columns\SerialColumn;
use LaraGrid\Columns\TextColumn;
use LaraGrid\Columns\YesNoColumn;
use LaraGrid\Editing\RowContext;
use LaraGrid\Grid;
use LaraGrid\Livewire\WithLaraGrid;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Editable LaraGrid demo — a booking-lines entry screen over the real resorts table.
*
* Exercises the full editing machinery: async resort picker (gridOptions RPC) with
* onSelect enrichment (rate pre-filled from the resort's comparison_tariff), typed
* cells with client+server validation, a dual-runtime formula column (nights × rate),
* auto-append, live footer totals, keyboard row ops (Insert, Shift+Delete/F7, Ctrl+D,
* Delete-clears), and the save path: gridRows() cleaning + reseedGrid().
*
* Nothing writes to the database — Save captures the cleaned rows and shows them.
*/
#[Title('Booking Entry')]
#[Layout('components.layouts.app')]
class BookingEntry extends Component
{
use WithLaraGrid;
/** @var list<array<string, mixed>> The grid-bound rows (each carries a stable _k). */
public array $lines = [];
/** @var list<array<string, mixed>> The last saved (cleaned) payload, for display. */
public array $saved = [];
public function mount(): void
{
$this->lines = $this->gridMountRows('lines');
}
/**
* @return array<string, Grid>
*/
protected function grids(): array
{
return [
'lines' => Grid::make('lines')
->editable()
->rowsFrom('lines')
// Demo app has no auth — permit openly. Gate with a policy in real apps.
->authorize(fn(): bool => true)
->defaultRows(3)
//->newRowUsing(fn(): array => ['nights' => 1])
->minRows(1)
->autoAppend()
->padRows(4)
->focusOnMount()
->focusOutTo('[data-save]')
->onCompleteFocus('[data-save]')
->columns([
SerialColumn::make(),
SearchSelectColumn::make('resort_id')->label('Resort')
->endOfListOption(allowOnEmpty: true)
->optionsUsing(fn(string $term): array => Resort::query()
->where('visibility', 'show')
->when($term !== '', fn($q) => $q->where('name', 'like', "%{$term}%"))
->orderBy('name')
->limit(50)
->get(['id', 'name'])
->map(fn(Resort $resort): array => [
'value' => (string) $resort->id,
'label' => $resort->name,
])
->all())
// Enrichment: picking a resort pre-fills the rate from its tariff;
// clearing the pick clears the rate. Write-backs ride the op response.
->onSelect(function (RowContext $row, mixed $value): void {
if ($value === null) {
$row->set('rate', null);
return;
}
$tariff = Resort::whereKey($value)->value('comparison_tariff');
$row->set('rate', $tariff !== null
? number_format((float) $tariff, 2, '.', '')
: null);
})
->required()
->minChars(0)->debounce(250)->limit(50)
->grow(),
DateColumn::make('fromDate')->label('From'),
DateColumn::make('toDate')->label('To'),
IntegerColumn::make('nights')->label('Nights')
->rules(['integer', 'min:1', 'max:60'])
->required()
->width(90),
YesNoColumn::make('taxable')->label('Taxable?'),
DecimalColumn::make('rate')->label('Rate / night')->scale(2)
->rules(['numeric', 'min:0'])
->width(120),
FormulaColumn::make('amount')->label('Amount')
->formula('round(nights * rate, 2)')
->width(130),
TextColumn::make('note')->label('Note')->maxLength(100)->grow(),
])
->footer([
Aggregate::sum('amount')->format('number', ['scale' => 2]),
])
// Derived nights: whenever From or To changes (typing, paste, fill-down),
// recompute nights = date difference. The hook runs server-side after the
// cell is applied; its write-back patches the client row, and the amount
// FORMULA recomputes AFTER the hook in the same op — so one date commit
// updates Nights and Amount together in a single round trip. Nights stays
// editable, so the operator can still override the computed value.
->afterCellChange(function (RowContext $row, string $column): void {
if (! in_array($column, ['fromDate', 'toDate'], true)) {
return;
}
$from = $row->get('fromDate');
$to = $row->get('toDate');
if (! $from || ! $to) {
return; // one side still blank — nothing to derive yet
}
$nights = (int) Carbon::parse($from)->startOfDay()
->diffInDays(Carbon::parse($to)->startOfDay(), false);
// A reversed range derives nothing — clear nights so the required/min
// validation flags the row instead of silently keeping a stale count.
$row->set('nights', $nights >= 1 ? $nights : null);
})
->stickyHeader()
->maxHeight('55vh')
->emptyState('No booking lines yet.'),
];
}
/**
* "Save": capture the cleaned rows (blank trailing rows stripped, client bookkeeping
* removed), reset the grid to fresh seeded lines, and push the reset to the client
* (reseedGrid — the mandatory step after any out-of-band rows mutation).
*/
public function save(): void
{
$rows = $this->gridRows('lines');
if ($rows === []) {
return;
}
$this->saved = $rows;
$this->lines = $this->gridMountRows('lines');
$this->reseedGrid('lines');
}
public function render(): View
{
return view('livewire.booking-entry');
}
}
<div>
<h1>Booking Entry <small style="font-weight:normal;color:#71717a">(editable LaraGrid demo — nothing is written to the database)</small></h1>
<p style="color:#71717a;font-size:.85rem;margin:0 0 .75rem">
Type to search a resort (rate auto-fills from its tariff) · Enter advances serpentine ·
typing overwrites a cell · F2 edits in place · Delete clears · Shift+Delete / F7 removes the row ·
Insert adds a row · Ctrl+D fills down · Ctrl+C copies TSV · Tab past the last cell lands on Save.
</p>
<x-laragrid :grid="$this->gridDefinition('lines')" :rows="$lines" />
<div style="margin-top:1rem;display:flex;gap:.75rem;align-items:center">
<button type="button" data-save wire:click="save"
style="font:inherit;padding:.45rem 1.4rem;border:1px solid #18181b;border-radius:.375rem;background:#18181b;color:#fff;cursor:pointer">
Save
</button>
<span style="color:#71717a;font-size:.85rem">Save captures the cleaned rows below and reseeds the grid.</span>
</div>
<x-source-code title="Booking Entry source" panel="Saved payload" :files="[
'app/Livewire/BookingEntry.php',
'resources/views/livewire/booking-entry.blade.php',
]">
@if ($saved !== [])
<pre>{{ json_encode($saved, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
@else
<p>Fill a line and hit Save — the cleaned gridRows() output lands here.</p>
@endif
</x-source-code>
</div>
Fill a line and hit Save — the cleaned gridRows() output lands here.