承接 unnathianalytics/laragrid 相关项目开发

从需求分析到上线部署,全程专人跟进,保证项目质量与交付效率

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

unnathianalytics/laragrid

Composer 安装命令:

composer require unnathianalytics/laragrid

包简介

Excel-style, keyboard-first datagrid for Laravel + Livewire — display, server-side readonly, and editable grids, fully configured from the component class with zero blade wiring and zero consumer build tooling.

README 文档

README

Latest Version License PHP

Excel-style, keyboard-first datagrid for Laravel + Livewire. Extracted from a production accounting system built for spreadsheet-trained operators, then made app-neutral.

The engine is framework-free vanilla JavaScript that owns every cell it paints: the grid body lives inside a wire:ignore region, Livewire never morphs a row, and all server traffic runs over renderless RPCs. The result is spreadsheet-grade speed with Laravel-grade authority — every edit is validated, authorized, and recomputed server-side.

Everything is configured in your component class with chained methods. No blade wiring, no JavaScript to write, no npm step — composer require is the entire install.

The three modes

Mode Declare with What you get
Display rows passed to the tag Paints in-memory rows. Works on plain Blade pages without any Livewire component.
Readonly server-side ->query(fn () => Model::query()) Sort, global search, filters, pagination through a whitelisted fail-closed pipeline. Page 1 ships in the initial payload (zero-round-trip first paint); later pages stream over an RPC with an LRU cache and idle prefetch of the next page.
Editable ->editable()->rowsFrom('lines') The full spreadsheet: optimistic client, authoritative server, typed op protocol, validation on both sides, formula columns, async pickers with row enrichment, auto-append, live footer totals.

Both interactive modes share one keyboard model, one selection engine, one theming system.

Requirements

  • PHP ^8.1
  • Laravel 10 / 11 / 12 / 13
  • Livewire ^4.1 — installed automatically as a dependency

Installation

composer require unnathianalytics/laragrid

That's all. The service provider auto-discovers, and the prebuilt script + stylesheet auto-inject into any page that renders a grid. No layout directives, no build step.

Optional publishes:

php artisan vendor:publish --tag=laragrid-config   # config/laragrid.php (global defaults)
php artisan vendor:publish --tag=laragrid-views    # blade views (mount + badge/edit-link cells)
php artisan vendor:publish --tag=laragrid-assets   # copy dist/ to public/vendor/laragrid

Asset delivery is configurable: set laragrid.inject_assets => false to place @laragridStyles / @laragridScripts yourself, or point laragrid.asset_url at a CDN or the published copy. Asset URLs carry a content hash, so upgrades bust browser caches automatically.

Quick start — a readonly list

use App\Models\Resort;
use LaraGrid\Actions\Action;
use LaraGrid\Aggregate;
use LaraGrid\Columns\{SerialColumn, TextColumn, IntegerColumn, DateColumn, ComputedColumn};
use LaraGrid\Filters\SelectFilter;
use LaraGrid\Grid;
use LaraGrid\Livewire\WithLaraGrid;
use LaraGrid\Support\CellHtml;
use Livewire\Component;

class ResortsIndex extends Component
{
    use WithLaraGrid;   // ← required: provides gridDefinition() and the grid RPCs

    protected function grids(): array
    {
        return ['resorts' => Grid::make('resorts')
            ->query(fn () => Resort::query())
            ->authorize('resort.viewAny')            // mandatory — grids are fail-closed
            ->paginate(25, [10, 25, 50, 100])
            ->defaultSort('name')
            ->searchable(['name', 'shortcode', 'resorts.slug'])   // see note below
            ->filters([
                SelectFilter::make('type')->label('Type')
                    ->options(fn () => Resort::distinct()->orderBy('type')->pluck('type', 'type')),
                SelectFilter::make('visibility')->label('Visibility')
                    ->options(['show' => 'Show', 'hide' => 'Hide']),
            ])
            ->columns([
                SerialColumn::make(),
                TextColumn::make('name')->label('Resort')->sortable()->searchable()->grow(),
                TextColumn::make('type')->sortable()->width(120),
                IntegerColumn::make('hits')->sortable()->width(90),
                ComputedColumn::make('status')->html()->width(90)
                    ->state(fn (array $row) => $row['visibility'] === 'show'
                        ? CellHtml::badge('green', 'Show')
                        : CellHtml::badge('zinc', 'Hide')),
                DateColumn::make('created_at')->label('Added')->sortable()->width(110),
            ])
            ->footer([Aggregate::sum('hits')->format('number')])
            ->actions([
                Action::make('edit')->icon('')->url(fn ($row) => route('resorts.edit', $row['id'])),
                Action::make('delete')->icon('')->confirm('Delete this resort?')
                    ->call(fn (array $row) => Resort::whereKey($row['id'])->delete()),
            ])
            ->stickyHeader()->striped()->maxHeight('70vh')];
    }

