Sortable
Board
Records in lanes — a kanban over a model and a column, cards dragged between lanes and inside one, with the order kept.
On this page
A board shows records in lanes by the value of one column — tasks by status, deals by stage — and moving a card between lanes changes that value. It is a surface like a table: declared once, rendered by the component that hosts it.
use NyonCode\WireSortable\Board\Board;
How It Works
The declaration and the host are separate. Board says what the lanes are,
which records go in them and what a card shows. WithBoard, composed into any
Livewire component, renders it and answers a drop. A board page is therefore a
Page that composes WithBoard — no
class of its own, and no package depending on another.
The drag is Livewire's. Every lane is a wire:sort list in one group, with
the lane's value as its group id, so a card can leave its lane and a drop calls
moveBoardCard(key, position, lane) on the host. This package ships no script
for it.
A drop is checked, then written. The lane must be one the board declares;
the record is looked up through the board's own query, so a card the board would
not show cannot be moved by naming its key; canMoveBoardCard() may refuse the
transition. Then MoveBoardCard writes the lane into the column and — when the
board has an orderColumn() — renumbers the lane from zero with the card at its
drop, in one transaction, writing only the rows whose number changed. Without an
order column a drop changes the lane and nothing else.
One query draws the whole board, sorted into lanes in PHP; a record whose
value matches no lane is not drawn. Narrow a large board with query().
Basic Usage
use NyonCode\WirePanels\Pages\Page;use NyonCode\WireSortable\Board\Board;use NyonCode\WireSortable\Concerns\WithBoard; final class TaskBoard extends Page{ use WithBoard; protected static string $view = 'wire-sortable::board.content'; public function board(Board $board): Board { return $board->model(Task::class)->groupBy('status')->lanes(TaskStatus::class); } }
Lanes
Three ways to say what the lanes are, in the order they are drawn:
->lanes(TaskStatus::class) // a backed enum: its cases, its labels and colours->lanes(['todo' => 'To do', 'done' => 'Done']) // value => label->lanes([Lane::make('todo')->label('To do'), Lane::make('done')->color('success')])
A lane's name is the column's value as stored — for an enum-cast column, the
case's backing value. An enum that implements the core HasLabel and HasColor
contracts labels and colours its lanes; otherwise the case name is humanised and
the marker is gray.
Cards
->cardTitle('title') // an attribute, or fn (Task $task) => …->cardDescription(fn (Task $task) => $task->owner?->name)->cardUrl(fn (Task $task) => route('tasks.edit', $task)) // the title becomes a wire:navigate link
Order
->orderColumn('position')
The lane a card lands in is renumbered from zero around it. Leave it out and
the board keeps whatever order query() gives, changing only the lane.
Guarding A Move
protected function canMoveBoardCard(Model $record, string $lane): bool{ return $record->status !== TaskStatus::Done; // a finished task is reopened by hand} protected function boardCardMoved(Model $record, string $from, string $to): void{ Notification::make()->title("Moved to {$to}")->send();}
Extended Example
A board in its own component, beside other content, rather than as a page:
use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Livewire\Component;use NyonCode\WireSortable\Board\Board;use NyonCode\WireSortable\Board\Lane;use NyonCode\WireSortable\Concerns\WithBoard; final class DealPipeline extends Component{ use WithBoard; public function board(Board $board): Board { return $board ->model(Deal::class) ->query(fn (Builder $query) => $query->where('owner_id', auth()->id())) ->groupBy('stage') ->lanes([ Lane::make('lead')->label('Leads'), Lane::make('proposal')->color('info'), Lane::make('won')->color('success'), Lane::make('lost')->color('danger'), ]) ->cardTitle('name') ->cardDescription(fn (Deal $deal) => money($deal->value)) ->orderColumn('stage_position'); } protected function canMoveBoardCard(Model $record, string $lane): bool { return $lane !== 'won' || $record->signed_at !== null; } public function render() { return view('livewire.deal-pipeline'); // @include('wire-sortable::board.board', ['lanes' => $this->boardLanesForView()]) }}
Board API
Board::make()->model(string $model) // class-string<Model>->query(?Closure $callback) // fn (Builder $query) => $query->… — narrows the records->groupBy(string $column) // the column a lane is a value of->lanes(array|string $lanes) // , value => label, or a backed enum class->cardTitle(string|Closure $title) // an attribute or fn (Model $record) => string — default 'id'->cardDescription(string|Closure|null $description)->cardUrl(?Closure $url) // fn (Model $record) => ?string->orderColumn(?string $column) // renumbered on a drop — default none->getLanes(): array->getGroupBy(): string->getOrderColumn(): ?string->getQuery(): Builder
Lane: Lane::make(string $name), ->label(string|Closure|null),
->color(string|Color|null) — default 'gray'.
WithBoard: board(Board $board): Board (declare), getBoard(): Board,
moveBoardCard(mixed $key, int $position, string $lane): void (what a drop
calls), canMoveBoardCard(Model, string): bool, boardCardMoved(Model, string, string): void, boardLanesForView(): array. Views: wire-sortable::board.board
(the lanes, given $lanes) and wire-sortable::board.content (the same, for a
Page's $view). Hook names: board, board-lane, board-card.
Related
- Pages — the page a board usually sits on
- Row Reordering — ordering a table's rows by drag