Refactor UnsavedController to use event.preventDefault() to trigger browser confirmation dialog

Per https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event

"best practice is to trigger the dialog by invoking `preventDefault()`
on the event object, while also setting `returnValue` to support legacy
cases."

We don't need to support legacy cases, as our supported browsers all
support the `preventDefault()` approach.

See also:
https://caniuse.com/mdn-api_window_beforeunload_event_preventdefault_activation

Fixes #12132

From PR #12139
pull/12273/head
Shubham 2024-07-19 14:26:17 +05:30 zatwierdzone przez LB (Ben Johnston)
rodzic abcb2da372
commit e7de2f8955
8 zmienionych plików z 70 dodań i 49 usunięć

Wyświetl plik

@ -281,6 +281,7 @@ Changelog
* Maintenance: Remove jQuery usage in telepath widget classes (Matt Westcott)
* Maintenance: Remove `xregexp` (IE11 polyfill) along with `window.XRegExp` global util (LB (Ben) Johnston)
* Maintenance: Refactor the Django port of `urlify` to use TypeScript, officially deprecate `window.URLify` global util (LB (Ben) Johnston)
* Maintenance: Adopt the modern best practice for `beforeunload` usage in `UnsavedController` to trigger a leave page warning when edits have been made (Shubham Mukati, Sage Abdullah)
6.0.6 (11.07.2024)

Wyświetl plik

@ -833,6 +833,7 @@
* Daniel Black
* Atif Khan
* Sanjeev Holla S
* Shubham Mukati
## Translators

Wyświetl plik

