From 1f1d74b9b5361d9c5b1a06344ac5090c679ced18 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Wed, 14 Jan 2026 23:37:26 +0400 Subject: [PATCH 1/4] feat: implement filtersLayout() support for board filters Add support for different filter layout options following Filament v4's exact pattern. Now supports: - FiltersLayout::Dropdown (default) - filters in dropdown button - FiltersLayout::Modal - filters in modal dialog - FiltersLayout::AboveContent - inline filters above board - FiltersLayout::AboveContentCollapsible - collapsible filters above board - FiltersLayout::Hidden - hide filters completely This fixes the issue where filtersLayout() was documented but not working because the view always rendered filters as a dropdown. Fixes: https://github.com/Relaticle/flowforge/discussions/72 --- resources/views/components/filters.blade.php | 223 ++++++++++++++----- 1 file changed, 169 insertions(+), 54 deletions(-) diff --git a/resources/views/components/filters.blade.php b/resources/views/components/filters.blade.php index 4bf7f6cf1..5da21bcd2 100644 --- a/resources/views/components/filters.blade.php +++ b/resources/views/components/filters.blade.php @@ -1,70 +1,185 @@ @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\Enums\FiltersResetActionPosition; + use Filament\Tables\Filters\Indicator; + use Filament\Tables\View\TablesIconAlias; use Filament\Tables\View\TablesRenderHook; + use Illuminate\View\ComponentAttributeBag; + + use function Filament\Support\generate_icon_html; + use function Filament\Support\prepare_inherited_attributes; - use function Filament\Support\generate_icon_html;use function Filament\Support\prepare_inherited_attributes; $table = $this->getTable(); $isFilterable = $table->isFilterable(); $isFiltered = $table->isFiltered(); $isSearchable = $table->isSearchable(); $filterIndicators = $table->getFilterIndicators(); + + // Get filter layout configuration (Filament v4 pattern) + $filtersLayout = $table->getFiltersLayout(); + $filtersTriggerAction = $table->getFiltersTriggerAction(); + $filtersApplyAction = $table->getFiltersApplyAction(); + $filtersForm = $this->getTableFiltersForm(); + $filtersFormWidth = $table->getFiltersFormWidth(); + $filtersFormMaxHeight = $table->getFiltersFormMaxHeight(); + $filtersResetActionPosition = $table->getFiltersResetActionPosition(); + $activeFiltersCount = $table->getActiveFiltersCount(); + + // Boolean flags based on layout (Filament v4 pattern) + $hasFiltersDialog = $isFilterable && in_array($filtersLayout, [FiltersLayout::Dropdown, FiltersLayout::Modal]); + $hasFiltersAboveContent = $isFilterable && in_array($filtersLayout, [FiltersLayout::AboveContent, FiltersLayout::AboveContentCollapsible]); + $hasCollapsibleFilters = $isFilterable && ($filtersLayout === FiltersLayout::AboveContentCollapsible); + $isFiltersHidden = $filtersLayout === FiltersLayout::Hidden; @endphp +@if ($isFilterable && ! $isFiltersHidden) + {{-- Filters Above Content --}} + @if ($hasFiltersAboveContent) +
+ + + @if ($hasCollapsibleFilters) +
+ + {{ $filtersTriggerAction->badge($activeFiltersCount) }} + +
+ @endif +
+ @endif +@endif
- @if($isFilterable) - - - {{ $table->getFiltersTriggerAction()->badge($table->getActiveFiltersCount()) }} - - -
-
-

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

- -
- - {{ __('filament-tables::table.filters.actions.reset.label') }} - + @if ($isFilterable && ! $isFiltersHidden) + {{-- Filters in Dropdown --}} + @if ($filtersLayout === FiltersLayout::Dropdown) + + + {{ $filtersTriggerAction->badge($activeFiltersCount) }} + + +
+
+

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

