[4.x] Add Deferred Filters Support for Chart Widget (#19082)

* feat(chart-widget): add support for deferred filters

- Implemented deferred filter updates to improve user experience by allowing users to apply multiple filters before updating the chart.
- Added "Apply" and "Reset" actions for deferred filters with customizable labels.
- Enhanced the chart widget's CSS for better filter layout and styling.
- Updated the chart widget's Blade view to accommodate new filter actions and structure.
- Introduced tests for deferred filters functionality, ensuring correct behavior for applying and resetting filters.

* refactor: resolved phpstan error & optimize trait mounting

* Fix instantiation of ComponentAttributeBag to support PHP =< 8.4

Signed-off-by: M. Muqiit Faturrahman <24507383+mmuqiitf@users.noreply.github.com>

* feat(chart-widget): add method to count active filters and update trigger action

* formatting

* cleanup

* Update 03-charts.md

* fix

* Update chart-widget.blade.php

* translations

* css

* css

* cs

* Update HasFiltersSchema.php

* styling

* css

* cs

* Update ChartWidgetTest.php

---------

Signed-off-by: M. Muqiit Faturrahman <24507383+mmuqiitf@users.noreply.github.com>
Co-authored-by: Dan Harrin <git@danharrin.com>
This commit is contained in:
M. Muqiit Faturrahman
2026-02-04 04:29:21 +07:00
committed by GitHub
parent 65f418f59f
commit 64110555a8
11 changed files with 387 additions and 21 deletions
+8 -8
View File
@@ -91,7 +91,7 @@
"mime": "^4.0.4",
"nouislider": "^15.8.1",
"npm-run-all": "^4.1.5",
"playwright": "^1.57.0",
"playwright": "^1.58.0",
"prettier": "^3.1.1",
"prettier-plugin-blade": "v2.0.0",
"prettier-plugin-tailwindcss": "^0.6.10",
@@ -10999,13 +10999,13 @@
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
@@ -11018,9 +11018,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+1 -1
View File
@@ -99,7 +99,7 @@
"mime": "^4.0.4",
"nouislider": "^15.8.1",
"npm-run-all": "^4.1.5",
"playwright": "^1.57.0",
"playwright": "^1.58.0",
"prettier": "^3.1.1",
"prettier-plugin-blade": "v2.0.0",
"prettier-plugin-tailwindcss": "^0.6.10",
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -179,12 +179,15 @@
& > .fi-section-header-after-ctn {
@apply self-center;
& .fi-sc-text,
& .fi-link {
&
.fi-sc-text:not(
.fi-section-header-after-ctn .fi-dropdown-panel *
),
& .fi-link:not(.fi-section-header-after-ctn .fi-dropdown-panel *) {
@apply leading-6;
}
& .fi-btn {
& .fi-btn:not(.fi-section-header-after-ctn .fi-dropdown-panel *) {
&.fi-size-xs {
@apply -my-0.5;
}
+58
View File
@@ -210,6 +210,64 @@ The `$this->filters` array will always reflect the current form data. Please not
If you want to add filters that apply to multiple widgets at once, see [filtering widget data](overview#filtering-widget-data) in the dashboard.
</Aside>
#### Deferring filter updates
By default, filters using the `filtersSchema()` method update the chart data immediately as they are changed. However, for complex queries or better user experience, you may want to **defer** filter updates until the user clicks an "Apply" button.
When deferred, filter changes are only applied when the user clicks the "Apply" button. This ensures that the chart only re-renders when the user has finished adjusting all of their filters.
The chart will display data using the default filter values when the page first loads, ensuring users see meaningful data immediately without needing to take action.
To enable deferred filters, set the `$hasDeferredFilters` property to `true`:
```php
use Filament\Widgets\ChartWidget\Concerns\HasFiltersSchema;
class BlogPostsChart extends ChartWidget
{
use HasFiltersSchema;
protected bool $hasDeferredFilters = true;
// ...
}
```
If you need dynamic control over whether filters are deferred, you may override the `hasDeferredFilters()` method:
```php
public function hasDeferredFilters(): bool
{
return auth()->user()->prefersDeferredFilters();
}
```
#### Resetting filters to defaults
When using deferred filters, a "Reset" link appears in the filter dropdown footer alongside the "Apply" button. Clicking this link restores all filters to their default values as defined in the `filtersSchema()` method. For example, if you set `->default(now()->subDays(30))` on a `DatePicker`, the reset action will restore that default date, not an empty value.
#### Customizing filter actions
You may customize the apply and reset actions that appear when using deferred filters. All methods that are available to [customize action trigger buttons](../actions/overview) can be used:
```php
use Filament\Actions\Action;
public function filtersApplyAction(Action $action): Action
{
return $action
->label('Update Chart')
->color('success');
}
public function filtersResetAction(Action $action): Action
{
return $action
->label('Clear Filters')
->color('danger');
}
```
## Live updating chart data (polling)
By default, chart widgets refresh their data every 5 seconds.
@@ -30,7 +30,11 @@
&.fi-dropdown {
& .fi-wi-chart-filter-content {
@apply p-6;
@apply grid gap-y-4 p-6;
}
& .fi-wi-chart-filter-content-actions-ctn {
@apply flex gap-3;
}
}
}
@@ -10,4 +10,20 @@ return [
],
'filters' => [
'actions' => [
'apply' => [
'label' => 'Apply',
],
'reset' => [
'label' => 'Reset',
],
],
],
];
@@ -50,6 +50,16 @@
<div class="fi-wi-chart-filter-content">
{{ $this->getFiltersSchema() }}
@if (method_exists($this, 'hasDeferredFilters') && $this->hasDeferredFilters())
<div
class="fi-wi-chart-filter-content-actions-ctn"
>
{{ $this->getFiltersApplyAction() }}
{{ $this->getFiltersResetAction() }}
</div>
@endif
</div>
</x-filament::dropdown>
@endif
-4
View File
@@ -45,10 +45,6 @@ abstract class ChartWidget extends Widget implements HasSchemas
public function mount(): void
{
if (method_exists($this, 'getFiltersSchema')) {
$this->getFiltersSchema()->fill();
}
$this->dataChecksum = $this->generateDataChecksum();
}
@@ -10,13 +10,35 @@ use Filament\Widgets\View\WidgetsIconAlias;
trait HasFiltersSchema /** @phpstan-ignore trait.unused */
{
/**
* @var array<string, mixed> | null
*/
public ?array $filters = [];
/**
* @var array<string, mixed> | null
*/
public ?array $deferredFilters = null;
public function filtersSchema(Schema $schema): Schema
{
return $schema;
}
public function hasDeferredFilters(): bool
{
return property_exists($this, 'hasDeferredFilters') && $this->hasDeferredFilters;
}
public function mountHasFiltersSchema(): void
{
$this->getFiltersSchema()->fill();
if ($this->hasDeferredFilters()) {
$this->filters = $this->deferredFilters;
}
}
public function getFiltersTriggerAction(): Action
{
return Action::make('filter')
@@ -33,8 +55,68 @@ trait HasFiltersSchema /** @phpstan-ignore trait.unused */
return $this->getSchema('filtersSchema');
}
return $this->filtersSchema($this->makeSchema()
->statePath('filters')
->live());
return $this->filtersSchema($this->makeSchema())
->when(
$this->hasDeferredFilters(),
fn (Schema $schema) => $schema
->statePath('deferredFilters')
->partiallyRender(),
fn (Schema $schema) => $schema
->statePath('filters')
->live(),
);
}
public function updatedFilters(): void
{
$this->cachedData = null;
}
public function applyFilters(): void
{
$this->filters = $this->deferredFilters;
$this->cachedData = null;
}
public function resetFiltersForm(): void
{
$this->getFiltersSchema()->fill();
if ($this->hasDeferredFilters()) {
$this->applyFilters();
return;
}
$this->cachedData = null;
}
public function getFiltersApplyAction(): Action
{
$action = Action::make('applyFilters')
->label(__('filament-widgets::chart.filters.actions.apply.label'))
->action('applyFilters')
->button();
if (method_exists($this, 'filtersApplyAction')) {
$action = $this->filtersApplyAction($action);
}
return $action;
}
public function getFiltersResetAction(): Action
{
$action = Action::make('resetFilters')
->label(__('filament-widgets::chart.filters.actions.reset.label'))
->action('resetFiltersForm')
->color('danger')
->button();
if (method_exists($this, 'filtersResetAction')) {
$action = $this->filtersResetAction($action);
}
return $action;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace Filament\Tests\Widgets;
use Filament\Forms\Components\Select;
use Filament\Schemas\Schema;
use Filament\Tests\TestCase;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\Artisan;
use Livewire\Livewire;
uses(TestCase::class);
beforeEach(function (): void {
Artisan::call('filament:assets');
});
it('has deferred filters disabled by default', function (): void {
$widget = Livewire::test(TestChartWidgetDefault::class);
expect($widget->instance()->hasDeferredFilters())->toBeFalse();
});
it('can enable deferred filters via `$hasDeferredFilters` property', function (): void {
$widget = Livewire::test(TestChartWidgetWithDeferredFiltersProperty::class);
expect($widget->instance()->hasDeferredFilters())->toBeTrue();
});
it('initializes both `$filters` and `$deferredFilters` on mount when deferred', function (): void {
Livewire::test(TestChartWidgetWithDeferredFiltersProperty::class)
->assertSet('filters', ['year' => '2024'])
->assertSet('deferredFilters', ['year' => '2024']);
});
it('updates `$filters` immediately when deferred is disabled', function (): void {
Livewire::test(TestChartWidgetDefault::class)
->assertSet('filters', ['year' => '2024'])
->set('filters.year', '2023')
->assertSet('filters', ['year' => '2023']);
});
it('updates only `$deferredFilters` when changed with deferred enabled', function (): void {
Livewire::test(TestChartWidgetWithDeferredFiltersProperty::class)
->assertSet('filters', ['year' => '2024'])
->assertSet('deferredFilters', ['year' => '2024'])
->set('deferredFilters.year', '2023')
->assertSet('filters', ['year' => '2024'])
->assertSet('deferredFilters', ['year' => '2023']);
});
it('applies deferred filters when `applyFilters()` is called', function (): void {
Livewire::test(TestChartWidgetWithDeferredFiltersProperty::class)
->set('deferredFilters.year', '2023')
->call('applyFilters')
->assertSet('filters', ['year' => '2023'])
->assertSet('deferredFilters', ['year' => '2023']);
});
it('resets filters to defaults when `resetFiltersForm()` is called', function (): void {
Livewire::test(TestChartWidgetWithDeferredFiltersProperty::class)
->set('deferredFilters.year', '2022')
->call('applyFilters')
->assertSet('filters', ['year' => '2022'])
->call('resetFiltersForm')
->assertSet('filters', ['year' => '2024'])
->assertSet('deferredFilters', ['year' => '2024']);
});
it('can override `hasDeferredFilters()` for dynamic behavior', function (): void {
$widget = Livewire::test(TestChartWidgetWithDynamicDeferredFilters::class);
expect($widget->instance()->hasDeferredFilters())->toBeTrue();
});
it('uses `statePath("deferredFilters")` when deferred', function (): void {
$widget = Livewire::test(TestChartWidgetWithDeferredFiltersProperty::class);
expect($widget->instance()->getFiltersSchema()->getStatePath())->toBe('deferredFilters');
});
it('uses `statePath("filters")` when not deferred', function (): void {
$widget = Livewire::test(TestChartWidgetDefault::class);
expect($widget->instance()->getFiltersSchema()->getStatePath())->toBe('filters');
});
class TestChartWidgetDefault extends ChartWidget
{
use ChartWidget\Concerns\HasFiltersSchema;
protected function getType(): string
{
return 'line';
}
protected function getData(): array
{
$year = (int) ($this->filters['year'] ?? 2024);
return [
'datasets' => [
[
'label' => "Data for {$year}",
'data' => [10, 20, 30],
],
],
'labels' => ['Jan', 'Feb', 'Mar'],
];
}
public function filtersSchema(Schema $schema): Schema
{
return $schema
->components([
Select::make('year')
->options([
'2024' => '2024',
'2023' => '2023',
'2022' => '2022',
])
->default('2024'),
]);
}
}
class TestChartWidgetWithDeferredFiltersProperty extends ChartWidget
{
use ChartWidget\Concerns\HasFiltersSchema;
protected bool $hasDeferredFilters = true;
protected function getType(): string
{
return 'line';
}
protected function getData(): array
{
$year = (int) ($this->filters['year'] ?? 2024);
return [
'datasets' => [
[
'label' => "Data for {$year}",
'data' => [10, 20, 30],
],
],
'labels' => ['Jan', 'Feb', 'Mar'],
];
}
public function filtersSchema(Schema $schema): Schema
{
return $schema
->components([
Select::make('year')
->options([
'2024' => '2024',
'2023' => '2023',
'2022' => '2022',
])
->default('2024'),
]);
}
}
class TestChartWidgetWithDynamicDeferredFilters extends ChartWidget
{
use ChartWidget\Concerns\HasFiltersSchema;
protected function getType(): string
{
return 'line';
}
public function hasDeferredFilters(): bool
{
return true;
}
protected function getData(): array
{
return [
'datasets' => [['data' => [10, 20, 30]]],
'labels' => ['Jan', 'Feb', 'Mar'],
];
}
public function filtersSchema(Schema $schema): Schema
{
return $schema
->components([
Select::make('year')->options(['2024' => '2024', '2023' => '2023'])->default('2024'),
]);
}
}