diff --git a/resources/dist/flowforge.js b/resources/dist/flowforge.js index 065cf2c..9b1bdb2 100644 --- a/resources/dist/flowforge.js +++ b/resources/dist/flowforge.js @@ -1 +1 @@ -function d({state:l}){return{state:l,isLoading:{},fullyLoaded:{},init(){this.$wire.$on("kanban-items-loaded",t=>{let{columnId:e,isFullyLoaded:i}=t;i&&(this.fullyLoaded[e]=!0)})},handleSortableEnd(t){let e=t.to.sortable.toArray(),i=t.item.getAttribute("x-sortable-item"),a=t.to.getAttribute("data-column-id"),o=t.item;this.setCardState(o,!0);let s=e.indexOf(i),r=s>0?e[s-1]:null,n=sthis.setCardState(o,!1)).catch(()=>this.setCardState(o,!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:i,scrollHeight:a,clientHeight:o}=t.target;(i+o)/a>=.8&&!this.isLoadingColumn(e)&&this.handleSmoothScroll(e)}}}export{d as default}; +function d({state:l}){return{state:l,isLoading:{},fullyLoaded:{},areFiltersOpen:!1,init(){this.$wire.$on("kanban-items-loaded",t=>{let{columnId:e,isFullyLoaded:s}=t;s&&(this.fullyLoaded[e]=!0)})},handleSortableEnd(t){let e=t.to.sortable.toArray(),s=t.item.getAttribute("x-sortable-item"),a=t.to.getAttribute("data-column-id"),i=t.item;this.setCardState(i,!0);let o=e.indexOf(s),r=o>0?e[o-1]:null,n=othis.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:s,scrollHeight:a,clientHeight:i}=t.target;(s+i)/a>=.8&&!this.isLoadingColumn(e)&&this.handleSmoothScroll(e)}}}export{d as default}; diff --git a/resources/js/flowforge.js b/resources/js/flowforge.js index 9fef938..ec2c91d 100644 --- a/resources/js/flowforge.js +++ b/resources/js/flowforge.js @@ -3,6 +3,7 @@ export default function flowforge({state}) { state, isLoading: {}, fullyLoaded: {}, + areFiltersOpen: false, init() { this.$wire.$on('kanban-items-loaded', (event) => { diff --git a/resources/views/components/filters.blade.php b/resources/views/components/filters.blade.php index 4bf7f6c..ec4b673 100644 --- a/resources/views/components/filters.blade.php +++ b/resources/views/components/filters.blade.php @@ -1,131 +1,324 @@ @php - use Filament\Support\Enums\IconSize;use Filament\Support\Icons\Heroicon;use Filament\Tables\Filters\Indicator;use Filament\Tables\View\TablesIconAlias;use Illuminate\View\ComponentAttributeBag; + use Filament\Support\Enums\IconSize; + use Filament\Support\Enums\Width; use Filament\Support\Facades\FilamentView; + use Filament\Support\Icons\Heroicon; + use Filament\Tables\Enums\FiltersLayout; + use Filament\Tables\Filters\Indicator; + use Filament\Tables\View\TablesIconAlias; use Filament\Tables\View\TablesRenderHook; - use function Filament\Support\generate_icon_html;use function Filament\Support\prepare_inherited_attributes; + use function Filament\Support\generate_icon_html; + $table = $this->getTable(); $isFilterable = $table->isFilterable(); - $isFiltered = $table->isFiltered(); $isSearchable = $table->isSearchable(); $filterIndicators = $table->getFilterIndicators(); -@endphp + // Filter layout configuration (matches Filament v4 exactly) + $filtersLayout = $table->getFiltersLayout(); + $filtersTriggerAction = $table->getFiltersTriggerAction(); + $filtersApplyAction = $table->getFiltersApplyAction(); + $filtersForm = $this->getTableFiltersForm(); + $filtersFormWidth = $table->getFiltersFormWidth(); + $filtersFormMaxHeight = $table->getFiltersFormMaxHeight(); + $filtersResetActionPosition = $table->getFiltersResetActionPosition(); + $activeFiltersCount = $table->getActiveFiltersCount(); -
-
- @if($isFilterable) - - - {{ $table->getFiltersTriggerAction()->badge($table->getActiveFiltersCount()) }} - + // Convert string width to Width enum if needed + if (is_string($filtersFormWidth)) { + $filtersFormWidth = Width::tryFrom($filtersFormWidth) ?? $filtersFormWidth; + } -
-
-

- {{ __('filament-tables::table.filters.heading') }} -

+ // Boolean flags based on layout (matches Filament v4 exactly) + $hasFiltersDialog = $isFilterable && in_array($filtersLayout, [FiltersLayout::Dropdown, FiltersLayout::Modal]); + $hasFiltersAboveContent = $isFilterable && in_array($filtersLayout, [FiltersLayout::AboveContent, FiltersLayout::AboveContentCollapsible]); + $hasFiltersBelowContent = $isFilterable && ($filtersLayout === FiltersLayout::BelowContent); + $hasFiltersBeforeContent = $isFilterable && in_array($filtersLayout, [FiltersLayout::BeforeContent, FiltersLayout::BeforeContentCollapsible]); + $hasFiltersAfterContent = $isFilterable && in_array($filtersLayout, [FiltersLayout::AfterContent, FiltersLayout::AfterContentCollapsible]); + $hasCollapsibleFilters = $isFilterable && in_array($filtersLayout, [ + FiltersLayout::AboveContentCollapsible, + FiltersLayout::BeforeContentCollapsible, + FiltersLayout::AfterContentCollapsible, + ]); + $hasFiltersTrigger = $isFilterable && ($hasFiltersDialog || $hasFiltersBeforeContent || $hasFiltersAfterContent); + $isFiltersHidden = $filtersLayout === FiltersLayout::Hidden; -
- - {{ __('filament-tables::table.filters.actions.reset.label') }} - -
-
+ // Toolbar visibility + $hasHeaderToolbar = $isSearchable || $hasFiltersTrigger; +@endphp - {{ $this->getTableFiltersForm() }} +{{-- Hidden layout: render nothing --}} +@if ($isFiltersHidden) + {{-- No filter UI --}} +@elseif ($isFilterable || $isSearchable) + {{-- Main container with Filament's table CSS cascade --}} +
$hasFiltersBeforeContent || $hasFiltersAfterContent, + ]) + > + {{-- BeforeContent sidebar (left of board) --}} + @if ($hasFiltersBeforeContent) +
! $hasCollapsibleFilters, + (($filtersFormWidth ??= Width::ExtraSmall) instanceof Width) ? "fi-width-{$filtersFormWidth->value}" : (is_string($filtersFormWidth) ? $filtersFormWidth : null), + ]) + > + +
+ @endif + {{-- Main content area --}} +
+ {{-- Filters Above Content (AboveContent and AboveContentCollapsible) --}} + @if ($hasFiltersAboveContent) +
+ - @if ($table->getFiltersApplyAction()->isVisible()) -
- {{ $table->getFiltersApplyAction() }} -
+ @if ($hasCollapsibleFilters) + + {{ $filtersTriggerAction->badge($activeFiltersCount) }} + @endif
+ @endif - - @endif - - @if($isSearchable) - {{-- Search input --}} - - @endif -
- - @if ($filterIndicators) - @if (filled($filterIndicatorsView = FilamentView::renderHook(TablesRenderHook::FILTER_INDICATORS, scopes: static::class, data: ['filterIndicators' => $filterIndicators]))) - {{ $filterIndicatorsView }} - @else -
-
- - {{ __('filament-tables::table.filters.indicator') }} + {{-- Toolbar with search and dialog triggers (for Dropdown/Modal/Before/After layouts) --}} + @if ($hasHeaderToolbar) +
+ {{-- Filters trigger for sidebar layouts (BeforeContent/AfterContent) --}} + @if ($hasFiltersBeforeContent || $hasFiltersAfterContent) + + {{ $filtersTriggerAction->badge($activeFiltersCount) }} + @endif -
- @foreach ($filterIndicators as $indicator) + {{-- Filters Dialog (Dropdown or Modal) --}} + @if ($hasFiltersDialog) + @if (($filtersLayout === FiltersLayout::Modal) || $filtersTriggerAction->isModalSlideOver()) @php - $indicatorColor = $indicator->getColor(); + $filtersTriggerActionModalAlignment = $filtersTriggerAction->getModalAlignment(); + $filtersTriggerActionIsModalAutofocused = $filtersTriggerAction->isModalAutofocused(); + $filtersTriggerActionHasModalCloseButton = $filtersTriggerAction->hasModalCloseButton(); + $filtersTriggerActionIsModalClosedByClickingAway = $filtersTriggerAction->isModalClosedByClickingAway(); + $filtersTriggerActionIsModalClosedByEscaping = $filtersTriggerAction->isModalClosedByEscaping(); + $filtersTriggerActionModalDescription = $filtersTriggerAction->getModalDescription(); + $filtersTriggerActionVisibleModalFooterActions = $filtersTriggerAction->getVisibleModalFooterActions(); + $filtersTriggerActionModalFooterActionsAlignment = $filtersTriggerAction->getModalFooterActionsAlignment(); + $filtersTriggerActionModalHeading = $filtersTriggerAction->getCustomModalHeading() ?? __('filament-tables::table.filters.heading'); + $filtersTriggerActionModalIcon = $filtersTriggerAction->getModalIcon(); + $filtersTriggerActionModalIconColor = $filtersTriggerAction->getModalIconColor(); + $filtersTriggerActionIsModalSlideOver = $filtersTriggerAction->isModalSlideOver(); + $filtersTriggerActionIsModalFooterSticky = $filtersTriggerAction->isModalFooterSticky(); + $filtersTriggerActionIsModalHeaderSticky = $filtersTriggerAction->isModalHeaderSticky(); @endphp - - {{ $indicator->getLabel() }} + + + {{ $filtersTriggerAction->badge($activeFiltersCount) }} + + + {{ $filtersTriggerAction->getModalContent() }} + + {{ $filtersForm }} - @if ($indicator->isRemovable()) + {{ $filtersTriggerAction->getModalContentFooter() }} + + @else + + + {{ $filtersTriggerAction->badge($activeFiltersCount) }} + + + + + @endif + @endif + + @if ($isSearchable) + + @endif +
+ @endif + + {{-- Board content slot (rendered by parent view) --}} + {{ $slot ?? '' }} + + {{-- Filters Below Content --}} + @if ($hasFiltersBelowContent) +
+ +
+ @endif + + {{-- Filter indicators --}} + @if ($filterIndicators) + @if (filled($filterIndicatorsView = FilamentView::renderHook(TablesRenderHook::FILTER_INDICATORS, scopes: static::class, data: ['filterIndicators' => $filterIndicators]))) + {{ $filterIndicatorsView }} + @else +
+
+ + {{ __('filament-tables::table.filters.indicator') }} + + +
+ @foreach ($filterIndicators as $indicator) @php - $indicatorRemoveLivewireClickHandler = $indicator->getRemoveLivewireClickHandler(); + $indicatorColor = $indicator->getColor(); @endphp - - @endif - - @endforeach -
-
+ + {{ $indicator->getLabel() }} - @if (collect($filterIndicators)->contains(fn (Indicator $indicator): bool => $indicator->isRemovable())) - + @if ($indicator->isRemovable()) + @php + $indicatorRemoveLivewireClickHandler = $indicator->getRemoveLivewireClickHandler(); + @endphp + + + @endif + + @endforeach +
+
+ + @if (collect($filterIndicators)->contains(fn (Indicator $indicator): bool => $indicator->isRemovable())) + + @endif +
@endif + @endif +
+ + {{-- AfterContent sidebar (right of board) --}} + @if ($hasFiltersAfterContent) +
! $hasCollapsibleFilters, + (($filtersFormWidth ??= Width::ExtraSmall) instanceof Width) ? "fi-width-{$filtersFormWidth->value}" : (is_string($filtersFormWidth) ? $filtersFormWidth : null), + ]) + > +
@endif - @endif -
+
+@elseif ($isSearchable) +
+
+ +
+
+@endif diff --git a/src/Concerns/HasBoardFilters.php b/src/Concerns/HasBoardFilters.php index 349a59e..030fb2f 100644 --- a/src/Concerns/HasBoardFilters.php +++ b/src/Concerns/HasBoardFilters.php @@ -5,34 +5,78 @@ namespace Relaticle\Flowforge\Concerns; use Closure; -use Filament\Support\Enums\Width; +use Filament\Tables\Enums\FiltersLayout; +use Filament\Tables\Filters\BaseFilter; use Filament\Tables\Table\Concerns\HasFilters; /** - * Minimal board filters - just stores filter definitions. + * Provides filter configuration for boards by extending Filament's HasFilters trait. + * + * We override only the methods that assume $this is a Table (since Board is a ViewComponent). + * All other configuration methods (filtersFormColumns, filtersLayout, etc.) work directly + * from the parent trait. */ trait HasBoardFilters { - use HasFilters; + use HasFilters { + filters as filamentFilters; + } - protected array $boardFilters = []; + /** + * Override filters() to not call $filter->table($this) since Board is not a Table. + * The actual table binding happens in InteractsWithBoardTable when filters are passed to the Table. + * + * @param array|Closure $filters + */ + public function filters(array | Closure $filters, FiltersLayout | string | Closure | null $layout = null): static + { + $this->filters = []; - protected Width | string | Closure | null $filtersFormWidth = null; + $evaluatedFilters = $this->evaluate($filters); - public function filters(array | Closure $filters): static - { - $this->boardFilters = $this->evaluate($filters); + foreach ($evaluatedFilters as $filter) { + $this->filters[$filter->getName()] = $filter; + } + + if ($layout !== null) { + $this->filtersLayout($layout); + } return $this; } + /** + * Get filters for board configuration. + * Alias for consistency with existing board API. + * + * @return array + */ public function getBoardFilters(): array { - return $this->boardFilters; + return $this->filters; } + /** + * Check if the board has filters defined. + */ public function hasBoardFilters(): bool { - return ! empty($this->boardFilters); + return ! empty($this->filters); + } + + /** + * Get the callback to modify the filters trigger action. + */ + public function getFiltersTriggerActionModifier(): ?Closure + { + return $this->modifyFiltersTriggerActionUsing; + } + + /** + * Get the callback to modify the filters apply action. + */ + public function getFiltersApplyActionModifier(): ?Closure + { + return $this->modifyFiltersApplyActionUsing; } } diff --git a/src/Concerns/InteractsWithBoardTable.php b/src/Concerns/InteractsWithBoardTable.php index a98494f..69d973b 100644 --- a/src/Concerns/InteractsWithBoardTable.php +++ b/src/Concerns/InteractsWithBoardTable.php @@ -9,36 +9,48 @@ use Filament\Tables\Table; /** - * Provides table functionality to any Livewire component that has a Board. - * This allows pure Livewire components to use Board filters without extending BoardPage. + * Bridges Board filter configuration to Filament's Table. + * + * The Board stores filter configuration via HasBoardFilters (which uses Filament's HasFilters trait). + * This trait passes all that configuration to the actual Table component. */ trait InteractsWithBoardTable { use InteractsWithTable; - /** - * Get table from board configuration. - */ public function table(Table $table): Table { $board = $this->getBoard(); $searchableColumns = collect($board->getSearchableFields()) - ->map(fn ($field) => Column::make($field)->searchable())->toArray(); + ->map(fn ($field) => Column::make($field)->searchable()) + ->toArray(); - return $table + $table = $table ->queryStringIdentifier('board') ->query($board->getQuery()) + ->columns($searchableColumns) ->filters($board->getBoardFilters()) ->filtersFormWidth($board->getFiltersFormWidth()) ->filtersFormColumns($board->getFiltersFormColumns()) + ->filtersFormMaxHeight($board->getFiltersFormMaxHeight()) ->filtersLayout($board->getFiltersLayout()) - ->columns($searchableColumns); + ->filtersResetActionPosition($board->getFiltersResetActionPosition()) + ->deferFilters($board->hasDeferredFilters()) + ->persistFiltersInSession($board->persistsFiltersInSession()) + ->deselectAllRecordsWhenFiltered($board->shouldDeselectAllRecordsWhenFiltered()); + + if ($triggerModifier = $board->getFiltersTriggerActionModifier()) { + $table->filtersTriggerAction($triggerModifier); + } + + if ($applyModifier = $board->getFiltersApplyActionModifier()) { + $table->filtersApplyAction($applyModifier); + } + + return $table; } - /** - * Override to use board-specific query string identifier. - */ protected function getTableQueryStringIdentifier(): ?string { return 'board';