@ -27,11 +27,7 @@ describe('ActionController', () => {
reload: {
configurable: true,
value: jest.fn().mockImplementation(() => {
const event = new Event('beforeunload');
Object.defineProperty(event, 'returnValue', {
value: null,
writable: true,
});
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
}),
},
@ -156,9 +152,8 @@ describe('ActionController', () => {
expect(beforeUnloadHandler).toHaveBeenCalledTimes(1);
const event = beforeUnloadHandler.mock.lastCall[0];
// These mean the browser confirmation dialog was not shown
// This means the browser confirmation dialog was not shown
expect(event.defaultPrevented).toBe(false);
expect(event.returnValue).toBeNull();
window.removeEventListener('beforeunload', beforeUnloadHandler);
});
@ -168,7 +163,7 @@ describe('ActionController', () => {
<form
data-controller="w-unsaved"
data-action="beforeunload@window->w-unsaved#confirm"
data-w-unsaved-confirmation-value="You have unsaved changes!"
data-w-unsaved-confirmation-value="true"
>
</form>
<button
@ -197,7 +192,7 @@ describe('ActionController', () => {
const event = beforeUnloadHandler.mock.lastCall[0];
// This means the browser confirmation dialog was shown
expect(event.returnValue).toBe('You have unsaved changes!');
expect(event.defaultPrevented).toBe(true);
window.removeEventListener('beforeunload', beforeUnloadHandler);
});
});
@ -208,7 +203,7 @@ describe('ActionController', () => {
<form
data-controller="w-unsaved"
data-action="beforeunload@window->w-unsaved#confirm"
data-w-unsaved-confirmation-value="You have unsaved changes!"
data-w-unsaved-confirmation-value="true"
>
</form>
<button
@ -239,9 +234,8 @@ describe('ActionController', () => {
expect(beforeUnloadHandler).toHaveBeenCalledTimes(1);
const beforeUnloadEvent = beforeUnloadHandler.mock.lastCall[0];
// If the browser confirmation was shown, these would be truthy
// If the browser confirmation was shown, this would be true
expect(beforeUnloadEvent.defaultPrevented).toBe(false);
expect(beforeUnloadEvent.returnValue).toBeNull();
expect(confirmHandler).toHaveBeenCalledTimes(1);
const confirmEvent = confirmHandler.mock.lastCall[0];

Wyświetl plik

@ -52,7 +52,7 @@ describe('UnsavedController', () => {
id="form"
data-controller="w-unsaved"
data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
data-w-unsaved-confirmation-value="You have unsaved changes!"
data-w-unsaved-confirmation-value="true"
>
<input type="text" id="name" value="John" />
<button>Submit</submit>
@ -212,15 +212,10 @@ describe('UnsavedController', () => {
describe('showing a confirmation message when exiting the browser tab', () => {
const mockBrowserClose = () =>
new Promise((resolve) => {
const event = new Event('beforeunload');
Object.defineProperty(event, 'returnValue', {
value: false,
writable: true,
});
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
resolve(event.returnValue);
// If the event is prevented, the browser will show a confirmation message.
resolve(event.defaultPrevented);
});
it('should not show a confirmation message if no edits exist', async () => {
@ -238,7 +233,7 @@ describe('UnsavedController', () => {
<form
data-controller="w-unsaved"
data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
data-w-unsaved-confirmation-value="You have unsaved changes!"
data-w-unsaved-confirmation-value="true"
data-w-unsaved-force-value="true"
>
<input type="text" id="name" value="John" />
@ -248,10 +243,30 @@ describe('UnsavedController', () => {
const result = await mockBrowserClose();
expect(result).toEqual('You have unsaved changes!');
expect(result).toEqual(true);
expect(events['w-unsaved:confirm']).toHaveLength(1);
});
it('should not show a confirmation message if forced but confirmation value is false', async () => {
await setup(`
<section>
<form
data-controller="w-unsaved"
data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
data-w-unsaved-confirmation-value="false"
data-w-unsaved-force-value="true"
>
<input type="text" id="name" value="John" />
<button>Submit</submit>
</form>
</section>`);
const result = await mockBrowserClose();
expect(result).toEqual(false);
expect(events['w-unsaved:confirm']).toHaveLength(0);
});
it('should allow a confirmation message to show before the browser closes', async () => {
await setup();
@ -263,7 +278,7 @@ describe('UnsavedController', () => {
const result = await mockBrowserClose();
expect(result).toEqual('You have unsaved changes!');
expect(result).toEqual(true);
expect(events['w-unsaved:confirm']).toHaveLength(1);
});

Wyświetl plik

@ -24,7 +24,7 @@ const DEFAULT_DURATIONS = {
* <form
* data-controller="w-unsaved"
* data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
* data-w-unsaved-confirmation-value="You have unsaved changes!"
* data-w-unsaved-confirmation-value="true"
* >
* <input type="text" value="something" />
* <button>Submit</submit>
@ -34,7 +34,7 @@ const DEFAULT_DURATIONS = {
* <form
* data-controller="w-unsaved"
* data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
* data-w-unsaved-confirmation-value="You have unsaved changes!"
* data-w-unsaved-confirmation-value="true"
* data-w-unsaved-watch-value="edits comments"
* >
* <input type="text" value="something" />
@ -45,7 +45,7 @@ const DEFAULT_DURATIONS = {
* <form
* data-controller="w-unsaved"
* data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check"
* data-w-unsaved-confirmation-value="You have unsaved changes!"
* data-w-unsaved-confirmation-value="true"
* data-w-unsaved-force-value="true"
* >
* <input type="text" value="something" />
@ -56,7 +56,7 @@ const DEFAULT_DURATIONS = {
* <form
* data-controller="w-unsaved"
* data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm"
* data-w-unsaved-confirmation-value="Please double check before you close"
* data-w-unsaved-confirmation-value="true"
* data-w-unsaved-force-value="true"
* data-w-unsaved-watch-value=""
* >
@ -66,7 +66,7 @@ const DEFAULT_DURATIONS = {
*/
export class UnsavedController extends Controller<HTMLFormElement> {
static values = {
confirmation: { default: '', type: String },
confirmation: { default: false, type: Boolean },
durations: { default: DEFAULT_DURATIONS, type: Object },
force: { default: false, type: Boolean },
hasComments: { default: false, type: Boolean },
@ -74,11 +74,21 @@ export class UnsavedController extends Controller<HTMLFormElement> {
watch: { default: 'edits', type: String },
};
/** Translated value for the beforeunload confirmation dialog, if empty no confirmation will show. */
declare confirmationValue: string;
/** Whether to show the browser confirmation dialog. */
declare confirmationValue: boolean;
/** Configurable duration values. */
declare durationsValue: typeof DEFAULT_DURATIONS;
/** When set to true, the form will always be considered dirty and the confirmation dialog will be forced to show. */
/**
* When set to `true`, the form will always be considered dirty.
* Useful for when the user just submitted an invalid form, in which case we
* consider the form to be dirty even on initial load.
*
* Setting this to `true` effectively disables the edit check, i.e. similar to
* setting `watchValue` to `''` and setting `hasEditsValue` to `true`.
*
* Note that the `confirmationValue` must still be set to `true` in order for
* the browser confirmation dialog to appear.
*/
declare forceValue: boolean;
/** Value (state) tracking of what changes exist (comments). */
declare hasCommentsValue: boolean;
@ -172,25 +182,21 @@ export class UnsavedController extends Controller<HTMLFormElement> {
}
/**
* Trigger the beforeunload confirmation dialog if active (confirm value exists).
* Trigger the beforeunload confirmation dialog if active (confirm value is true).
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
*/
confirm(event: BeforeUnloadEvent) {
const confirmationMessage = this.confirmationValue;
if (!confirmationMessage) return null;
if (!this.confirmationValue) return;
if (this.forceValue || this.hasCommentsValue || this.hasEditsValue) {
// Dispatch a `confirm` event that is cancelable to allow for custom handling
// instead of the browser's default confirmation dialog.
const confirmEvent = this.dispatch('confirm', { cancelable: true });
if (confirmEvent.defaultPrevented) return null;
if (confirmEvent.defaultPrevented) return;
// eslint-disable-next-line no-param-reassign
event.returnValue = confirmationMessage;
return confirmationMessage;
// This will trigger the browser's default confirmation dialog
event.preventDefault();
}
return null;
}
hasCommentsValueChanged(current: boolean, previous: boolean) {
@ -237,10 +243,10 @@ export class UnsavedController extends Controller<HTMLFormElement> {
/**
* When the form is submitted, ensure that the exit confirmation
* does not trigger. Deactivate the confirmation by setting the
* confirm value to empty.
* confirmation value to false.
*/
submit() {
this.confirmationValue = '';
this.confirmationValue = false;
}
/**
@ -360,8 +366,11 @@ export class UnsavedController extends Controller<HTMLFormElement> {
if (!(form instanceof HTMLFormElement)) return;
[
['data-w-unsaved-confirmation-value', confirmationMessage || ' '],
['data-w-unsaved-force-value', String(alwaysDirty || false)],
[
'data-w-unsaved-confirmation-value',
`${!!confirmationMessage || true}`,
],
['data-w-unsaved-force-value', `${alwaysDirty || false}`],
['data-w-unsaved-watch-value', 'edits comments'],
].forEach(([key, value]) => {
form.setAttribute(key, value);

Wyświetl plik

@ -75,6 +75,7 @@ This release adds formal support for Django 5.1.
* Migrate preview-panel JavaScript to Stimulus & TypeScript, add full unit testing (Sage Abdullah)
* Move `wagtailConfig` values from inline scripts to the `wagtail_config` template tag (LB (Ben) Johnston, Sage Abdullah)
* Deprecate the `{% locales %}` and `{% js_translation_strings %}` template tags (LB (Ben) Johnston, Sage Abdullah)
* Adopt the modern best practice for `beforeunload` usage in `UnsavedController` to trigger a leave page warning when edits have been made (Shubham Mukati, Sage Abdullah)
## Upgrade considerations - changes affecting all projects

Wyświetl plik

@ -18,7 +18,7 @@
data-controller="w-init w-unsaved"
data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check keyup->w-unsaved#check"
data-w-init-event-value="{% if comments_enabled %}w-comments:init{% endif %}"
data-w-unsaved-confirmation-value="{{ _("This page has unsaved changes.") }}"
data-w-unsaved-confirmation-value="true"
data-w-unsaved-force-value="{% if has_unsaved_changes %}true{% else %}false{% endif %}"
data-w-unsaved-watch-value="edits{% if comments_enabled %} comments{% endif %}"
>

Wyświetl plik

@ -22,7 +22,7 @@
data-controller="w-init w-unsaved"
data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check keyup->w-unsaved#check"
data-w-init-event-value="{% if comments_enabled %}w-comments:init{% endif %}"
data-w-unsaved-confirmation-value="{{ _("This page has unsaved changes.") }}"
data-w-unsaved-confirmation-value="true"
data-w-unsaved-force-value="{% if has_unsaved_changes %}true{% else %}false{% endif %}"
data-w-unsaved-watch-value="edits{% if comments_enabled %} comments{% endif %}"
>