mirror of
https://github.com/filamentphp/filament.git
synced 2026-09-01 15:09:33 +08:00
[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:
committed by
GitHub
parent
65f418f59f
commit
64110555a8
Generated
+8
-8
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+1
-1
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user