    public function render()
    {
        return view('livewire.resorts-index');
    }
}
<x-laragrid :grid="$this->gridDefinition('resorts')" />

Search targets: bare names in ->searchable() must be declared columns — a typo fails loudly at build time instead of silently searching nothing. To search a database column you don't display, table-qualify it ('resorts.slug'); the dot marks it as an explicit DB column.

Quick start — an editable entry grid

use LaraGrid\Columns\{SerialColumn, SearchSelectColumn, IntegerColumn, DecimalColumn, FormulaColumn, TextColumn};
use LaraGrid\Editing\RowContext;

class BookingEntry extends Component
{
    use WithLaraGrid;

    /** @var list<array<string, mixed>> */
    public array $lines = [];

    public function mount(): void
    {
        $this->lines = $this->gridMountRows('lines');   // seeds defaultRows via the factory
    }

    protected function grids(): array
    {
        return ['lines' => Grid::make('lines')
            ->editable()
            ->rowsFrom('lines')                         // binds public array $lines
            ->authorize(fn () => $this->authorize('booking.create'))
            ->defaultRows(3)
            ->newRowUsing(fn () => ['nights' => 1])     // template for seeded AND inserted rows
            ->minRows(1)
            ->autoAppend()                              // Enter past the last cell grows the grid
            ->focusOnMount()
            ->focusOutTo('[data-save]')                 // Tab past the last cell lands on Save
            ->columns([
                SerialColumn::make(),
                SearchSelectColumn::make('resort_id')->label('Resort')
                    ->optionsUsing(fn (string $term) => Resort::query()
                        ->when($term !== '', fn ($q) => $q->where('name', 'like', "%{$term}%"))
                        ->limit(50)->get(['id', 'name'])
                        ->map(fn ($r) => ['value' => (string) $r->id, 'label' => $r->name])
                        ->all())
                    ->onSelect(function (RowContext $row, mixed $value): void {
                        // Enrichment: the pick pre-fills the rate; write-backs reconcile
                        // into the client row automatically.
                        $row->set('rate', Resort::whereKey($value)->value('comparison_tariff'));
                    })
                    ->required()->minChars(0)->debounce(250)->grow(),
                IntegerColumn::make('nights')->rules(['integer', 'min:1'])->required()->width(90),
                DecimalColumn::make('rate')->scale(2)->rules(['numeric', 'min:0'])->width(120),
                FormulaColumn::make('amount')->formula('round(nights * rate, 2)')->width(130),
                TextColumn::make('note')->maxLength(100)->grow(),
            ])
            ->footer([Aggregate::sum('amount')->format('number', ['scale' => 2])])];
    }

    public function save(): void
    {
        $rows = $this->gridRows('lines');       // cleaned: blank trailing rows + bookkeeping stripped

        Booking::createFromLines($rows);        // your persistence — the grid never owns it

        $this->lines = $this->gridMountRows('lines');
        $this->reseedGrid('lines');             // required after any out-of-band rows change
    }
}

The editable contract in one paragraph: the client applies every keystroke optimistically and streams typed ops to the server, where each write is authorized, cast, validated, run through your hooks, and formula columns are recomputed — the response reconciles authoritative values back into the grid. Rows are addressed by stable keys, never positions. Blank trailing rows (the auto-append artifact) are invisible to validation, totals, and gridRows().

Display-only mode

<x-laragrid :grid="$grid" :rows="[['name' => 'Alpha'], ['name' => 'Beta']]" />

No ->query(), no ->editable() — the rows are painted as-is with full keyboard navigation, selection, copy, column chooser, and footer totals. No Livewire component is required. To refresh a display grid's data later, call $this->reseedGrid('name', $freshRows).

Column types

