Do not trigger unsaved changes alert for action modals that cannot contain unsaved changes (#20331)

* 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 <noreply@anthropic.com>

* Fix unsaved action changes alert edge cases

Co-authored-by: Amp <amp@ampcode.com>

* Prevent stale unsaved action alert state

Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dan Harrin <git@danharrin.com>
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
grafst
2026-09-04 14:13:06 +01:00
committed by GitHub
co-authored by Claude Fable 5 Amp Dan Harrin
parent a094b06707
commit dc34f6bb3d
13 changed files with 291 additions and 2 deletions
+2
View File
@@ -265,6 +265,8 @@ public function panel(Panel $panel): Panel
}
```
Action modals with a disabled schema, such as the read-only `ViewAction` modal, do not trigger the alert. You may also [disable the alert for another action that cannot contain unsaved changes](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:
+20
View File
@@ -747,6 +747,26 @@ 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;
use Filament\Infolists\Components\TextEntry;
Action::make('viewAuthor')
->schema([
TextEntry::make('name'),
TextEntry::make('email'),
])
->unsavedChangesAlert(false)
```
<UtilityInjection set="actions" version="4.x">The `unsavedChangesAlert()` method also accepts a function to dynamically calculate the value. You can inject various utilities into the function as parameters.</UtilityInjection>
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:
@@ -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 @@ trait CanOpenModal
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 @@ trait CanOpenModal
return $this->evaluate($this->isModalAutofocused) ?? ModalComponent::$isAutofocused;
}
public function hasUnsavedChangesAlert(): bool
{
return (bool) ($this->evaluate($this->hasUnsavedChangesAlert) ?? (! $this->isSchemaDisabled()));
}
/**
* @deprecated Use `makeModalSubmitAction()` instead.
*
@@ -172,6 +172,9 @@ trait InteractsWithActions /** @phpstan-ignore trait.unused */
}
}
// Keep a reference to this entry since the action's mount lifecycle may replace it.
$mountedActionState = &$this->mountedActions[$action->getNestingIndex()];
try {
if (
$action->hasAuthorizationNotification() &&
@@ -212,6 +215,9 @@ trait InteractsWithActions /** @phpstan-ignore trait.unused */
throw $exception;
}
$mountedActionState['hasUnsavedChangesAlert'] = $action->hasUnsavedChangesAlert();
unset($mountedActionState);
if (! $this->mountedActionShouldOpenModal(mountedAction: $action)) {
if ($context['mountedFromUrl'] ?? false) {
// A modal-less action mounted from the URL has nothing to show the user, so
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -67,7 +67,9 @@ window.setUpUnsavedActionChangesAlert = ({
}
if (
($wire.mountedActions?.length ?? 0) &&
($wire.mountedActions ?? []).some(
(mountedAction) => mountedAction.hasUnsavedChangesAlert ?? true,
) &&
!$wire?.__instance?.effects?.redirect
) {
event.preventDefault()
@@ -0,0 +1 @@
<x-filament-panels::page></x-filament-panels::page>
+56
View File
@@ -234,6 +234,62 @@ describe('validation', function (): void {
});
});
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, strict: 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, strict: true);
});
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, strict: true);
});
it('returns `true` from `hasUnsavedChangesAlert()` by default', function (): void {
expect(Action::make('test')->hasUnsavedChangesAlert())
->toBeTrue();
});
it('can use `unsavedChangesAlert()` to opt an action with a disabled schema back in to the unsaved changes alert', function (): void {
expect(Action::make('test')->disabledSchema()->unsavedChangesAlert()->hasUnsavedChangesAlert())
->toBeTrue();
});
it('can use `unsavedChangesAlert()` with a `Closure`', function (): void {
expect(Action::make('test')->unsavedChangesAlert(static fn (): bool => false)->hasUnsavedChangesAlert())
->toBeFalse();
});
it('preserves each `unsavedChangesAlert()` configuration when an action mounts a child during its mount lifecycle', function (): void {
livewire(Actions::class)
->mountAction('mountsChildDuringMount')
->assertCount('mountedActions', 2)
->assertSet('mountedActions.0.hasUnsavedChangesAlert', false, strict: true)
->assertSet('mountedActions.1.hasUnsavedChangesAlert', true, strict: true);
});
it('does not restore an action that unmounts itself during `mountUsing()`', function (): void {
livewire(Actions::class)
->mountAction('unmountsDuringMount')
->assertSet('mountedActions', [], strict: true);
});
it('does not overwrite the unsaved changes alert configuration of an action replaced during `mountUsing()`', function (): void {
livewire(Actions::class)
->mountAction('replacesDuringMount')
->assertSet('mountedActions.0.name', 'replacementMountedDuringMount', strict: true)
->assertSet('mountedActions.0.hasUnsavedChangesAlert', true, strict: true);
});
});
describe('arguments', function (): void {
it('can mount an action with arguments', function (): void {
livewire(Actions::class)
+73
View File
@@ -11,6 +11,79 @@ beforeEach(function (): void {
});
describe('browser interactions', function (): void {
it('prevents `beforeunload` only while actions that can contain unsaved changes are mounted', function (bool $isDarkMode): void {
$this->actingAs(User::factory()->create());
$browser = visit('/unsaved-changes-alert-browser-test');
if ($isDarkMode) {
$browser->inDarkMode();
}
$dispatchBeforeUnloadEvent = <<<'JS'
(() => {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
})()
JS;
$browser
->assertScript($dispatchBeforeUnloadEvent, false)
->click('[data-testid="read-only-unsaved-changes-alert-trigger"]')
->assertVisible('[data-testid="read-only-unsaved-changes-alert-modal"]')
->assertScript($dispatchBeforeUnloadEvent, false)
->assertNoAccessibilityIssues()
->click('[data-testid="read-only-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Cancel')
->assertMissing('[data-testid="read-only-unsaved-changes-alert-modal"]')
->click('[data-testid="editable-unsaved-changes-alert-trigger"]')
->assertVisible('[data-testid="editable-unsaved-changes-alert-modal"]')
->assertScript($dispatchBeforeUnloadEvent, true)
->click('[data-testid="editable-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Cancel')
->assertMissing('[data-testid="editable-unsaved-changes-alert-modal"]')
->click('[data-testid="nested-unsaved-changes-alert-trigger"]')
->assertVisible('[data-testid="nested-unsaved-changes-alert-modal"]')
->assertScript($dispatchBeforeUnloadEvent, false)
->click('[data-testid="nested-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Open editable nested action')
->assertVisible('[data-testid="editable-nested-unsaved-changes-alert-modal"]')
->assertScript($dispatchBeforeUnloadEvent, true)
->wait(0.5)
->assertNoAccessibilityIssues()
->click('[data-testid="editable-nested-unsaved-changes-alert-modal"] .fi-modal-footer-actions button >> text=Cancel')
->assertVisible('[data-testid="nested-unsaved-changes-alert-modal"]')
->assertScript($dispatchBeforeUnloadEvent, false)
->assertNoSmoke();
})->with([
'light mode' => false,
'dark mode' => true,
]);
it('preserves the `beforeunload` alert for mounted action state without a `hasUnsavedChangesAlert` key', function (): void {
$this->actingAs(User::factory()->create());
visit('/unsaved-changes-alert-browser-test')
->assertScript(<<<'JS'
(() => {
setUpUnsavedActionChangesAlert({
resolveLivewireComponentUsing: () => ({}),
$wire: {
mountedActions: [{}],
__instance: { effects: {} },
},
})
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
})()
JS, true)
->assertNoSmoke();
});
it('can run another action after closing a child opened by an action without a modal', function (bool $isDarkMode): void {
$this->actingAs(User::factory()->create());
+9
View File
@@ -64,6 +64,15 @@ it('can view multiple records sequentially', function (): void {
->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, strict: true);
});
it('returns `view` from `getDefaultName()`', function (): void {
expect(ViewAction::getDefaultName())->toBe('view');
});
+46
View File
@@ -363,6 +363,52 @@ class Actions extends Page
->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');
}),
Action::make('mountsChildDuringMount')
->requiresConfirmation()
->unsavedChangesAlert(false)
->registerModalActions([
Action::make('childMountedDuringParentMount')
->requiresConfirmation()
->unsavedChangesAlert()
->action(static fn () => null),
])
->mountUsing(function (Action $action): void {
$action->getLivewire()->mountAction('childMountedDuringParentMount');
})
->action(static fn () => null),
Action::make('unmountsDuringMount')
->requiresConfirmation()
->mountUsing(function (Action $action): void {
$action->getLivewire()->unmountAction();
})
->action(static fn () => null),
Action::make('replacesDuringMount')
->requiresConfirmation()
->unsavedChangesAlert(false)
->mountUsing(function (Action $action): void {
$action->getLivewire()->replaceMountedAction('replacementMountedDuringMount');
})
->action(static fn () => null),
Action::make('replacementMountedDuringMount')
->requiresConfirmation()
->unsavedChangesAlert()
->action(static fn () => null),
];
}
@@ -0,0 +1,57 @@
<?php
namespace Filament\Tests\Fixtures\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
class UnsavedChangesAlertBrowserTest extends Page
{
protected string $view = 'pages.unsaved-changes-alert-browser-test';
protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedExclamationTriangle;
protected static bool $shouldRegisterNavigation = false;
protected function getHeaderActions(): array
{
return [
Action::make('readOnlyUnsavedChangesAlert')
->label('Read-only unsaved changes alert')
->schema([
TextInput::make('name'),
])
->disabledSchema()
->modalSubmitAction(false)
->extraAttributes(['data-testid' => 'read-only-unsaved-changes-alert-trigger'])
->extraModalWindowAttributes(['data-testid' => 'read-only-unsaved-changes-alert-modal']),
Action::make('editableUnsavedChangesAlert')
->label('Editable unsaved changes alert')
->schema([
TextInput::make('name'),
])
->modalSubmitAction(false)
->extraAttributes(['data-testid' => 'editable-unsaved-changes-alert-trigger'])
->extraModalWindowAttributes(['data-testid' => 'editable-unsaved-changes-alert-modal']),
Action::make('nestedUnsavedChangesAlert')
->label('Nested unsaved changes alert')
->modalHeading('Nested unsaved changes alert')
->modalSubmitAction(false)
->unsavedChangesAlert(false)
->extraAttributes(['data-testid' => 'nested-unsaved-changes-alert-trigger'])
->extraModalWindowAttributes(['data-testid' => 'nested-unsaved-changes-alert-modal'])
->extraModalFooterActions([
Action::make('editableNestedUnsavedChangesAlert')
->label('Open editable nested action')
->schema([
TextInput::make('name'),
])
->modalSubmitAction(false)
->extraModalWindowAttributes(['data-testid' => 'editable-nested-unsaved-changes-alert-modal']),
]),
];
}
}
@@ -62,6 +62,7 @@ use Filament\Tests\Fixtures\Pages\TextInputTest;
use Filament\Tests\Fixtures\Pages\TimePickerBrowserTest;
use Filament\Tests\Fixtures\Pages\ToggleButtonsTest;
use Filament\Tests\Fixtures\Pages\ToggleTest;
use Filament\Tests\Fixtures\Pages\UnsavedChangesAlertBrowserTest;
use Filament\Tests\Fixtures\Pages\WizardBrowserTest;
use Filament\Tests\Fixtures\Resources\Companies\CompanyResource;
use Filament\Tests\Fixtures\Resources\Companies\Resources\CompanyTeamResource;
@@ -94,6 +95,7 @@ class AdminPanelProvider extends PanelProvider
->emailChangeVerification()
->emailVerification()
->profile()
->unsavedChangesAlerts(static fn (): bool => request()->routeIs('filament.admin.pages.unsaved-changes-alert-browser-test'))
->resources([
CompanyResource::class,
CompanyTeamResource::class,
@@ -155,6 +157,7 @@ class AdminPanelProvider extends PanelProvider
TimePickerBrowserTest::class,
ToggleButtonsTest::class,
ToggleTest::class,
UnsavedChangesAlertBrowserTest::class,
WizardBrowserTest::class,
UserManagement::class,
ManageAdmins::class,