From b07cde5dbb648546916c832a6ee9b00e293fb43b Mon Sep 17 00:00:00 2001 From: Dustin Date: Sat, 31 Jan 2026 22:39:23 -0600 Subject: [PATCH 1/9] =?UTF-8?q?Add=20swimlane=20support=20for=202D=20kanba?= =?UTF-8?q?n=20boards=20(columns=20=C3=97=20rows)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce optional swimlane grouping that creates a grid layout with columns as the primary axis and swimlanes as secondary row grouping. Boards without swimlanes configured continue to work identically. New classes: - Swimlane: ViewComponent mirroring Column (name, label, color, icon) - HasBoardSwimlanes: Trait for swimlane configuration on Board Data layer: - getBoardRecordsForCell(): query records by column × swimlane - getBatchedSwimlaneRecordCounts(): GROUP BY column, swimlane counts - Board::getViewData() branches into flat vs 2D swimlane structure Views: - swimlane-board.blade.php: HTML table with sticky headers/labels, inline x-data for collapse state (avoids x-load timing issues) - swimlane-cell.blade.php: per-cell card list with independent scroll - index.blade.php: conditional flat vs swimlane rendering Features: - Collapsible swimlane rows with localStorage persistence - Per-cell pagination via composite "columnId|swimlaneId" keys - Horizontal-only drag via per-swimlane sortable groups - Uncategorized swimlane for records with no matching lane value - Filament theme-compatible styling (bg-white/dark:bg-gray-900) Co-Authored-By: Claude Opus 4.5 --- resources/dist/flowforge.js | 2 +- resources/js/flowforge.js | 44 ++++++ resources/lang/en/flowforge.php | 1 + resources/views/index.blade.php | 38 +++-- .../views/livewire/swimlane-board.blade.php | 147 ++++++++++++++++++ .../views/livewire/swimlane-cell.blade.php | 50 ++++++ src/Board.php | 137 ++++++++++++++-- src/Concerns/HasBoardRecords.php | 82 ++++++++++ src/Concerns/HasBoardSwimlanes.php | 100 ++++++++++++ src/Concerns/InteractsWithBoard.php | 62 +++++--- src/FlowforgeServiceProvider.php | 2 + src/Swimlane.php | 100 ++++++++++++ 12 files changed, 723 insertions(+), 42 deletions(-) create mode 100644 resources/views/livewire/swimlane-board.blade.php create mode 100644 resources/views/livewire/swimlane-cell.blade.php create mode 100644 src/Concerns/HasBoardSwimlanes.php create mode 100644 src/Swimlane.php diff --git a/resources/dist/flowforge.js b/resources/dist/flowforge.js index 245cfb7f8..640228984 100644 --- a/resources/dist/flowforge.js +++ b/resources/dist/flowforge.js @@ -1 +1 @@ -function d({state:a}){return{state:a,isLoading:{},fullyLoaded:{},init(){this.$wire.$on("kanban-items-loaded",t=>{let{columnId:e,isFullyLoaded:o}=t;o&&(this.fullyLoaded[e]=!0)})},handleSortableEnd(t){let e=t.to.sortable.toArray(),o=t.item.getAttribute("x-sortable-item");if(!o&&(o=t.item.getAttribute("data-card-id"),!o)){console.error("Flowforge: Could not determine card ID for move operation");return}let s=t.to.getAttribute("data-column-id");if(!s){console.error("Flowforge: Target column ID is missing");return}let i=t.item;this.setCardState(i,!0);let r=e.indexOf(o),l=r>0?e[r-1]:null,n=rthis.setCardState(i,!1)).catch(()=>this.setCardState(i,!1))},setCardState(t,e){t.style.opacity=e?"0.7":"",t.style.pointerEvents=e?"none":""},isLoadingColumn(t){return this.isLoading[t]||!1},isColumnFullyLoaded(t){return this.fullyLoaded[t]||!1},handleSmoothScroll(t){this.isLoadingColumn(t)||this.isColumnFullyLoaded(t)||(this.isLoading[t]=!0,this.$wire.loadMoreItems(t).then(()=>setTimeout(()=>this.isLoading[t]=!1,100)).catch(()=>this.isLoading[t]=!1))},handleColumnScroll(t,e){if(this.isColumnFullyLoaded(e))return;let{scrollTop:o,scrollHeight:s,clientHeight:i}=t.target;(o+i)/s>=.8&&!this.isLoadingColumn(e)&&this.handleSmoothScroll(e)}}}export{d as default}; +function d({state:o}){return{state:o,isLoading:{},fullyLoaded:{},collapsedSwimlanes:{},init(){this.$wire.$on("kanban-items-loaded",e=>{let{columnId:t,isFullyLoaded:a}=e;a&&(this.fullyLoaded[t]=!0)}),this.state.swimlanes&&this._restoreSwimlaneState()},handleSortableEnd(e){let t=e.to.sortable.toArray(),a=e.item.getAttribute("x-sortable-item");if(!a&&(a=e.item.getAttribute("data-card-id"),!a)){console.error("Flowforge: Could not determine card ID for move operation");return}let l=e.to.getAttribute("data-column-id");if(!l){console.error("Flowforge: Target column ID is missing");return}let i=e.item;this.setCardState(i,!0);let s=t.indexOf(a),r=s>0?t[s-1]:null,n=sthis.setCardState(i,!1)).catch(()=>this.setCardState(i,!1))},setCardState(e,t){e.style.opacity=t?"0.7":"",e.style.pointerEvents=t?"none":""},isLoadingColumn(e){return this.isLoading[e]||!1},isColumnFullyLoaded(e){return this.fullyLoaded[e]||!1},handleSmoothScroll(e){this.isLoadingColumn(e)||this.isColumnFullyLoaded(e)||(this.isLoading[e]=!0,this.$wire.loadMoreItems(e).then(()=>setTimeout(()=>this.isLoading[e]=!1,100)).catch(()=>this.isLoading[e]=!1))},handleColumnScroll(e,t){if(this.isColumnFullyLoaded(t))return;let{scrollTop:a,scrollHeight:l,clientHeight:i}=e.target;(a+i)/l>=.8&&!this.isLoadingColumn(t)&&this.handleSmoothScroll(t)},toggleSwimlane(e){this.collapsedSwimlanes[e]=!this.collapsedSwimlanes[e],this._saveSwimlaneState()},isSwimlaneCollapsed(e){return this.collapsedSwimlanes[e]||!1},_getSwimlaneStorageKey(){return"flowforge:swimlanes:"+window.location.pathname},_saveSwimlaneState(){try{localStorage.setItem(this._getSwimlaneStorageKey(),JSON.stringify(this.collapsedSwimlanes))}catch{}},_restoreSwimlaneState(){try{let e=localStorage.getItem(this._getSwimlaneStorageKey());e&&(this.collapsedSwimlanes=JSON.parse(e))}catch{this.collapsedSwimlanes={}}}}}export{d as default}; diff --git a/resources/js/flowforge.js b/resources/js/flowforge.js index 62dd2237d..2159d98e8 100644 --- a/resources/js/flowforge.js +++ b/resources/js/flowforge.js @@ -3,6 +3,7 @@ export default function flowforge({state}) { state, isLoading: {}, fullyLoaded: {}, + collapsedSwimlanes: {}, init() { this.$wire.$on('kanban-items-loaded', (event) => { @@ -11,6 +12,11 @@ export default function flowforge({state}) { this.fullyLoaded[columnId] = true; } }); + + // Restore collapsed swimlane state from localStorage + if (this.state.swimlanes) { + this._restoreSwimlaneState(); + } }, handleSortableEnd(event) { @@ -80,5 +86,43 @@ export default function flowforge({state}) { this.handleSmoothScroll(columnId); } }, + + // --- Swimlane collapse/expand --- + + toggleSwimlane(swimlaneId) { + this.collapsedSwimlanes[swimlaneId] = !this.collapsedSwimlanes[swimlaneId]; + this._saveSwimlaneState(); + }, + + isSwimlaneCollapsed(swimlaneId) { + return this.collapsedSwimlanes[swimlaneId] || false; + }, + + _getSwimlaneStorageKey() { + // Use the page URL path as a board-specific key + return 'flowforge:swimlanes:' + window.location.pathname; + }, + + _saveSwimlaneState() { + try { + localStorage.setItem( + this._getSwimlaneStorageKey(), + JSON.stringify(this.collapsedSwimlanes) + ); + } catch (e) { + // localStorage may be unavailable; ignore silently + } + }, + + _restoreSwimlaneState() { + try { + const stored = localStorage.getItem(this._getSwimlaneStorageKey()); + if (stored) { + this.collapsedSwimlanes = JSON.parse(stored); + } + } catch (e) { + this.collapsedSwimlanes = {}; + } + }, } } diff --git a/resources/lang/en/flowforge.php b/resources/lang/en/flowforge.php index 680b958ef..2b30af3bc 100644 --- a/resources/lang/en/flowforge.php +++ b/resources/lang/en/flowforge.php @@ -8,4 +8,5 @@ 'plural_card_label' => 'Records', 'cards_pagination' => ':current of :total :cards', 'all_cards_loaded' => 'All :total :cards loaded', + 'uncategorized' => 'Uncategorized', ]; diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index f48427f27..939c5d567 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -1,5 +1,5 @@ @php use Filament\Support\Facades\FilamentAsset; @endphp -@props(['columns', 'config']) +@props(['columns', 'swimlanes', 'config'])
-
-
- @foreach($columns as $columnId => $column) - - @endforeach + @if($swimlanes) + {{-- 2D Swimlane Grid Layout --}} +
+
-
+ @else + {{-- Flat Column Layout (original) --}} +
+
+ @foreach($columns as $columnId => $column) + + @endforeach +
+
+ @endif
diff --git a/resources/views/livewire/swimlane-board.blade.php b/resources/views/livewire/swimlane-board.blade.php new file mode 100644 index 000000000..e85808f88 --- /dev/null +++ b/resources/views/livewire/swimlane-board.blade.php @@ -0,0 +1,147 @@ +@php use Relaticle\Flowforge\Support\ColorResolver; @endphp +@props(['columns', 'swimlanes', 'config']) + +@php + $columnIds = array_keys($columns); + $columnCount = count($columnIds); + $hasPositionIdentifier = $this->getBoard()->getPositionIdentifierAttribute() !== null; +@endphp + +
+ + {{-- Sticky Column Header Row --}} + + + {{-- Empty corner cell --}} + + + @foreach($columns as $columnId => $column) + @php + $resolvedColor = ColorResolver::resolve($column['color']); + $isSemantic = ColorResolver::isSemantic($resolvedColor); + @endphp + + @endforeach + + + + + @foreach($swimlanes as $swimlaneId => $swimlane) + @php + $resolvedLaneColor = ColorResolver::resolve($swimlane['color']); + $isLaneSemantic = ColorResolver::isSemantic($resolvedLaneColor); + $laneColorShades = $isLaneSemantic ? null : $resolvedLaneColor; + @endphp + + {{-- Swimlane Row --}} + + {{-- Swimlane Label Cell (row header) --}} + + + {{-- Swimlane Cells --}} + @foreach($columnIds as $columnId) + @php + $cell = $swimlane['cells'][$columnId] ?? ['items' => [], 'total' => 0]; + $cellKey = $columnId . '|' . $swimlaneId; + @endphp + + @endforeach + + @endforeach + +
+
+ @if ($column['icon'] ?? null) + + @endif +

+ {{ $column['label'] }} +

+
+
+ + +
+ +
+
+
diff --git a/resources/views/livewire/swimlane-cell.blade.php b/resources/views/livewire/swimlane-cell.blade.php new file mode 100644 index 000000000..2c71f0594 --- /dev/null +++ b/resources/views/livewire/swimlane-cell.blade.php @@ -0,0 +1,50 @@ +@props(['columnId', 'swimlaneId', 'cell', 'config', 'hasPositionIdentifier']) + +@php + $cellKey = $columnId . '|' . $swimlaneId; + $items = $cell['items'] ?? []; + $total = $cell['total'] ?? 0; +@endphp + +
count($items)) + @scroll.throttle.100ms="handleColumnScroll($event, '{{ $cellKey }}')" + @endif + class="flowforge-swimlane-cell p-2 overflow-y-auto overflow-x-hidden overscroll-y-contain kanban-cards" + style="min-height: 100px; max-height: 400px;" +> + @if (count($items) > 0) + @foreach ($items as $record) + + @endforeach + + @if($total > count($items)) +
+
+ {{ __('flowforge::flowforge.loading_more_cards') }} +
+
+ @endif + @else +
+

+
+ @endif +
diff --git a/src/Board.php b/src/Board.php index 0aec3e432..8604c1560 100644 --- a/src/Board.php +++ b/src/Board.php @@ -11,6 +11,7 @@ use Relaticle\Flowforge\Concerns\HasBoardColumns; use Relaticle\Flowforge\Concerns\HasBoardFilters; use Relaticle\Flowforge\Concerns\HasBoardRecords; +use Relaticle\Flowforge\Concerns\HasBoardSwimlanes; use Relaticle\Flowforge\Concerns\HasCardSchema; use Relaticle\Flowforge\Concerns\InteractsWithKanbanQuery; use Relaticle\Flowforge\Contracts\HasBoard; @@ -23,6 +24,7 @@ class Board extends ViewComponent use HasBoardColumns; use HasBoardFilters; use HasBoardRecords; + use HasBoardSwimlanes; use HasCardSchema; use InteractsWithKanbanQuery; @@ -55,18 +57,38 @@ protected function setUp(): void /** * Get view data for the board template. * Delegates to Livewire component like Filament's Table does. + * + * When swimlanes are configured, returns a 2D structure: + * columns = header definitions (no items), swimlanes = rows with cells. + * When no swimlanes, returns the flat column structure (unchanged). */ public function getViewData(): array { - // Batch all column counts in a single query + $config = [ + 'recordTitleAttribute' => $this->getRecordTitleAttribute(), + 'columnIdentifierAttribute' => $this->getColumnIdentifierAttribute(), + 'cardLabel' => __('flowforge::flowforge.card_label'), + 'pluralCardLabel' => __('flowforge::flowforge.plural_card_label'), + ]; + + if ($this->hasSwimlanes()) { + return $this->getViewDataWithSwimlanes($config); + } + + return $this->getViewDataFlat($config); + } + + /** + * Build the flat (no-swimlane) view data — original behavior. + */ + protected function getViewDataFlat(array $config): array + { $allCounts = $this->getBatchedBoardRecordCounts(); - // Build columns data using new concerns $columns = []; foreach ($this->getColumns() as $column) { $columnId = $column->getName(); - // Get formatted records $records = $this->getBoardRecords($columnId); $formattedRecords = $records->map(fn ($record) => $this->formatBoardRecord($record))->toArray(); @@ -82,12 +104,109 @@ public function getViewData(): array return [ 'columns' => $columns, - 'config' => [ - 'recordTitleAttribute' => $this->getRecordTitleAttribute(), - 'columnIdentifierAttribute' => $this->getColumnIdentifierAttribute(), - 'cardLabel' => __('flowforge::flowforge.card_label'), - 'pluralCardLabel' => __('flowforge::flowforge.plural_card_label'), - ], + 'swimlanes' => null, + 'config' => $config, + ]; + } + + /** + * Build the 2D swimlane view data: columns (headers only) × swimlanes (rows with cells). + */ + protected function getViewDataWithSwimlanes(array $config): array + { + $config['swimlaneIdentifierAttribute'] = $this->getSwimlaneIdentifierAttribute(); + + // Column header definitions (no items — those live in cells) + $columnHeaders = []; + foreach ($this->getColumns() as $column) { + $columnHeaders[$column->getName()] = [ + 'id' => $column->getName(), + 'label' => $column->getLabel(), + 'color' => $column->getColor(), + 'icon' => $column->getIcon(), + ]; + } + + // Batch all cell counts in one query + $cellCounts = $this->getBatchedSwimlaneRecordCounts(); + + // Build swimlane rows with cells + $swimlaneData = []; + $columnIds = array_keys($columnHeaders); + + foreach ($this->getSwimlanes() as $swimlane) { + $swimlaneId = $swimlane->getName(); + $laneTotal = 0; + $cells = []; + + foreach ($columnIds as $columnId) { + $cellKey = $columnId . '|' . $swimlaneId; + $cellCount = $cellCounts[$cellKey] ?? 0; + $laneTotal += $cellCount; + + $records = $this->getBoardRecordsForCell($columnId, $swimlaneId); + $formattedRecords = $records->map(fn ($record) => $this->formatBoardRecord($record))->toArray(); + + $cells[$columnId] = [ + 'items' => $formattedRecords, + 'total' => $cellCount, + ]; + } + + $swimlaneData[$swimlaneId] = [ + 'id' => $swimlaneId, + 'label' => $swimlane->getLabel(), + 'color' => $swimlane->getColor(), + 'icon' => $swimlane->getIcon(), + 'total' => $laneTotal, + 'cells' => $cells, + ]; + } + + // Check for uncategorized records (cards whose swimlane value doesn't match any defined swimlane) + $definedSwimlaneIds = $this->getSwimlaneIdentifiers(); + $uncategorizedTotal = 0; + $uncategorizedCells = []; + + foreach ($columnIds as $columnId) { + $cellKey = $columnId . '|__uncategorized__'; + $cellCount = $cellCounts[$cellKey] ?? 0; + + // Also check for values that don't match any defined swimlane + foreach ($cellCounts as $key => $count) { + if (! str_starts_with($key, $columnId . '|')) { + continue; + } + $laneId = substr($key, strlen($columnId) + 1); + if ($laneId !== '__uncategorized__' && ! in_array($laneId, $definedSwimlaneIds, true)) { + $cellCount += $count; + } + } + + if ($cellCount > 0) { + $uncategorizedTotal += $cellCount; + $uncategorizedCells[$columnId] = [ + 'items' => [], // Uncategorized cards loaded on-demand if needed + 'total' => $cellCount, + ]; + } + } + + if ($uncategorizedTotal > 0) { + $swimlaneData['__uncategorized__'] = [ + 'id' => '__uncategorized__', + 'label' => __('flowforge::flowforge.uncategorized'), + 'color' => 'gray', + 'icon' => null, + 'total' => $uncategorizedTotal, + 'cells' => $uncategorizedCells, + ]; + } + + return [ + 'columns' => $columnHeaders, + 'swimlanes' => $swimlaneData, + 'config' => $config, ]; } diff --git a/src/Concerns/HasBoardRecords.php b/src/Concerns/HasBoardRecords.php index c8f7a8f9f..491ad384a 100644 --- a/src/Concerns/HasBoardRecords.php +++ b/src/Concerns/HasBoardRecords.php @@ -148,6 +148,88 @@ public function getBatchedBoardRecordCounts(): array ->toArray(); } + /** + * Get records for a specific cell (column × swimlane intersection) with cursor-based pagination. + */ + public function getBoardRecordsForCell(string $columnId, string $swimlaneId): Collection + { + $query = $this->getQuery(); + + if (! $query) { + return new Collection; + } + + $statusField = $this->getColumnIdentifierAttribute(); + $swimlaneField = $this->getSwimlaneIdentifierAttribute(); + $livewire = $this->getLivewire(); + + $cellKey = $columnId . '|' . $swimlaneId; + $limit = property_exists($livewire, 'columnCardLimits') + ? ($livewire->columnCardLimits[$cellKey] ?? $this->getCardsPerColumn()) + : $this->getCardsPerColumn(); + + $queryClone = (clone $query) + ->where($statusField, $columnId) + ->where($swimlaneField, $swimlaneId); + + // Apply table filters and search using Filament's native system + if ($livewire->getTable()->isFilterable() || $livewire->hasTableSearch()) { + $baseQuery = $livewire->getFilteredTableQuery(); + $queryClone = (clone $baseQuery) + ->where($statusField, $columnId) + ->where($swimlaneField, $swimlaneId); + } + + $positionField = $this->getPositionIdentifierAttribute(); + $keyName = $queryClone->getModel()->getKeyName(); + + if ($positionField && $this->modelHasColumn($queryClone->getModel(), $positionField)) { + $queryClone->orderBy($positionField, 'asc') + ->orderBy($keyName, 'asc'); + } + + return $queryClone->limit($limit)->get(); + } + + /** + * Get record counts for all column × swimlane cells in a single query using GROUP BY. + * + * @return array Keyed by "columnId|swimlaneId" => count + */ + public function getBatchedSwimlaneRecordCounts(): array + { + $query = $this->getQuery(); + + if (! $query) { + return []; + } + + $statusField = $this->getColumnIdentifierAttribute(); + $swimlaneField = $this->getSwimlaneIdentifierAttribute(); + $livewire = $this->getLivewire(); + + $queryClone = clone $query; + + // Apply table filters and search using Filament's native system + if ($livewire->getTable()->isFilterable() || $livewire->hasTableSearch()) { + $baseQuery = $livewire->getFilteredTableQuery(); + $queryClone = clone $baseQuery; + } + + $rows = $queryClone + ->select(DB::raw("{$statusField} as column_value, {$swimlaneField} as swimlane_value, COUNT(*) as total")) + ->groupBy($statusField, $swimlaneField) + ->get(); + + $counts = []; + foreach ($rows as $row) { + $key = $row->column_value . '|' . ($row->swimlane_value ?? '__uncategorized__'); + $counts[$key] = (int) $row->total; + } + + return $counts; + } + /** * Format a record for display with Infolist entries. */ diff --git a/src/Concerns/HasBoardSwimlanes.php b/src/Concerns/HasBoardSwimlanes.php new file mode 100644 index 000000000..2a213a05c --- /dev/null +++ b/src/Concerns/HasBoardSwimlanes.php @@ -0,0 +1,100 @@ + + */ + protected array $swimlanes = []; + + protected string | Closure | null $swimlaneIdentifierAttribute = null; + + /** + * Configure board swimlanes. + */ + public function swimlanes(array | Closure $swimlanes): static + { + $this->swimlanes = $this->evaluate($swimlanes); + + foreach ($this->swimlanes as $swimlane) { + if ($swimlane instanceof Swimlane) { + $swimlane->board($this); + } + } + + return $this; + } + + /** + * Set the swimlane identifier attribute (the model field used for grouping). + */ + public function swimlaneIdentifier(string | Closure $attribute): static + { + $this->swimlaneIdentifierAttribute = $attribute; + + return $this; + } + + /** + * Get configured swimlanes. + * + * @return array + */ + public function getSwimlanes(): array + { + return $this->swimlanes; + } + + /** + * Get the swimlane identifier attribute. + */ + public function getSwimlaneIdentifierAttribute(): ?string + { + return $this->evaluate($this->swimlaneIdentifierAttribute); + } + + /** + * Check if swimlanes are configured. + */ + public function hasSwimlanes(): bool + { + return ! empty($this->swimlanes) && $this->getSwimlaneIdentifierAttribute() !== null; + } + + /** + * Get swimlane identifiers. + * + * @return array + */ + public function getSwimlaneIdentifiers(): array + { + return array_map(fn (Swimlane $swimlane) => $swimlane->getName(), $this->swimlanes); + } + + /** + * Get a specific swimlane by identifier. + */ + public function getSwimlane(string $identifier): ?Swimlane + { + foreach ($this->swimlanes as $swimlane) { + if ($swimlane->getName() === $identifier) { + return $swimlane; + } + } + + return null; + } +} diff --git a/src/Concerns/InteractsWithBoard.php b/src/Concerns/InteractsWithBoard.php index 6b5ad5665..119f9ab7a 100644 --- a/src/Concerns/InteractsWithBoard.php +++ b/src/Concerns/InteractsWithBoard.php @@ -302,30 +302,36 @@ protected function isDuplicatePositionError(QueryException $e): bool str_contains($e->getMessage(), 'UNIQUE constraint failed'); } - public function loadMoreItems(string $columnId, ?int $count = null): void + /** + * Load more items for a column or cell. + * + * When swimlanes are active, the $cellKey uses the format "columnId|swimlaneId" + * to identify a specific cell. Without swimlanes, it's just the columnId. + */ + public function loadMoreItems(string $cellKey, ?int $count = null): void { $count = $count ?? $this->getBoard()->getCardsPerColumn(); // Set loading state - $this->loadingStates[$columnId] = true; + $this->loadingStates[$cellKey] = true; try { $board = $this->getBoard(); - $currentLimit = $this->columnCardLimits[$columnId] ?? $board->getCardsPerColumn(); + $currentLimit = $this->columnCardLimits[$cellKey] ?? $board->getCardsPerColumn(); $newLimit = $currentLimit + $count; - // Check if we have more items to load - $totalCount = $board->getBoardRecordCount($columnId); + // Determine total count based on whether this is a cell or column key + $totalCount = $this->getCellOrColumnCount($cellKey); $actualNewLimit = min($newLimit, $totalCount); - $this->columnCardLimits[$columnId] = $actualNewLimit; + $this->columnCardLimits[$cellKey] = $actualNewLimit; // Calculate how many items were actually loaded $actualLoadedCount = $actualNewLimit - $currentLimit; // Emit event for frontend update $this->dispatch('kanban-items-loaded', [ - 'columnId' => $columnId, + 'columnId' => $cellKey, 'loadedCount' => $actualLoadedCount, 'totalCount' => $totalCount, 'isFullyLoaded' => $actualNewLimit >= $totalCount, @@ -333,42 +339,58 @@ public function loadMoreItems(string $columnId, ?int $count = null): void } finally { // Clear loading state - $this->loadingStates[$columnId] = false; + $this->loadingStates[$cellKey] = false; + } + } + + /** + * Get total record count for a cell key ("columnId|swimlaneId") or plain column. + */ + protected function getCellOrColumnCount(string $cellKey): int + { + $board = $this->getBoard(); + + if (str_contains($cellKey, '|') && $board->hasSwimlanes()) { + [$columnId, $swimlaneId] = explode('|', $cellKey, 2); + $counts = $board->getBatchedSwimlaneRecordCounts(); + + return $counts[$cellKey] ?? 0; } + + return $board->getBoardRecordCount($cellKey); } /** - * Load all items in a column (disables pagination for that column). + * Load all items in a column or cell (disables pagination for that column/cell). */ - public function loadAllItems(string $columnId): void + public function loadAllItems(string $cellKey): void { - $this->loadingStates[$columnId] = true; + $this->loadingStates[$cellKey] = true; try { - $board = $this->getBoard(); - $totalCount = $board->getBoardRecordCount($columnId); + $totalCount = $this->getCellOrColumnCount($cellKey); // Set limit to total count to load everything - $this->columnCardLimits[$columnId] = $totalCount; + $this->columnCardLimits[$cellKey] = $totalCount; $this->dispatch('kanban-all-items-loaded', [ - 'columnId' => $columnId, + 'columnId' => $cellKey, 'totalCount' => $totalCount, ]); } finally { - $this->loadingStates[$columnId] = false; + $this->loadingStates[$cellKey] = false; } } /** - * Check if a column is fully loaded. + * Check if a column or cell is fully loaded. */ - public function isColumnFullyLoaded(string $columnId): bool + public function isColumnFullyLoaded(string $cellKey): bool { $board = $this->getBoard(); - $totalCount = $board->getBoardRecordCount($columnId); - $loadedCount = $this->columnCardLimits[$columnId] ?? $board->getCardsPerColumn(); + $totalCount = $this->getCellOrColumnCount($cellKey); + $loadedCount = $this->columnCardLimits[$cellKey] ?? $board->getCardsPerColumn(); return $loadedCount >= $totalCount; } diff --git a/src/FlowforgeServiceProvider.php b/src/FlowforgeServiceProvider.php index 8f51960e1..a1f76a38b 100644 --- a/src/FlowforgeServiceProvider.php +++ b/src/FlowforgeServiceProvider.php @@ -92,6 +92,8 @@ private function registerBladeComponents(): void Blade::component('flowforge::livewire.column', 'flowforge::column'); Blade::component('flowforge::livewire.empty-column', 'flowforge::empty-column'); Blade::component('flowforge::livewire.card', 'flowforge::card'); + Blade::component('flowforge::livewire.swimlane-board', 'flowforge::swimlane-board'); + Blade::component('flowforge::livewire.swimlane-cell', 'flowforge::swimlane-cell'); } protected function getAssetPackageName(): ?string diff --git a/src/Swimlane.php b/src/Swimlane.php new file mode 100644 index 000000000..6f8583173 --- /dev/null +++ b/src/Swimlane.php @@ -0,0 +1,100 @@ +name = $name; + } + + public static function make(?string $name = null): static + { + $swimlaneClass = static::class; + + $name ??= static::getDefaultName(); + + if (blank($name)) { + throw new Exception("Swimlane of class [$swimlaneClass] must have a unique name, passed to the [make()] method."); + } + + $static = app($swimlaneClass, ['name' => $name]); + $static->configure(); + + return $static; + } + + public static function getDefaultName(): ?string + { + return null; + } + + protected function setUp(): void + { + parent::setUp(); + } + + public function label(string | Htmlable | Closure | null $label): static + { + $this->label = $label; + + return $this; + } + + public function getLabel(): string | Htmlable | null + { + return $this->evaluate($this->label) ?? $this->generateDefaultLabel(); + } + + protected function generateDefaultLabel(): string + { + return str($this->getName()) + ->kebab() + ->replace(['-', '_'], ' ') + ->title() + ->toString(); + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return array + */ + protected function resolveDefaultClosureDependencyForEvaluationByName(string $parameterName): array + { + return match ($parameterName) { + 'swimlane' => [$this], + 'name' => [$this->getName()], + 'label' => [$this->getLabel()], + default => parent::resolveDefaultClosureDependencyForEvaluationByName($parameterName), + }; + } +} From 0e2bbc438f8d2498cfd8bc510d82ae030e80875a Mon Sep 17 00:00:00 2001 From: Dustin Date: Sat, 31 Jan 2026 22:55:42 -0600 Subject: [PATCH 2/9] Remove height constraints so swimlane board scrolls with page - Remove max-height and overflow-auto from swimlane-board container, keep only overflow-x-auto for wide boards - Remove max-height and overflow-y-auto from swimlane cells so cards expand to natural height - Remove overflow-hidden wrapper from swimlane board in index view so content is not clipped to viewport height Co-Authored-By: Claude Opus 4.5 --- resources/views/index.blade.php | 12 +++++------- resources/views/livewire/swimlane-board.blade.php | 3 +-- resources/views/livewire/swimlane-cell.blade.php | 4 ++-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index 939c5d567..9852fdca4 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -23,13 +23,11 @@ class="w-full h-full flex flex-col relative" @if($swimlanes) {{-- 2D Swimlane Grid Layout --}} -
- -
+ @else {{-- Flat Column Layout (original) --}}
diff --git a/resources/views/livewire/swimlane-board.blade.php b/resources/views/livewire/swimlane-board.blade.php index e85808f88..97f36e3f2 100644 --- a/resources/views/livewire/swimlane-board.blade.php +++ b/resources/views/livewire/swimlane-board.blade.php @@ -8,8 +8,7 @@ @endphp
@if (count($items) > 0) @foreach ($items as $record) From 799de28a2509c91a41a72e0c4d26b3793fc1e1df Mon Sep 17 00:00:00 2001 From: Dustin Date: Sat, 31 Jan 2026 23:02:36 -0600 Subject: [PATCH 3/9] Fix swimlane cell alignment and remove empty placeholders - Use inline vertical-align: top on td elements instead of Tailwind align-top class (not compiled from vendor blade by JIT) - Use inline padding on swimlane cells instead of Tailwind p-2 - Remove dashed placeholder boxes from empty cells - Remove min-height constraint from cells Co-Authored-By: Claude Opus 4.5 --- resources/views/livewire/swimlane-board.blade.php | 8 ++++---- resources/views/livewire/swimlane-cell.blade.php | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/resources/views/livewire/swimlane-board.blade.php b/resources/views/livewire/swimlane-board.blade.php index 97f36e3f2..825304f7e 100644 --- a/resources/views/livewire/swimlane-board.blade.php +++ b/resources/views/livewire/swimlane-board.blade.php @@ -68,8 +68,8 @@ class="sticky top-0 z-20 bg-white dark:bg-gray-900 border-b border-r border-gray {{-- Swimlane Label Cell (row header) --}}
@endif - @else -
-

-
@endif
From 5e7ad0e3694a87cc62095560ecc1e62024a9ac6f Mon Sep 17 00:00:00 2001 From: Dustin Date: Sat, 31 Jan 2026 23:44:59 -0600 Subject: [PATCH 4/9] Compact card layout with tighter spacing for schema entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce card margin (mb-3 → mb-2), title size (text-sm → text-xs), and padding (p-3 → px-3 pt-2 / px-3 pb-2) - Remove bottom margin from header row - Wrap schema output in .flowforge-card-schema div - Add scoped CSS to collapse Filament's default gap-6 between schema entries and gap-y-2 within entry wrappers to zero Co-Authored-By: Claude Opus 4.5 --- resources/views/livewire/card.blade.php | 27 +++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/resources/views/livewire/card.blade.php b/resources/views/livewire/card.blade.php index 33dfbb07f..3a1b56a8e 100644 --- a/resources/views/livewire/card.blade.php +++ b/resources/views/livewire/card.blade.php @@ -10,7 +10,7 @@
$hasActions || $hasCardAction, 'cursor-pointer transition-all duration-100 ease-in-out hover:shadow-lg hover:border-gray-400 active:shadow-md' => $hasCardAction, 'cursor-grab hover:cursor-grabbing' => $hasPositionIdentifier, @@ -24,8 +24,8 @@ data-position="{{ $record['position'] ?? '' }}" >
-
-

+

@if($hasActions) -
+
@endif
-
{{-- Render card schema with compact spacing --}} @if(filled($record['schema'])) - {{ $record['schema'] }} +
+ {{ $record['schema'] }} +
@endif

+ +@once + +@endonce From 0378f484b0ca321c1e07264951c9d025ca74ed46 Mon Sep 17 00:00:00 2001 From: Dustin Date: Sun, 1 Feb 2026 08:18:25 -0600 Subject: [PATCH 5/9] Fluid columns with fixed-width cards (300px max) Columns expand to fill available space (w-full table, min-width 300px) while cards are capped at max-w-[300px] for consistent sizing. Co-Authored-By: Claude Opus 4.5 --- resources/views/livewire/card.blade.php | 2 +- resources/views/livewire/swimlane-board.blade.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/card.blade.php b/resources/views/livewire/card.blade.php index 3a1b56a8e..59c5d1d2a 100644 --- a/resources/views/livewire/card.blade.php +++ b/resources/views/livewire/card.blade.php @@ -10,7 +10,7 @@
$hasActions || $hasCardAction, 'cursor-pointer transition-all duration-100 ease-in-out hover:shadow-lg hover:border-gray-400 active:shadow-md' => $hasCardAction, 'cursor-grab hover:cursor-grabbing' => $hasPositionIdentifier, diff --git a/resources/views/livewire/swimlane-board.blade.php b/resources/views/livewire/swimlane-board.blade.php index 825304f7e..38654bf0a 100644 --- a/resources/views/livewire/swimlane-board.blade.php +++ b/resources/views/livewire/swimlane-board.blade.php @@ -26,7 +26,7 @@ class="flowforge-swimlane-board overflow-x-auto rounded-xl border border-gray-20 } }" > - +
{{-- Sticky Column Header Row --}} @@ -40,7 +40,7 @@ class="flowforge-swimlane-board overflow-x-auto rounded-xl border border-gray-20 @endphp
@@ -125,7 +125,7 @@ class="w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-20 @endphp
From 2c00d26c96d991454e9030d97265811187fef7ab Mon Sep 17 00:00:00 2001 From: Dustin Date: Sun, 1 Feb 2026 08:56:28 -0600 Subject: [PATCH 6/9] Add optional card header and footer schema support New cardHeaderSchema() and cardFooterSchema() methods mirror the existing cardSchema() pattern. Header renders above the title, footer renders below body with a border separator. Both are optional and only render when configured. Co-Authored-By: Claude Opus 4.5 --- resources/views/livewire/card.blade.php | 56 +++++++++++++++---------- src/Concerns/HasBoardRecords.php | 18 ++++++++ src/Concerns/HasCardSchema.php | 56 +++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/resources/views/livewire/card.blade.php b/resources/views/livewire/card.blade.php index 59c5d1d2a..6433b1591 100644 --- a/resources/views/livewire/card.blade.php +++ b/resources/views/livewire/card.blade.php @@ -23,38 +23,50 @@ @endif data-position="{{ $record['position'] ?? '' }}" > -
+
-

+ {{-- Optional header (e.g. ticket number) --}} + @if(filled($record['headerSchema'] ?? null)) +
+ {{ $record['headerSchema'] }} +
@endif - > - {{ $record['title'] }} -

+ + {{-- Card title --}} +

+ {{ $record['title'] }} +

+
@if($hasActions) -
+
@endif
-
- {{-- Render card schema with compact spacing --}} - @if(filled($record['schema'])) -
- {{ $record['schema'] }} -
- @endif -
+ {{-- Card schema (body fields) --}} + @if(filled($record['schema'])) +
+ {{ $record['schema'] }} +
+ @endif
+ + {{-- Optional card footer --}} + @if(filled($record['footerSchema'] ?? null)) + + @endif
@once diff --git a/src/Concerns/HasBoardRecords.php b/src/Concerns/HasBoardRecords.php index 491ad384a..955e109c5 100644 --- a/src/Concerns/HasBoardRecords.php +++ b/src/Concerns/HasBoardRecords.php @@ -243,6 +243,15 @@ public function formatBoardRecord(Model $record): array 'model' => $record, ]; + // Process card header schema if available + $formatted['headerSchema'] = null; + $headerSchema = $this->getCardHeaderSchema($record); + + if ($headerSchema !== null) { + $headerSchema->model($record); + $formatted['headerSchema'] = $headerSchema; + } + // Process card schema if available $formatted['schema'] = null; $schema = $this->getCardSchema($record); @@ -255,6 +264,15 @@ public function formatBoardRecord(Model $record): array $formatted['schema'] = $schema; } + // Process card footer schema if available + $formatted['footerSchema'] = null; + $footerSchema = $this->getCardFooterSchema($record); + + if ($footerSchema !== null) { + $footerSchema->model($record); + $formatted['footerSchema'] = $footerSchema; + } + return $formatted; } diff --git a/src/Concerns/HasCardSchema.php b/src/Concerns/HasCardSchema.php index 20f8b5db1..0f577ca14 100644 --- a/src/Concerns/HasCardSchema.php +++ b/src/Concerns/HasCardSchema.php @@ -12,6 +12,10 @@ trait HasCardSchema { protected ?Closure $cardSchemaBuilder = null; + protected ?Closure $cardHeaderSchemaBuilder = null; + + protected ?Closure $cardFooterSchemaBuilder = null; + /** * Configure the card schema using the Schema builder pattern. */ @@ -22,6 +26,26 @@ public function cardSchema(Closure $builder): static return $this; } + /** + * Configure the card header schema using the Schema builder pattern. + */ + public function cardHeaderSchema(Closure $builder): static + { + $this->cardHeaderSchemaBuilder = $builder; + + return $this; + } + + /** + * Configure the card footer schema using the Schema builder pattern. + */ + public function cardFooterSchema(Closure $builder): static + { + $this->cardFooterSchemaBuilder = $builder; + + return $this; + } + /** * Get the configured card schema for a specific record. */ @@ -38,6 +62,38 @@ public function getCardSchema(Model $record): ?Schema return $this->evaluate($this->cardSchemaBuilder, ['schema' => $schema]); } + /** + * Get the configured card header schema for a specific record. + */ + public function getCardHeaderSchema(Model $record): ?Schema + { + if ($this->cardHeaderSchemaBuilder === null) { + return null; + } + + $livewire = $this->getLivewire(); + /** @phpstan-ignore argument.type (Filament Schema expects HasSchemas&Livewire\Component but getLivewire returns HasBoard) */ + $schema = Schema::make($livewire)->record($record); + + return $this->evaluate($this->cardHeaderSchemaBuilder, ['schema' => $schema]); + } + + /** + * Get the configured card footer schema for a specific record. + */ + public function getCardFooterSchema(Model $record): ?Schema + { + if ($this->cardFooterSchemaBuilder === null) { + return null; + } + + $livewire = $this->getLivewire(); + /** @phpstan-ignore argument.type (Filament Schema expects HasSchemas&Livewire\Component but getLivewire returns HasBoard) */ + $schema = Schema::make($livewire)->record($record); + + return $this->evaluate($this->cardFooterSchemaBuilder, ['schema' => $schema]); + } + /** * @return array */ From 723d3200f60fa2a27f2cf7e2ac9c68b11ac43cf5 Mon Sep 17 00:00:00 2001 From: Dustin Date: Mon, 14 Sep 2026 07:35:42 -0500 Subject: [PATCH 7/9] perf(swimlanes): batch cell records into one query instead of one per cell getViewDataWithSwimlanes() batches the cell counts and then throws that work away: the loop below reads $cellCount and calls getBoardRecordsForCell() on the very next line regardless, once per column x swimlane. On a board with 39 columns and 4 swimlanes that is 156 queries to render 31 cards. 150 of them ask for the contents of a cell the loop has already been told is empty. The cost per query is not uniform either. A board query built over a derived table -- the usual shape when the grouping values are computed rather than stored -- exposes its column and swimlane keys as aliases, so the per-cell WHERE cannot use an index and every cell re-runs the full projection. One query now fetches the filtered set and groups it by column|swimlane in memory. The per-cell limit, including the load-more override in $columnCardLimits, is applied to the group rather than in SQL, so counts and pagination behave as before. Ordering mirrors what getBoardRecordsForCell() applied so the grouped result keeps the same order. getBoardRecordsForCell() is untouched and still serves the load-more path. Measured on a 39x4 board rendering 31 cards, five runs each, medians: queries 210 -> 50 database 653ms -> 22ms server time 1984ms -> 1040ms Output verified byte-identical after normalising the per-request tokens Livewire stamps on every response, in both the all-columns and filtered states. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Vj99aoZp7rQPd7HFzG2DJ --- src/Board.php | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Board.php b/src/Board.php index 8604c1560..5718258bc 100644 --- a/src/Board.php +++ b/src/Board.php @@ -130,6 +130,39 @@ protected function getViewDataWithSwimlanes(array $config): array // Batch all cell counts in one query $cellCounts = $this->getBatchedSwimlaneRecordCounts(); + // Batch the cell RECORDS in one query too. + // + // The per-cell fetch below used to call getBoardRecordsForCell() once for + // every column x swimlane intersection, including cells whose count it had + // just read as zero on the line above. On a board with 39 columns and 4 + // swimlanes that is 156 queries to render 31 cards, 150 of them against + // cells already known to be empty. + // + // It is worse than the count suggests. Consumers commonly build the board + // query over a derived table whose grouping columns are computed aliases, + // so the per-cell WHERE cannot use an index and each query re-runs the whole + // projection. Measured on one such board: 210 queries and 653ms of database + // time before, 50 queries and 22ms after, for byte-identical output. + $statusField = $this->getColumnIdentifierAttribute(); + $swimlaneField = $this->getSwimlaneIdentifierAttribute(); + $livewire = $this->getLivewire(); + + $baseQuery = ($livewire->getTable()->isFilterable() || $livewire->hasTableSearch()) + ? clone $livewire->getFilteredTableQuery() + : clone $this->getQuery(); + + // Mirrors the ordering getBoardRecordsForCell() applies, so grouping the + // single result set yields the same order the per-cell queries did. + $positionField = $this->getPositionIdentifierAttribute(); + if ($positionField && $this->modelHasColumn($baseQuery->getModel(), $positionField)) { + $baseQuery->orderBy($positionField, 'asc') + ->orderBy($baseQuery->getModel()->getKeyName(), 'asc'); + } + + $recordsByCell = $baseQuery->get()->groupBy( + fn ($record) => $record->{$statusField} . '|' . $record->{$swimlaneField} + ); + // Build swimlane rows with cells $swimlaneData = []; $columnIds = array_keys($columnHeaders); @@ -144,8 +177,14 @@ protected function getViewDataWithSwimlanes(array $config): array $cellCount = $cellCounts[$cellKey] ?? 0; $laneTotal += $cellCount; - $records = $this->getBoardRecordsForCell($columnId, $swimlaneId); - $formattedRecords = $records->map(fn ($record) => $this->formatBoardRecord($record))->toArray(); + // Same per-cell limit getBoardRecordsForCell() would have applied, + // including the load-more override, but taken from the batch. + $cellLimit = property_exists($livewire, 'columnCardLimits') + ? ($livewire->columnCardLimits[$cellKey] ?? $this->getCardsPerColumn()) + : $this->getCardsPerColumn(); + + $records = ($recordsByCell[$cellKey] ?? collect())->take($cellLimit); + $formattedRecords = $records->map(fn ($record) => $this->formatBoardRecord($record))->values()->toArray(); $cells[$columnId] = [ 'items' => $formattedRecords, From 1c0a8da2d564efbd2aa4737296dfbc5207c87196 Mon Sep 17 00:00:00 2001 From: optmsp <67170673+optmsp@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:36:16 +0000 Subject: [PATCH 8/9] Fix styling --- src/BoardResourcePage.php | 3 ++- tests/Feature/ConcurrentPositionInsertionTest.php | 7 ++++--- tests/Feature/RetryMechanismTest.php | 6 ++++-- tests/Feature/ServiceProviderTest.php | 15 +++++++++------ 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/BoardResourcePage.php b/src/BoardResourcePage.php index b914a1caf..68e71ef64 100644 --- a/src/BoardResourcePage.php +++ b/src/BoardResourcePage.php @@ -4,6 +4,7 @@ namespace Relaticle\Flowforge; +use Filament\Actions\Action; use Filament\Actions\Contracts\HasActions; use Filament\Actions\Exceptions\ActionNotResolvableException; use Filament\Forms\Contracts\HasForms; @@ -45,7 +46,7 @@ abstract class BoardResourcePage extends Page implements HasActions, HasBoard, H * board action detection. * * @param array> $actions - * @return array<\Filament\Actions\Action> + * @return array * * @throws ActionNotResolvableException */ diff --git a/tests/Feature/ConcurrentPositionInsertionTest.php b/tests/Feature/ConcurrentPositionInsertionTest.php index 0a9f92083..5611bb015 100644 --- a/tests/Feature/ConcurrentPositionInsertionTest.php +++ b/tests/Feature/ConcurrentPositionInsertionTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Database\QueryException; use Relaticle\Flowforge\Services\DecimalPosition; use Relaticle\Flowforge\Tests\Fixtures\Task; @@ -38,7 +39,7 @@ 'order_position' => $position, ]); $insertedPositions[] = $task->order_position; - } catch (\Illuminate\Database\QueryException $e) { + } catch (QueryException $e) { // If we hit a duplicate (extremely rare), count it $failedInserts++; } @@ -89,7 +90,7 @@ 'order_position' => $position, ]); $insertedCount++; - } catch (\Illuminate\Database\QueryException) { + } catch (QueryException) { // Unique constraint violation - should never happen } } @@ -118,7 +119,7 @@ 'title' => 'Duplicate Card', 'status' => 'todo', 'order_position' => '1500.0000000000', - ]))->toThrow(\Illuminate\Database\QueryException::class); + ]))->toThrow(QueryException::class); }); test('positions remain sortable after many insertions', function () { diff --git a/tests/Feature/RetryMechanismTest.php b/tests/Feature/RetryMechanismTest.php index 8d39997d2..d67c5be96 100644 --- a/tests/Feature/RetryMechanismTest.php +++ b/tests/Feature/RetryMechanismTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\QueryException; +use Relaticle\Flowforge\Board; use Relaticle\Flowforge\Concerns\InteractsWithBoard; use Relaticle\Flowforge\Tests\Fixtures\Task; @@ -13,12 +15,12 @@ class RetryMechanismTestHelper isDuplicatePositionError as public; } - public function getBoard(): \Relaticle\Flowforge\Board + public function getBoard(): Board { throw new RuntimeException('Not implemented for testing'); } - public function getBoardQuery(): ?\Illuminate\Database\Eloquent\Builder + public function getBoardQuery(): ?Builder { return null; } diff --git a/tests/Feature/ServiceProviderTest.php b/tests/Feature/ServiceProviderTest.php index 3e8498939..5589de492 100644 --- a/tests/Feature/ServiceProviderTest.php +++ b/tests/Feature/ServiceProviderTest.php @@ -4,6 +4,9 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Blade; +use Relaticle\Flowforge\Board; +use Relaticle\Flowforge\BoardPage; +use Relaticle\Flowforge\Column; use Relaticle\Flowforge\Commands\MakeKanbanBoardCommand; use Relaticle\Flowforge\FlowforgeServiceProvider; @@ -24,7 +27,7 @@ $serviceProvider = new FlowforgeServiceProvider(app()); // Use reflection to test protected getAssets method - $reflection = new \ReflectionClass($serviceProvider); + $reflection = new ReflectionClass($serviceProvider); $method = $reflection->getMethod('getAssets'); $method->setAccessible(true); $assets = $method->invoke($serviceProvider); @@ -60,7 +63,7 @@ $serviceProvider = new FlowforgeServiceProvider(app()); // Use reflection to test protected getScriptData method - $reflection = new \ReflectionClass($serviceProvider); + $reflection = new ReflectionClass($serviceProvider); $method = $reflection->getMethod('getScriptData'); $method->setAccessible(true); $scriptData = $method->invoke($serviceProvider); @@ -81,8 +84,8 @@ }); test('core classes exist and are autoloadable', function () { - expect(class_exists(\Relaticle\Flowforge\Board::class))->toBeTrue(); - expect(class_exists(\Relaticle\Flowforge\BoardPage::class))->toBeTrue(); - expect(class_exists(\Relaticle\Flowforge\Column::class))->toBeTrue(); - expect(class_exists(\Relaticle\Flowforge\Commands\MakeKanbanBoardCommand::class))->toBeTrue(); + expect(class_exists(Board::class))->toBeTrue(); + expect(class_exists(BoardPage::class))->toBeTrue(); + expect(class_exists(Column::class))->toBeTrue(); + expect(class_exists(MakeKanbanBoardCommand::class))->toBeTrue(); }); From 8d079f96d607507cc01cda31fd5c124a0fb91988 Mon Sep 17 00:00:00 2001 From: Dustin Date: Mon, 14 Sep 2026 07:55:10 -0500 Subject: [PATCH 9/9] fix(livewire): rebuild the board after a table state update A board whose columns, query or swimlanes derive from $tableFilters or $tableSearch rendered one round trip stale. Toggling a filter that adds or removes columns returned the previous column set, and it stayed wrong until something unrelated triggered another render -- a poll, or any other action. bootedInteractsWithBoard() builds the board, and Livewire runs booted() before it applies the incoming property update. So board() reads the filter state from the request before the one being handled. Observed by intercepting the toggle's own livewire/update response: switching a filter from 3 columns to 39 returned a payload containing 3 column headers. booted() still builds it, because cacheBoardActions() has to run before Filament processes the request. The new hook rebuilds once the new state is in place. Scoped to table state on purpose. An unguarded updated hook also fires for $columnCardLimits, which load-more-on-scroll writes to, and would throw away the pagination it had just set. Verified on a board that swings between 3 and 39 columns: four consecutive toggles, both directions, each returning the correct column set in its own response. Request cost unchanged at 50-51 queries. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Vj99aoZp7rQPd7HFzG2DJ --- src/Concerns/InteractsWithBoard.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Concerns/InteractsWithBoard.php b/src/Concerns/InteractsWithBoard.php index 119f9ab7a..7328e29d1 100644 --- a/src/Concerns/InteractsWithBoard.php +++ b/src/Concerns/InteractsWithBoard.php @@ -51,6 +51,34 @@ public function bootedInteractsWithBoard(): void $this->cacheBoardActions(); } + /** + * Rebuild the board after Livewire applies a table state update. + * + * booted() runs before incoming property updates are applied, so a board + * whose column set, query or swimlanes depend on $tableFilters or + * $tableSearch is built from the previous request's state and renders one + * round trip stale. Toggling a filter that adds or removes columns returns + * the old column set, and it stays wrong until something unrelated triggers + * another render. + * + * booted() still builds the board, because cacheBoardActions() has to run + * before Filament processes the request; this only rebuilds it once the new + * state is actually in place. + * + * Only table state is rebuilt on. Rebuilding on every property update would + * also fire for $columnCardLimits, which load-more-on-scroll writes to, and + * discard the pagination it just set. + */ + public function updatedInteractsWithBoard(string $property): void + { + if (! str_starts_with($property, 'tableFilters') && ! str_starts_with($property, 'tableSearch')) { + return; + } + + $this->board = $this->board($this->makeBoard()); + $this->cacheBoardActions(); + } + /** * Cache board actions for Filament's action system. */