Column Editor Value Notes
SerialColumn row number the # gutter
TextColumn text string ->maxLength(), ->upper()/->lower()
IntegerColumn number int grouping-tolerant input
DecimalColumn number fixed-scale string ->scale(n); precision never rides a float
DateColumn date ISO Y-m-d fuzzy typed input (31/12, 311226); display pattern configurable; financial-year inference opt-in
SelectColumn dropdown option id ->options([...]) embedded whitelist
SearchSelectColumn async picker option id ->optionsUsing(fn ($term, $row)), ->onSelect() enrichment, ->minChars(), ->debounce(), ->limit()
CheckboxColumn instant toggle bool
FormulaColumn computed ->formula('qty * rate') — evaluated live client-side, authoritatively server-side
ComputedColumn server-baked ->state(fn ($row)), pair with ->html() for badges/links via CellHtml
ReadonlyColumn / HiddenColumn display / carried writes rejected server-side

Shared column chains: label, width / minWidth / maxWidth / grow, align, visible, frozen, sortable(bool|'db.column'), searchable, filterable(Filter), required / required(fn), readonly / readonly(fn), rules([...]), lockedWhen('col', value), requiredWhen('col', value), whenFilled(sets: [...], clears: [...]), endOfListOption(), opensPanel('name'), html().

Grid definition reference

Dataquery(fn) · authorize(ability|fn) · paginate(per, [options]) · defaultSort(col, dir) · searchable([...]) · filters([...]) · rowKey('id') · rowActivate(fn ($row) => ?url) — Enter/double-click opens a row, permission-gated per row.

Editableeditable() · rowsFrom('prop') · defaultRows(n) · newRowUsing(fn) · minRows(n) · autoAppend() · padRows(n) · sync(SyncPolicy::PerCell|PerRow|Deferred) · refreshesHost([...]) (re-render host chrome when listed columns change) · completeWhenBalanced('dr', 'cr') · afterCellChange(fn) · afterRowRemove(fn).

Behaviorkeymap('entry'|'excel') · toolbar(...) / toolbar(false) · focusOnMount() · focusOutTo(selector) · onCompleteFocus(selector) · emptyState(text) · statusBar(bool) · persistWidths() (column layout survives reloads).

LayoutstickyHeader() · freezeColumns(n) · striped() · density(GridDensity::Compact|Normal|Comfortable) · height('420px') · maxHeight('60vh') · fillParent() · themeClass('my-theme') · rowClass(fn) · cellClass(fn).

Actions

->actions([          // per-row buttons in a trailing column
    Action::make('edit')->icon('')->url(fn ($row) => route('items.edit', $row['id'])),
    Action::make('archive')->confirm('Archive?')->visible(fn ($row) => ! $row['archived'])
        ->call(fn (array $row) => Item::whereKey($row['id'])->archive()),
])
->bulkActions([      // run over checked rows; a selector gutter + toolbar bulk bar appear
    Action::make('delete')->confirm('Delete selected?')
        ->call(fn (array $keys) => Item::whereKey($keys)->delete()),
])
->toolbarActions([   // grid-scoped buttons in the toolbar
    Action::make('new')->label('New Item')->url(fn () => route('items.create')),
])

Actions are fail-closed end to end: the client only echoes an action name (plus row keys) — the server re-authorizes the grid gate, the per-action ->authorize(), re-resolves the row from its authoritative source, and re-checks ->visible() before the closure runs. A hidden button is an unusable button. url() actions bake their per-row URL server-side; rows where the resolver returns null get no button. After a call() action, readonly grids refetch their current page and editable grids receive a reseed payload automatically. Throw a ValidationException inside a callback to show the operator a refusal message.

Toolbar, search & filters

The toolbar renders itself from the definition: a debounced search box (when the grid declares ->searchable()), one control per grid-level filter, the bulk bar, toolbar action buttons, and the column chooser. Suppress or tune it: ->toolbar(false), ->toolbar(search: false). Filter options accept a {value => label} map (the pluck() idiom), a list of ['value' => , 'label' => ] rows, or plain scalars. SelectFilter and TernaryFilter (All / Yes / No) ship in core; column header funnels via ->filterable() run through the same whitelisted pipeline.

Keyboard

