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 + *
+ * + *