mirror of
https://github.com/wagtail/wagtail.git
synced 2024-11-25 05:02:57 +01:00
Migrate enableDirtyFormCheck to Stimulus UnsavedController
- Include backwards compatible global function replacement
This commit is contained in:
parent
dece4fdaf4
commit
422d6a8cbe
189
client/src/controllers/UnsavedController.test.js
Normal file
189
client/src/controllers/UnsavedController.test.js
Normal file
@ -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 = `
|
||||
<section>
|
||||
<form
|
||||
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!"
|
||||
>
|
||||
<input type="text" id="name" value="John" />
|
||||
<button>Submit</submit>
|
||||
</form>
|
||||
</section>`,
|
||||
identifier = 'w-unsaved',
|
||||
) => {
|
||||
document.body.innerHTML = `<main>${html}</main>`;
|
||||
|
||||
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(`
|
||||
<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="You have unsaved changes!"
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
});
|
384
client/src/controllers/UnsavedController.ts
Normal file
384
client/src/controllers/UnsavedController.ts
Normal file
@ -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
|
||||
* <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!"
|
||||
* >
|
||||
* <input type="text" value="something" />
|
||||
* <button>Submit</submit>
|
||||
* </form>
|
||||
*
|
||||
* @example - Watch comments for changes in addition to edits (default is edits only)
|
||||
* <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-watch-value="edits comments"
|
||||
* >
|
||||
* <input type="text" value="something" />
|
||||
* <button>Submit</submit>
|
||||
* </form>
|
||||
*
|
||||
* @example - Force the confirmation dialog
|
||||
* <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-force-value="true"
|
||||
* >
|
||||
* <input type="text" value="something" />
|
||||
* <button>Submit</submit>
|
||||
* </form>
|
||||
*
|
||||
* @example - Force the confirmation dialog without watching for edits/comments
|
||||
* <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-force-value="true"
|
||||
* data-w-unsaved-watch-value=""
|
||||
* >
|
||||
* <input type="text" value="something" />
|
||||
* <button>Submit</submit>
|
||||
* </form>
|
||||
*/
|
||||
export class UnsavedController extends Controller<HTMLFormElement> {
|
||||
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<typeof debounce>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -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' },
|
||||
];
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
<form
|
||||
method="POST"
|
||||
data-controller="w-unsaved"
|
||||
data-action="w-unsaved#submit beforeunload@window->w-unsaved#confirm change->w-unsaved#check keyup->w-unsaved#check"
|
||||
data-w-unsaved-confirm-value="This page has unsaved changes." // equivalent to `confirmationMessage`.
|
||||
data-w-unsaved-force-value="true" // equivalent to `alwaysDirty`.
|
||||
data-w-unsaved-watch-value="edits comments" // can add 'comments' if comments is enabled, defaults to only 'edits'.
|
||||
>
|
||||
... form contents
|
||||
</form>
|
||||
```
|
||||
|
||||
### `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.
|
||||
|
Loading…
Reference in New Issue
Block a user