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..9852fdca4 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/card.blade.php b/resources/views/livewire/card.blade.php index 33dfbb07f..6433b1591 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, @@ -23,34 +23,61 @@ @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'])) + {{-- Card schema (body fields) --}} + @if(filled($record['schema'])) +
{{ $record['schema'] }} - @endif -
+
+ @endif
+ + {{-- Optional card footer --}} + @if(filled($record['footerSchema'] ?? null)) + + @endif
+ +@once + +@endonce diff --git a/resources/views/livewire/swimlane-board.blade.php b/resources/views/livewire/swimlane-board.blade.php new file mode 100644 index 000000000..38654bf0a --- /dev/null +++ b/resources/views/livewire/swimlane-board.blade.php @@ -0,0 +1,146 @@ +@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..9c8085c42 --- /dev/null +++ b/resources/views/livewire/swimlane-cell.blade.php @@ -0,0 +1,46 @@ +@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 overflow-x-hidden kanban-cards" + style="padding: 0.5rem;" +> + @if (count($items) > 0) + @foreach ($items as $record) + + @endforeach + + @if($total > count($items)) +
+
+ {{ __('flowforge::flowforge.loading_more_cards') }} +
+
+ @endif + @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..955e109c5 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. */ @@ -161,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); @@ -173,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/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/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 */ 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), + }; + } +}