0
0
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:
the-r3aper7 2023-12-21 19:24:13 +05:30 committed by LB (Ben Johnston)
parent dece4fdaf4
commit 422d6a8cbe
5 changed files with 600 additions and 178 deletions

View 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);
});
});
});

View 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 forms data to avoid race conditions with form widgets that might process the values.
* User interaction with the form within that delay also wont 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;
}
}

View File

@ -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' },
]; ];

View File

@ -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 forms data to avoid race conditions with form widgets that might process the values.
// User interaction with the form within that delay also wont 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')

View File

@ -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.