mirror of
https://github.com/filamentphp/filament.git
synced 2026-10-29 18:19:53 +08:00
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:
co-authored by
Claude Fable 5
Amp
Dan Harrin
parent
a094b06707
commit
dc34f6bb3d
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+1
-1
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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user