Keys Action
Arrows / Tab / Home / End / PageUp / PageDown / Ctrl+edges navigate
Shift + movement, Ctrl+A extend / select all
Ctrl+C copy selection as TSV (pastes into Excel; paste back round-trips)
Type / F2 / double-click overwrite / edit a cell
Enter commit + advance — serpentine in entry, down in excel
Delete clear selected cells
Shift+Delete or F7 delete the row (minRows-guarded)
Insert / Ctrl+D insert row / fill down
Ctrl+E jump to the first error
ContextMenu / Shift+F10 open the row's actions menu
Escape clear selection / cancel edit

Paste is bulk-aware: a multi-row TSV paste maps onto editable cells, auto-appends rows as needed, and flushes as one batch (with a confirm above 500 cells).

Theming

Every visual is a --lgrid-* CSS token (row height, paddings, all colors) with self-contained defaults — the grid looks right on a page with no CSS framework at all. In a Tailwind v4 app it adopts your --color-* @theme palette automatically. Override tokens globally, under your own ->themeClass(), or under .dark (dark mode is token-flipping, nothing more). All elements carry stable lgrid-* semantic classes, so any part is restylable and nothing is ever purged by a build tool. Print collapses to a clean black-on-white table.

Extending

// A custom column type is just a class:
class RatingColumn extends \LaraGrid\Columns\Column
{
    public function painterId(): string { return 'rating'; }
    public function editorId(): ?string { return 'rating'; }
    public function parseSpec(): array { return ['kind' => 'int']; }
}

// App-specific formatters and parse kinds register in a service provider:
app(\LaraGrid\Formatting\FormatRegistry::class)->register('inr', new InrFormatter);
app(\LaraGrid\Casting\CastRegistry::class)->register('paise', new PaiseCast);
// The window.LaraGrid seams (load your script after the grid bundle):
LaraGrid.registerPainter('rating', (cellEl, ctx) => { /* draw the cell */ });
LaraGrid.registerEditor('rating', RatingEditor);        // { mount, value, focus, destroy }
LaraGrid.registerFormatter('inr', (value, args) => );  // twin of the PHP formatter
LaraGrid.registerCast('paise', { parse, editText });    // twin of the PHP cast

Formatting and casting run in both runtimes — the client for instant paint, the server for authority. Every PHP formatter/cast you register must have a behaviourally identical JS twin under the same name, pinned by a shared vector in tests/fixtures/grid-vectors/ (the package's own suite runs 122 vectors through both runtimes on every build). Complete worked example — integer-paise money, lakh/crore grouping, financial-year dates: docs/recipes/inr-paise.md.

Host events

Grid → host (bubbling DOM events): lgrid:complete (entry finished), lgrid:activate (row opened), lgrid:panel (cell hands off to a host modal), lgrid:column-resized, lgrid:column-visibility. Host → grid: lgrid:reseed (use $this->reseedGrid(...)), lgrid:panel-done (use $this->gridPanelDone(...)), lgrid:toolbar (drive the grid from fully custom host chrome).

Configuration

config/laragrid.php holds app-wide defaults only — density, keymap preset, date display pattern, financial-year start month (off by default), toolbar defaults, asset injection and asset URL. Any grid overrides any of them through its chained definition.

Troubleshooting

  • "Method …::gridMountRows does not exist" — add use WithLaraGrid; inside the component class (importing the trait at the top of the file is not enough).
  • "No hint path defined for [layouts]" on a bare Livewire 4 app — pin the layout on your full-page component: #[Layout('components.layouts.app')].
  • "searchable target [x] is not a declared column" — declare the column, or table-qualify the target ('items.slug') to search an undisplayed DB column.
  • Stale assets after upgrading — none: tags carry a content hash. If a proxy interferes, hard-refresh once.

Testing

Package suite: composer test (Pest via Testbench) and npm test (Node vector runners) — PHP and JS are asserted against the same committed vectors. Your app tests drive grids through the public RPCs, exactly as the browser does:

Livewire::test(BookingEntry::class)
    ->call('gridOps', 'lines', ['ops' => [
        ['t' => 'set', 'seq' => 1, 'row' => $key, 'col' => 'nights', 'v' => '3'],
    ]])
    ->call('gridAction', 'resorts', 'delete', [$id]);

License

MIT © Unnathi Analytics

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 0
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: JavaScript

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-07-14

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固