mirror of
https://github.com/wagtail/wagtail.git
synced 2024-11-29 01:22:07 +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 { TagController } from './TagController';
|
||||||
import { TeleportController } from './TeleportController';
|
import { TeleportController } from './TeleportController';
|
||||||
import { TooltipController } from './TooltipController';
|
import { TooltipController } from './TooltipController';
|
||||||
|
import { UnsavedController } from './UnsavedController';
|
||||||
import { UpgradeController } from './UpgradeController';
|
import { UpgradeController } from './UpgradeController';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,5 +51,6 @@ export const coreControllerDefinitions: Definition[] = [
|
|||||||
{ controllerConstructor: TagController, identifier: 'w-tag' },
|
{ controllerConstructor: TagController, identifier: 'w-tag' },
|
||||||
{ controllerConstructor: TeleportController, identifier: 'w-teleport' },
|
{ controllerConstructor: TeleportController, identifier: 'w-teleport' },
|
||||||
{ controllerConstructor: TooltipController, identifier: 'w-tooltip' },
|
{ controllerConstructor: TooltipController, identifier: 'w-tooltip' },
|
||||||
|
{ controllerConstructor: UnsavedController, identifier: 'w-unsaved' },
|
||||||
{ controllerConstructor: UpgradeController, identifier: 'w-upgrade' },
|
{ controllerConstructor: UpgradeController, identifier: 'w-upgrade' },
|
||||||
];
|
];
|
||||||
|
@ -32,184 +32,6 @@ window.wagtail = wagtail;
|
|||||||
|
|
||||||
window.escapeHtml = escapeHtml;
|
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 */
|
/* Dropzones */
|
||||||
$('.drop-zone')
|
$('.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`.
|
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
|
### `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.
|
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