+ +
+ + {{ __('filament-tables::table.filters.actions.reset.label') }} + +
-
- {{ $this->getTableFiltersForm() }} + {{ $filtersForm }} - - @if ($table->getFiltersApplyAction()->isVisible()) -
- {{ $table->getFiltersApplyAction() }} -
- @endif -
- - + @if ($filtersApplyAction->isVisible()) +
+ {{ $filtersApplyAction }} +
+ @endif +
+ + + {{-- Filters in Modal --}} + @elseif ($filtersLayout === FiltersLayout::Modal) + @php + $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 + + + + {{ $filtersTriggerAction->badge($activeFiltersCount) }} + + + {{ $filtersTriggerAction->getModalContent() }} + + {{ $filtersForm }} + + {{ $filtersTriggerAction->getModalContentFooter() }} + + @endif @endif - @if($isSearchable) - {{-- Search input --}} + @if ($isSearchable)
- - {{ __('filament-tables::table.filters.indicator') }} - + + {{ __('filament-tables::table.filters.indicator') }} +
@foreach ($filterIndicators as $indicator) @@ -114,9 +229,9 @@ class="fi-ta-filters-dropdown z-40" + @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 349a59ea4..030fb2f47 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 a98494f07..69d973ba3 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'; From 4dcd376998beee65d0ed082390c164a1ee1a4d72 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Sat, 17 Jan 2026 16:26:03 +0400 Subject: [PATCH 3/4] feat: add url persistence for board filters enable shareable filter state via Livewire #[Url] attribute --- src/BoardPage.php | 7 +++++++ src/BoardResourcePage.php | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/BoardPage.php b/src/BoardPage.php index 8fcc0a975..9527d20e6 100644 --- a/src/BoardPage.php +++ b/src/BoardPage.php @@ -7,6 +7,7 @@ use Filament\Actions\Contracts\HasActions; use Filament\Forms\Contracts\HasForms; use Filament\Pages\Page; +use Livewire\Attributes\Url; use Relaticle\Flowforge\Concerns\BaseBoard; use Relaticle\Flowforge\Contracts\HasBoard; @@ -19,4 +20,10 @@ abstract class BoardPage extends Page implements HasActions, HasBoard, HasForms use BaseBoard; protected string $view = 'flowforge::filament.pages.board-page'; + + /** + * @var array|null + */ + #[Url(as: 'filters')] + public ?array $tableFilters = null; } diff --git a/src/BoardResourcePage.php b/src/BoardResourcePage.php index 4b5cfa21a..b914a1caf 100644 --- a/src/BoardResourcePage.php +++ b/src/BoardResourcePage.php @@ -8,6 +8,7 @@ use Filament\Actions\Exceptions\ActionNotResolvableException; use Filament\Forms\Contracts\HasForms; use Filament\Resources\Pages\Page; +use Livewire\Attributes\Url; use Relaticle\Flowforge\Concerns\BaseBoard; use Relaticle\Flowforge\Contracts\HasBoard; @@ -26,6 +27,12 @@ abstract class BoardResourcePage extends Page implements HasActions, HasBoard, H protected string $view = 'flowforge::filament.pages.board-page'; + /** + * @var array|null + */ + #[Url(as: 'filters')] + public ?array $tableFilters = null; + /** * Override Filament's action resolution to detect and route board actions. * From dd5adbd7df16f830a925c0475d9bc2e353e92f67 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Sat, 17 Jan 2026 16:26:07 +0400 Subject: [PATCH 4/4] chore: remove unused areFiltersOpen property from alpine component --- resources/dist/flowforge.js | 2 +- resources/js/flowforge.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/dist/flowforge.js b/resources/dist/flowforge.js index 9b1bdb220..065cf2c2e 100644 --- a/resources/dist/flowforge.js +++ b/resources/dist/flowforge.js @@ -1 +1 @@ -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}; +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}; diff --git a/resources/js/flowforge.js b/resources/js/flowforge.js index ec2c91dda..9fef93892 100644 --- a/resources/js/flowforge.js +++ b/resources/js/flowforge.js @@ -3,7 +3,6 @@ export default function flowforge({state}) { state, isLoading: {}, fullyLoaded: {}, - areFiltersOpen: false, init() { this.$wire.$on('kanban-items-loaded', (event) => {