From 4653d983c1cbf5f906ce8c147a92cd970e32224c Mon Sep 17 00:00:00 2001 From: grafst Date: Tue, 4 Aug 2026 17:24:34 +0200 Subject: [PATCH] Do not trigger unsaved changes alert for action modals that cannot contain unsaved changes The panel-level unsaved changes alert hooked `beforeunload` whenever any action was mounted, so read-only modals such as `ViewAction`'s warned about unsaved changes that cannot exist. Mounted actions now expose a `hasUnsavedChangesAlert` flag that the client-side guard checks, which defaults to `false` for actions with a disabled schema and can be controlled per action with `unsavedChangesAlert()`. Co-Authored-By: Claude Fable 5 --- docs/05-panel-configuration.md | 2 ++ packages/actions/docs/02-modals.md | 21 +++++++++++ .../actions/src/Concerns/CanOpenModal.php | 14 ++++++++ .../src/Concerns/InteractsWithActions.php | 2 ++ .../resources/js/unsaved-changes-alert.js | 4 ++- tests/src/Actions/ActionTest.php | 36 +++++++++++++++++-- tests/src/Actions/ViewActionTest.php | 9 +++++ tests/src/Fixtures/Pages/Actions.php | 16 +++++++++ 8 files changed, 101 insertions(+), 3 deletions(-) diff --git a/docs/05-panel-configuration.md b/docs/05-panel-configuration.md index da6238ff0ca..d9bbfbd3131 100644 --- a/docs/05-panel-configuration.md +++ b/docs/05-panel-configuration.md @@ -265,6 +265,8 @@ public function panel(Panel $panel): Panel } ``` +Action modals that cannot contain unsaved changes, such as the read-only `ViewAction` modal, do not trigger the alert. You may also [disable the alert for a specific action](actions/modals#disabling-the-unsaved-changes-alert) using the `unsavedChangesAlert(false)` method. + ## Enabling database transactions By default, Filament does not wrap operations in database transactions, and allows the user to enable this themselves when they have tested to ensure that their operations are safe to be wrapped in a transaction. However, you can enable database transactions at once for all operations by using the `databaseTransactions()` method: diff --git a/packages/actions/docs/02-modals.md b/packages/actions/docs/02-modals.md index 1f58b44c293..a5e317ed0e7 100644 --- a/packages/actions/docs/02-modals.md +++ b/packages/actions/docs/02-modals.md @@ -747,6 +747,27 @@ use Filament\Support\View\Components\ModalComponent; ModalComponent::closedByEscaping(false); ``` +### Disabling the unsaved changes alert + +When [unsaved changes alerts](../panel-configuration#unsaved-changes-alerts) are enabled for a panel, users are warned before leaving the page while an action modal is open. If a specific action's modal cannot contain unsaved changes, you can disable the warning for it using the `unsavedChangesAlert(false)` method: + +```php +use Filament\Actions\Action; + +Action::make('updateAuthor') + ->schema([ + // ... + ]) + ->action(function (array $data): void { + // ... + }) + ->unsavedChangesAlert(false) +``` + +The `unsavedChangesAlert()` method also accepts a function to dynamically calculate the value. You can inject various utilities into the function as parameters. + +By default, actions with a [disabled schema](#disabling-all-form-fields), such as `ViewAction`, do not trigger the alert, since their modals do not accept user input. + ### Hiding the modal close button By default, modals have a close button in the top right corner. If you wish to hide the close button, you can use the `modalCloseButton(false)` method: diff --git a/packages/actions/src/Concerns/CanOpenModal.php b/packages/actions/src/Concerns/CanOpenModal.php index 4e2da349e40..ddda07d4e0d 100644 --- a/packages/actions/src/Concerns/CanOpenModal.php +++ b/packages/actions/src/Concerns/CanOpenModal.php @@ -93,6 +93,8 @@ trait CanOpenModal protected bool | Closure | null $isModalAutofocused = null; + protected bool | Closure | null $hasUnsavedChangesAlert = null; + protected string | BackedEnum | Htmlable | Closure | null $modalIcon = null; /** @@ -121,6 +123,13 @@ public function closeModalByEscaping(bool | Closure | null $condition = true): s return $this; } + public function unsavedChangesAlert(bool | Closure | null $condition = true): static + { + $this->hasUnsavedChangesAlert = $condition; + + return $this; + } + /** * @deprecated Use `modalAlignment(Alignment::Center)` instead. */ @@ -730,6 +739,11 @@ public function isModalAutofocused(): bool return $this->evaluate($this->isModalAutofocused) ?? ModalComponent::$isAutofocused; } + public function hasUnsavedChangesAlert(): bool + { + return (bool) ($this->evaluate($this->hasUnsavedChangesAlert) ?? (! $this->isSchemaDisabled())); + } + /** * @deprecated Use `makeModalSubmitAction()` instead. * diff --git a/packages/actions/src/Concerns/InteractsWithActions.php b/packages/actions/src/Concerns/InteractsWithActions.php index adcb4947fc8..538499f1fd3 100644 --- a/packages/actions/src/Concerns/InteractsWithActions.php +++ b/packages/actions/src/Concerns/InteractsWithActions.php @@ -207,6 +207,8 @@ public function mountAction(string $name, array $arguments = [], array $context throw $exception; } + $this->mountedActions[array_key_last($this->mountedActions)]['hasUnsavedChangesAlert'] = $action->hasUnsavedChangesAlert(); + if (! $this->mountedActionShouldOpenModal(mountedAction: $action)) { if ($context['mountedFromUrl'] ?? false) { // A modal-less action mounted from the URL has nothing to show the user, so diff --git a/packages/panels/resources/js/unsaved-changes-alert.js b/packages/panels/resources/js/unsaved-changes-alert.js index 4af36be1a8a..ead572ee257 100644 --- a/packages/panels/resources/js/unsaved-changes-alert.js +++ b/packages/panels/resources/js/unsaved-changes-alert.js @@ -67,7 +67,9 @@ window.setUpUnsavedActionChangesAlert = ({ } if ( - ($wire.mountedActions?.length ?? 0) && + ($wire.mountedActions ?? []).some( + (mountedAction) => mountedAction.hasUnsavedChangesAlert ?? true, + ) && !$wire?.__instance?.effects?.redirect ) { event.preventDefault() diff --git a/tests/src/Actions/ActionTest.php b/tests/src/Actions/ActionTest.php index 360e0605839..10fa28d3b4b 100644 --- a/tests/src/Actions/ActionTest.php +++ b/tests/src/Actions/ActionTest.php @@ -10,6 +10,8 @@ use Filament\Schemas\Concerns\InteractsWithSchemas; use Filament\Schemas\Contracts\HasSchemas; use Filament\Support\Colors\Color; +use Filament\Support\Enums\IconPosition; +use Filament\Support\Enums\IconSize; use Filament\Support\Enums\Size; use Filament\Support\Enums\Width; use Filament\Support\Facades\FilamentView; @@ -232,6 +234,36 @@ }); }); +describe('unsaved changes alerts', function (): void { + it('flags a mounted action with an editable schema for the unsaved changes alert', function (): void { + livewire(Actions::class) + ->mountAction('data') + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', true); + }); + + it('does not flag a mounted action with a disabled schema for the unsaved changes alert', function (): void { + livewire(Actions::class) + ->mountAction('disabledSchema') + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', false); + }); + + it('does not flag a mounted action that opts out of the unsaved changes alert', function (): void { + livewire(Actions::class) + ->mountAction('withoutUnsavedChangesAlert') + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', false); + }); + + it('has an unsaved changes alert by default', function (): void { + expect(Action::make('test')->hasUnsavedChangesAlert()) + ->toBeTrue(); + }); + + it('can opt an action with a disabled schema back in to the unsaved changes alert', function (): void { + expect(Action::make('test')->disabledSchema()->unsavedChangesAlert()->hasUnsavedChangesAlert()) + ->toBeTrue(); + }); +}); + describe('arguments', function (): void { it('can mount an action with arguments', function (): void { livewire(Actions::class) @@ -1945,7 +1977,7 @@ it('renders an `iconPosition(After)` icon after the label', function (): void { $html = Action::make('test') ->icon('heroicon-o-arrow-right') - ->iconPosition(\Filament\Support\Enums\IconPosition::After) + ->iconPosition(IconPosition::After) ->label('Next') ->toHtml(); @@ -1979,7 +2011,7 @@ it('renders an `iconSize()` as a `fi-size-*` class on the icon', function (): void { $html = Action::make('test') ->icon('heroicon-o-trash') - ->iconSize(\Filament\Support\Enums\IconSize::Large) + ->iconSize(IconSize::Large) ->toHtml(); expect($html)->toContain('fi-size-lg'); diff --git a/tests/src/Actions/ViewActionTest.php b/tests/src/Actions/ViewActionTest.php index bb1ec243790..773cb78d5aa 100644 --- a/tests/src/Actions/ViewActionTest.php +++ b/tests/src/Actions/ViewActionTest.php @@ -64,6 +64,15 @@ ->assertSchemaStateSet(['name' => 'Department 2']); }); +it('does not flag a mounted `ViewAction` for the unsaved changes alert', function (): void { + $ticket = Ticket::factory()->create(); + $department = Department::factory()->hasAttached($ticket)->create(); + + livewire(DepartmentsRelationManager::class, ['ownerRecord' => $ticket, 'pageClass' => EditTicket::class]) + ->mountAction(TestAction::make(ViewAction::class)->table($department)) + ->assertSet('mountedActions.0.hasUnsavedChangesAlert', false); +}); + it('returns `view` from `getDefaultName()`', function (): void { expect(ViewAction::getDefaultName())->toBe('view'); }); diff --git a/tests/src/Fixtures/Pages/Actions.php b/tests/src/Fixtures/Pages/Actions.php index a7a3fc9607d..d41d68f3f37 100644 --- a/tests/src/Fixtures/Pages/Actions.php +++ b/tests/src/Fixtures/Pages/Actions.php @@ -279,6 +279,22 @@ protected function getHeaderActions(): array ->action(function (): void { $this->dispatch('enforcement-authorized-called'); }), + Action::make('disabledSchema') + ->schema([ + TextInput::make('payload'), + ]) + ->disabledSchema() + ->action(function (): void { + $this->dispatch('disabled-schema-called'); + }), + Action::make('withoutUnsavedChangesAlert') + ->schema([ + TextInput::make('payload'), + ]) + ->unsavedChangesAlert(false) + ->action(function (): void { + $this->dispatch('without-unsaved-changes-alert-called'); + }), ]; } }