diff --git a/client/src/controllers/UnsavedController.test.js b/client/src/controllers/UnsavedController.test.js
new file mode 100644
index 0000000000..4020c21f98
--- /dev/null
+++ b/client/src/controllers/UnsavedController.test.js
@@ -0,0 +1,189 @@
+import { Application } from '@hotwired/stimulus';
+
+import { UnsavedController } from './UnsavedController';
+
+jest.useFakeTimers();
+
+describe('UnsavedController', () => {
+ const eventNames = ['w-unsaved:add', 'w-unsaved:clear', 'w-unsaved:ready'];
+
+ const events = {};
+
+ let application;
+ let errors = [];
+
+ beforeAll(() => {
+ eventNames.forEach((name) => {
+ events[name] = [];
+ });
+
+ Object.keys(events).forEach((name) => {
+ document.addEventListener(name, (event) => {
+ events[name].push(event);
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // Mock FormData.entries
+ // https://github.com/jsdom/jsdom/blob/main/lib/jsdom/living/xhr/FormData.webidl
+
+ const mockEntries = jest
+ .fn()
+ .mockReturnValueOnce(['name', 'John'])
+ .mockReturnValue([['name', 'Joe']]);
+
+ global.FormData = class FormData {
+ entries() {
+ return mockEntries();
+ }
+ };
+ });
+
+ const setup = async (
+ html = `
+ `,
+ identifier = 'w-unsaved',
+ ) => {
+ document.body.innerHTML = `${html}`;
+
+ application = new Application();
+
+ application.handleError = (error, message) => {
+ errors.push({ error, message });
+ };
+
+ application.register(identifier, UnsavedController);
+
+ application.start();
+
+ await jest.runAllTimersAsync();
+
+ return [
+ ...document.querySelectorAll(`[data-controller~="${identifier}"]`),
+ ].map((element) =>
+ application.getControllerForElementAndIdentifier(element, identifier),
+ );
+ };
+
+ afterEach(() => {
+ application?.stop && application.stop();
+ errors = [];
+ eventNames.forEach((name) => {
+ events[name] = [];
+ });
+ });
+
+ it('should dispatch a ready event when loaded', async () => {
+ expect(events['w-unsaved:clear']).toHaveLength(0);
+ expect(events['w-unsaved:ready']).toHaveLength(0);
+
+ await setup();
+
+ expect(events['w-unsaved:clear']).toHaveLength(1);
+ expect(events['w-unsaved:ready']).toHaveLength(1);
+ });
+
+ describe('checking for edits', () => {
+ it('should allow checking for changes to field values', async () => {
+ expect(events['w-unsaved:add']).toHaveLength(0);
+
+ await setup();
+
+ expect(events['w-unsaved:add']).toHaveLength(0);
+
+ document.getElementById('name').value = 'Joe';
+ document
+ .getElementById('name')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ expect(events['w-unsaved:add']).toHaveLength(1);
+ expect(events['w-unsaved:add'][0]).toHaveProperty('detail.type', 'edits');
+ });
+ });
+
+ 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,
+ });
+
+ window.dispatchEvent(event);
+
+ resolve(event.returnValue);
+ });
+
+ it('should not show a confirmation message if no edits exist', async () => {
+ await setup();
+
+ const result = await mockBrowserClose();
+
+ expect(result).toEqual(false);
+ });
+
+ it('should show a confirmation message if forced', async () => {
+ await setup(`
+ `);
+
+ const result = await mockBrowserClose();
+
+ expect(result).toEqual('You have unsaved changes!');
+ });
+
+ it('should allow a confirmation message to show before the browser closes', async () => {
+ await setup();
+
+ document
+ .getElementById('name')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ await jest.runAllTimersAsync();
+
+ const result = await mockBrowserClose();
+
+ expect(result).toEqual('You have unsaved changes!');
+ });
+
+ it('should should not show a confirmation message if there are edits but the form is being submitted', async () => {
+ await setup();
+
+ document
+ .getElementById('name')
+ .dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ // mock submitting the form
+ document.getElementById('form').dispatchEvent(new Event('submit'));
+
+ await jest.runAllTimersAsync();
+
+ const result = await mockBrowserClose();
+
+ expect(result).toEqual(false);
+ });
+ });
+});
diff --git a/client/src/controllers/UnsavedController.ts b/client/src/controllers/UnsavedController.ts
new file mode 100644
index 0000000000..22d286bf73
--- /dev/null
+++ b/client/src/controllers/UnsavedController.ts
@@ -0,0 +1,384 @@
+import { Controller } from '@hotwired/stimulus';
+import { debounce } from '../utils/debounce';
+import { domReady } from '../utils/domReady';
+
+declare global {
+ interface Window {
+ comments: { commentApp: { selectors: any; store: any } };
+ enableDirtyFormCheck: any;
+ }
+}
+
+const DEFAULT_DURATIONS = {
+ initial: 10_000,
+ long: 3_000,
+ notify: 30,
+ short: 300,
+};
+
+/**
+ * Enables the controlled form to support prompting the user when they
+ * are about to move away from the page with potentially unsaved changes.
+ *
+ * @example - Warn the user when there are unsaved edits
+ *
+ *
+ * @example - Watch comments for changes in addition to edits (default is edits only)
+ *
+ *
+ * @example - Force the confirmation dialog
+ *
+ *
+ * @example - Force the confirmation dialog without watching for edits/comments
+ *
+ */
+export class UnsavedController extends Controller {
+ static values = {
+ confirmation: { default: '', type: String },
+ durations: { default: DEFAULT_DURATIONS, type: Object },
+ force: { default: false, type: Boolean },
+ hasComments: { default: false, type: Boolean },
+ hasEdits: { default: false, type: Boolean },
+ watch: { default: 'edits', type: String },
+ };
+
+ /** Translated value for the beforeunload confirmation dialog, if empty no confirmation will show. */
+ declare confirmationValue: string;
+ /** 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. */
+ declare forceValue: boolean;
+ /** Value (state) tracking of what changes exist (comments). */
+ declare hasCommentsValue: boolean;
+ /** Value (state) tracking of what changes exist (edits). */
+ declare hasEditsValue: boolean;
+ /** Determines what kinds of data will be watched, defaults to edits only. */
+ declare watchValue: string;
+
+ initialFormData?: string;
+ observer?: MutationObserver;
+ runningCheck?: ReturnType;
+
+ initialize() {
+ this.notify = debounce(this.notify.bind(this), this.durationsValue.notify);
+ }
+
+ connect() {
+ this.clear();
+ const durations = this.durationsValue;
+ const watch = this.watchValue;
+
+ if (watch.includes('comments')) this.watchComments(durations);
+ if (watch.includes('edits')) this.watchEdits(durations);
+
+ this.dispatch('ready', { cancelable: false });
+ }
+
+ /**
+ * Resolve the form's `formData` into a comparable string without any comments
+ * data included and other unrelated data cleaned from the value.
+ *
+ * Include handling of File field data to determine a comparable value.
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/File
+ */
+ get formData() {
+ const exclude = ['comment_', 'comments-', 'csrfmiddlewaretoken', 'next'];
+ const formData = new FormData(this.element);
+ return JSON.stringify(
+ [...formData.entries()].filter(
+ ([key]) => !exclude.some((prefix) => key.startsWith(prefix)),
+ ),
+ (_key, value) =>
+ value instanceof File
+ ? { name: value.name, size: value.size, type: value.type }
+ : value,
+ );
+ }
+
+ /**
+ * Check for edits to the form with a delay based on whether the form
+ * currently has edits. If called multiple times, cancel & restart the
+ * delay timer.
+ *
+ * Intentionally delay the check if there are already edits to the longer
+ * delay so that the UX is improved. Users are unlikely to go back to an
+ * original state of the form after making edits.
+ */
+ check() {
+ const { long: longDuration, short: shortDuration } = this.durationsValue;
+
+ if (this.runningCheck) {
+ this.runningCheck.cancel();
+ }
+
+ this.runningCheck = debounce(
+ () => {
+ if (this.forceValue) {
+ this.hasEditsValue = true;
+ return;
+ }
+
+ if (!this.initialFormData) {
+ this.hasEditsValue = false;
+ return;
+ }
+
+ this.hasEditsValue = this.initialFormData !== this.formData;
+ },
+ this.hasEditsValue ? longDuration : shortDuration,
+ );
+
+ return this.runningCheck();
+ }
+
+ /**
+ * Clear the tracking changes values and messages.
+ */
+ clear() {
+ this.hasCommentsValue = false;
+ this.hasEditsValue = false;
+ }
+
+ /**
+ * Trigger the beforeunload confirmation dialog if active (confirm value exists).
+ * @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.forceValue || this.hasCommentsValue || this.hasEditsValue) {
+ // eslint-disable-next-line no-param-reassign
+ event.returnValue = confirmationMessage;
+
+ return confirmationMessage;
+ }
+
+ return null;
+ }
+
+ hasCommentsValueChanged(current: boolean, previous: boolean) {
+ if (current !== previous) this.notify();
+ }
+
+ hasEditsValueChanged(current: boolean, previous: boolean) {
+ if (current !== previous) this.notify();
+ }
+
+ /**
+ * Notify the user of changes to the form.
+ * Dispatch events to update the footer message via dispatching events.
+ */
+ notify() {
+ const comments = this.hasCommentsValue;
+ const edits = this.hasEditsValue;
+
+ if (!comments && !edits) {
+ this.dispatch('clear', { cancelable: false });
+ return;
+ }
+
+ const [type] = [
+ edits && comments && 'all',
+ comments && 'comments',
+ edits && 'edits',
+ ].filter(Boolean);
+
+ this.dispatch('add', { cancelable: false, detail: { type } });
+ }
+
+ /**
+ * When the form is submitted, ensure that the exit confirmation
+ * does not trigger. Deactivate the confirmation by setting the
+ * confirm value to empty.
+ */
+ submit() {
+ this.confirmationValue = '';
+ }
+
+ /**
+ * Watch for comment changes, updating the timeout to match the timings for
+ * responding to page form changes.
+ */
+ watchComments({ long: longDuration, short: shortDuration }) {
+ let updateIsCommentsDirty;
+
+ const { commentApp } = window.comments;
+
+ const initialComments = commentApp.selectors.selectIsDirty(
+ commentApp.store.getState(),
+ );
+
+ this.dispatch('watch-edits', {
+ cancelable: false,
+ detail: { initialComments },
+ });
+
+ this.hasCommentsValue = initialComments;
+
+ commentApp.store.subscribe(() => {
+ if (updateIsCommentsDirty) {
+ updateIsCommentsDirty.cancel();
+ }
+
+ updateIsCommentsDirty = debounce(
+ () => {
+ this.hasCommentsValue = commentApp.selectors.selectIsDirty(
+ commentApp.store.getState(),
+ );
+ },
+ this.hasCommentsValue ? longDuration : shortDuration,
+ );
+
+ updateIsCommentsDirty();
+ });
+ }
+
+ /**
+ * Delay snap-shotting the form’s data to avoid race conditions with form widgets that might process the values.
+ * User interaction with the form within that delay also won’t trigger the confirmation message if
+ * they are only quickly viewing the form.
+ *
+ * While the `check` method will be triggered based on Stimulus actions (e.g. change/keyup) we also
+ * want to account for input DOM notes entering/existing the UI and check when that happens.
+ */
+ watchEdits({ initial: initialDelay }) {
+ const form = this.element;
+
+ debounce(() => {
+ const initialFormData = this.formData;
+
+ this.initialFormData = initialFormData;
+
+ this.dispatch('watch-edits', {
+ cancelable: false,
+ detail: { initialFormData },
+ });
+
+ const isValidInputNode = (node) =>
+ node.nodeType === node.ELEMENT_NODE &&
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName);
+
+ const observer = new MutationObserver((mutationList) => {
+ const hasMutationWithValidInputNode = mutationList.some(
+ (mutation) =>
+ Array.from(mutation.addedNodes).some(isValidInputNode) ||
+ Array.from(mutation.removedNodes).some(isValidInputNode),
+ );
+
+ if (hasMutationWithValidInputNode) this.check();
+ });
+
+ observer.observe(form, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+
+ this.observer = observer;
+ }, initialDelay)();
+ }
+
+ disconnect() {
+ if (this.runningCheck) {
+ this.runningCheck.cancel();
+ }
+ if (this.observer) {
+ this.observer.disconnect();
+ }
+ }
+
+ /**
+ * Ensure we have backwards compatibility for any window global calls to
+ * `window.enableDirtyFormCheck`.
+ *
+ * @deprecated RemovedInWagtail70
+ */
+ static afterLoad(identifier: string) {
+ /**
+ * Support a basic form of the legacy "dirty form check" global initialisation function.
+ *
+ * @param {string} formSelector - A CSS selector to select the form to apply this check to.
+ * @param {Object} options
+ * @param {boolean} options.alwaysDirty - When set to true the form will always be considered dirty
+ * @param {string} options.confirmationMessage - The message to display in the prompt.
+ *
+ * @deprecated RemovedInWagtail70
+ */
+ const enableDirtyFormCheck = (
+ formSelector: string,
+ { alwaysDirty = false, confirmationMessage = '' },
+ ) => {
+ domReady().then(() => {
+ const form = document.querySelector(
+ `${formSelector}:not([data-controller~='${identifier}'])`,
+ );
+
+ if (!(form instanceof HTMLFormElement)) return;
+
+ [
+ ['data-w-unsaved-confirmation-value', confirmationMessage || ' '],
+ ['data-w-unsaved-force-value', String(alwaysDirty || false)],
+ ['data-w-unsaved-watch-value', 'edits comments'],
+ ].forEach(([key, value]) => {
+ form.setAttribute(key, value);
+ });
+
+ form.setAttribute(
+ 'data-action',
+ [
+ form.getAttribute('data-action') || '',
+ 'w-unsaved#submit',
+ 'beforeunload@window->w-unsaved#confirm',
+ 'change->w-unsaved#check',
+ 'keyup->w-unsaved#check',
+ ]
+ .filter(Boolean)
+ .join(' '),
+ );
+
+ form.setAttribute(
+ 'data-controller',
+ [form.getAttribute('data-controller') || '', identifier]
+ .filter(Boolean)
+ .join(' '),
+ );
+ });
+ };
+
+ window.enableDirtyFormCheck = enableDirtyFormCheck;
+ }
+}
diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts
index c83111b17f..2019db9ef8 100644
--- a/client/src/controllers/index.ts
+++ b/client/src/controllers/index.ts
@@ -21,6 +21,7 @@ import { SyncController } from './SyncController';
import { TagController } from './TagController';
import { TeleportController } from './TeleportController';
import { TooltipController } from './TooltipController';
+import { UnsavedController } from './UnsavedController';
import { UpgradeController } from './UpgradeController';
/**
@@ -50,5 +51,6 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: TagController, identifier: 'w-tag' },
{ controllerConstructor: TeleportController, identifier: 'w-teleport' },
{ controllerConstructor: TooltipController, identifier: 'w-tooltip' },
+ { controllerConstructor: UnsavedController, identifier: 'w-unsaved' },
{ controllerConstructor: UpgradeController, identifier: 'w-upgrade' },
];
diff --git a/client/src/entrypoints/admin/core.js b/client/src/entrypoints/admin/core.js
index d692782068..7b30f1bf34 100644
--- a/client/src/entrypoints/admin/core.js
+++ b/client/src/entrypoints/admin/core.js
@@ -32,184 +32,6 @@ window.wagtail = wagtail;
window.escapeHtml = escapeHtml;
-/**
- * Enables a "dirty form check", prompting the user if they are navigating away
- * from a page with unsaved changes, as well as optionally controlling other
- * behaviour via a callback
- *
- * It takes the following parameters:
- *
- * - formSelector - A CSS selector to select the form to apply this check to.
- *
- * - options - An object for passing in options. Possible options are:
- * - confirmationMessage - The message to display in the prompt.
- * - alwaysDirty - When set to true the form will always be considered dirty,
- * prompting the user even when nothing has been changed.
- * - commentApp - The CommentApp used by the commenting system, if the dirty check
- * should include comments
- * - callback - A function to be run when the dirty status of the form, or the comments
- * system (if using) changes, taking formDirty, commentsDirty as arguments
- */
-function enableDirtyFormCheck(formSelector, options) {
- const $form = $(formSelector);
- const confirmationMessage = options.confirmationMessage || ' ';
- const alwaysDirty = options.alwaysDirty || false;
- const commentApp = options.commentApp || null;
- let initialData = null;
- let formSubmitted = false;
-
- const updateCallback = (formDirty, commentsDirty) => {
- if (!formDirty && !commentsDirty) {
- document.dispatchEvent(new CustomEvent('w-unsaved:clear'));
- return;
- }
-
- const [type] = [
- formDirty && commentsDirty && 'all',
- commentsDirty && 'comments',
- formDirty && 'edits',
- ].filter(Boolean);
-
- document.dispatchEvent(
- new CustomEvent('w-unsaved:add', { detail: { type } }),
- );
- };
-
- $form.on('submit', () => {
- formSubmitted = true;
- });
-
- let isDirty = alwaysDirty;
- let isCommentsDirty = false;
-
- let updateIsCommentsDirtyTimeout = -1;
- if (commentApp) {
- isCommentsDirty = commentApp.selectors.selectIsDirty(
- commentApp.store.getState(),
- );
- commentApp.store.subscribe(() => {
- // Update on a timeout to match the timings for responding to page form changes
- clearTimeout(updateIsCommentsDirtyTimeout);
- updateIsCommentsDirtyTimeout = setTimeout(
- () => {
- const newIsCommentsDirty = commentApp.selectors.selectIsDirty(
- commentApp.store.getState(),
- );
- if (newIsCommentsDirty !== isCommentsDirty) {
- isCommentsDirty = newIsCommentsDirty;
- updateCallback(isDirty, isCommentsDirty);
- }
- },
- isCommentsDirty ? 3000 : 300,
- );
- });
- }
-
- updateCallback(isDirty, isCommentsDirty);
-
- let updateIsDirtyTimeout = -1;
-
- const isFormDirty = () => {
- if (alwaysDirty) {
- return true;
- }
- if (!initialData) {
- return false;
- }
-
- const formData = new FormData($form[0]);
- const keys = Array.from(formData.keys()).filter(
- (key) => !key.startsWith('comments-'),
- );
- if (keys.length !== initialData.size) {
- return true;
- }
-
- return keys.some((key) => {
- const newValue = formData.getAll(key);
- const oldValue = initialData.get(key);
- if (newValue === oldValue) {
- return false;
- }
- if (Array.isArray(newValue) && Array.isArray(oldValue)) {
- return (
- newValue.length !== oldValue.length ||
- newValue.some((value, index) => value !== oldValue[index])
- );
- }
- return false;
- });
- };
-
- const updateIsDirty = () => {
- const previousIsDirty = isDirty;
- isDirty = isFormDirty();
- if (previousIsDirty !== isDirty) {
- updateCallback(isDirty, isCommentsDirty);
- }
- };
-
- // Delay snapshotting the form’s data to avoid race conditions with form widgets that might process the values.
- // User interaction with the form within that delay also won’t trigger the confirmation message.
- if (!alwaysDirty) {
- setTimeout(() => {
- const initialFormData = new FormData($form[0]);
- initialData = new Map();
- Array.from(initialFormData.keys())
- .filter((key) => !key.startsWith('comments-'))
- .forEach((key) => initialData.set(key, initialFormData.getAll(key)));
-
- const updateDirtyCheck = () => {
- clearTimeout(updateIsDirtyTimeout);
- // If the form is dirty, it is relatively unlikely to become clean again, so
- // run the dirty check on a relatively long timer that we reset on any form update
- // otherwise, use a short timer both for nicer UX and to ensure widgets
- // like Draftail have time to serialize their data
- updateIsDirtyTimeout = setTimeout(updateIsDirty, isDirty ? 3000 : 300);
- };
-
- $form.on('change keyup', updateDirtyCheck).trigger('change');
-
- const isValidInputNode = (node) =>
- node.nodeType === node.ELEMENT_NODE &&
- ['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName);
-
- const observer = new MutationObserver((mutationList) => {
- const hasMutationWithValidInputNode = mutationList.some(
- (mutation) =>
- Array.from(mutation.addedNodes).some(isValidInputNode) ||
- Array.from(mutation.removedNodes).some(isValidInputNode),
- );
-
- if (hasMutationWithValidInputNode) {
- updateDirtyCheck();
- }
- });
-
- observer.observe($form[0], {
- childList: true,
- attributes: false,
- subtree: true,
- });
- }, 1000 * 10);
- }
-
- // eslint-disable-next-line consistent-return
- window.addEventListener('beforeunload', (event) => {
- clearTimeout(updateIsDirtyTimeout);
- updateIsDirty();
- const displayConfirmation = !formSubmitted && (isDirty || isCommentsDirty);
-
- if (displayConfirmation) {
- // eslint-disable-next-line no-param-reassign
- event.returnValue = confirmationMessage;
- return confirmationMessage;
- }
- });
-}
-
-window.enableDirtyFormCheck = enableDirtyFormCheck;
-
$(() => {
/* Dropzones */
$('.drop-zone')
diff --git a/docs/releases/6.0.md b/docs/releases/6.0.md
index 806ebcb83d..b54f30dfb5 100644
--- a/docs/releases/6.0.md
+++ b/docs/releases/6.0.md
@@ -264,6 +264,31 @@ class MyPage(Page):
The undocumented internal methods `filter_queryset(queryset)` on `wagtail.admin.views.generic.IndexView`, and `get_filtered_queryset()` on `wagtail.admin.views.reports.ReportView`, now return just the filtered queryset; previously they returned a tuple of `(filters, queryset)`. The filterset instance is always available as the cached property `self.filters`.
+### Deprecation of the undocumented `window.enableDirtyFormCheck` function
+
+The admin frontend `window.enableDirtyFormCheck` will be removed in a future release and as of this release only supports the basic initialization.
+
+The previous approach was to call a window global function as follows.
+
+```javascript
+window.enableDirtyFormCheck('.my-form', { alwaysDirty: true, confirmationMessage: 'You have unsaved changes'});
+```
+
+The new approach will be data attribute driven as follows.
+
+```javascript
+
+```
+
### `data-tippy-content` attribute support will be removed
The implementation of the JS tooltips have been fully migrated to the Stimulus `w-tooltip`/`TooltipController` implementation.