From 054e72b4c26fa12b7ba92a0943b586396a2b6dc4 Mon Sep 17 00:00:00 2001 From: Sage Abdullah Date: Sun, 7 Apr 2024 01:54:36 +0700 Subject: [PATCH] Setup main PreviewController unit tests Test loading the last device size from localStorage Ensure selected device size class is applied on connect Add test for using ResizeObserver in PreviewController Add tests for default PreviewController behaviour Add test for opening preview in a new tab Add test for handling a request error when opening preview in a new tab Add test for showing the spinner when loading the preview Add test for enforcing rendered preview width when there are errors Split PreviewController tests into separate describe blocks Use fake timers for all PreviewController tests Add more detailed assertions in initializeOpenedPanel Add assertions for setTimeout in PreviewController test Add test for auto-update cycle Add test for disabling auto update on panel close Add tests for manually updating the preview Add tests for switching preview modes Only add the mode select element for these tests, to ensure that the preview panel works even without it (e.g. for models that only define a single preview mode) Add tests for PreviewController.disconnect and for requiring the url value Add test for assuming the first size input is the default Add ResizeObserver test for preview controller Reuse url variable in PreviewController tests --- .../src/controllers/PreviewController.test.js | 1553 ++++++++++++++++- client/src/controllers/PreviewController.ts | 12 + 2 files changed, 1554 insertions(+), 11 deletions(-) diff --git a/client/src/controllers/PreviewController.test.js b/client/src/controllers/PreviewController.test.js index 68d099af83..5e4ccce12e 100644 --- a/client/src/controllers/PreviewController.test.js +++ b/client/src/controllers/PreviewController.test.js @@ -1,28 +1,1559 @@ import { Application } from '@hotwired/stimulus'; +import { ProgressController } from './ProgressController'; import { PreviewController } from './PreviewController'; +jest.mock('../config/wagtailConfig.js', () => ({ + WAGTAIL_CONFIG: { + CSRF_HEADER_NAME: 'X-CSRFToken', + CSRF_TOKEN: 'test-token', + }, +})); + +jest.useFakeTimers(); +jest.spyOn(global, 'setTimeout'); + describe('PreviewController', () => { let application; - const url = '/preview/'; + let windowSpy; + + const identifier = 'w-preview'; + + const events = { + update: [], + json: [], + error: [], + load: [], + loaded: [], + ready: [], + updated: [], + }; + + const pushEvent = (event) => + events[event.type.substring(identifier.length + 1)].push(event); + + const originalWindow = { ...window }; + const mockWindow = (props) => + windowSpy.mockImplementation(() => ({ + ...originalWindow, + ...props, + })); + + const ResizeObserverMock = jest.fn((callback) => { + const observed = []; + return { + callback, + observe: jest.fn((el) => observed.push(el)), + unobserve: jest.fn((el) => observed.splice(observed.indexOf(el), 1)), + disconnect: jest.fn(() => observed.splice(0, observed.length)), + // Not a real ResizeObserver method, but useful for simulating resize + notify: jest.fn((entries) => callback(entries)), + }; + }); + + global.ResizeObserver = ResizeObserverMock; + + const url = '/admin/pages/1/edit/preview/'; + const spinner = /* html */ ` + + `; + const validAvailableResponse = `{ "is_valid": true, "is_available": true }`; + const invalidAvailableResponse = `{ "is_valid": false, "is_available": true }`; + const unavailableResponse = `{ "is_valid": false, "is_available": false }`; + + const refreshButton = /* html */ ` + + `; + + const modeSelect = /* html */ ` + + `; + + beforeAll(() => { + Object.keys(events).forEach((name) => { + document.addEventListener(`${identifier}:${name}`, pushEvent); + }); + }); + + afterAll(() => { + Object.keys(events).forEach((name) => { + document.removeEventListener(`${identifier}:${name}`, pushEvent); + }); + }); beforeEach(() => { - document.body.innerHTML = ` -
-
+ windowSpy = jest.spyOn(global, 'window', 'get'); + + document.body.innerHTML = /* html */ ` +
+ +
+
+

Checks

+
+
+

Preview

+
+ + + + + + + Preview in new tab + + + + + + + +
+
`; }); afterEach(() => { application.stop(); + jest.clearAllMocks(); + jest.clearAllTimers(); + windowSpy.mockRestore(); + localStorage.removeItem('wagtail:preview-panel-device'); + Object.keys(events).forEach((name) => { + events[name] = []; + }); }); - it('should start the application', async () => { - // start application + const expectIframeReloaded = async ( + expectedUrl = `http://localhost${url}?in_preview_panel=true`, + ) => { + // Should create a new invisible iframe with the correct URL + let iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(2); + const oldIframe = iframes[0]; + const newIframe = iframes[1]; + const oldIframeId = oldIframe.id; + expect(oldIframeId).toBeTruthy(); + expect(newIframe.hasAttribute('id')).toBe(false); + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.classList.contains('w-preview__proxy')).toBe(true); + + // Simulate the iframe loading + const mockScroll = jest.fn(); + newIframe.contentWindow.scroll = mockScroll; + await Promise.resolve(); + newIframe.dispatchEvent(new Event('load')); + expect(mockScroll).toHaveBeenCalled(); + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0]).toBe(newIframe); + expect(newIframe.id).toEqual(oldIframeId); + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.getAttribute('style')).toBeNull(); + expect(newIframe.contentWindow.scroll).toHaveBeenCalledWith( + oldIframe.contentWindow.scrollX, + oldIframe.contentWindow.scrollY, + ); + + // Clear the fetch call history + fetch.mockClear(); + }; + + const initializeOpenedPanel = async (expectedUrl) => { + expect(global.fetch).not.toHaveBeenCalled(); + expect(events).toMatchObject({ + update: [], + json: [], + error: [], + load: [], + loaded: [], + ready: [], + updated: [], + }); + application = Application.start(); - application.register('w-preview', PreviewController); + application.register(identifier, PreviewController); + await Promise.resolve(); + + // Should not have fetched the preview URL + expect(global.fetch).not.toHaveBeenCalled(); + + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Open the side panel + const sidePanelContainer = document.querySelector( + '[data-side-panel="preview"]', + ); + sidePanelContainer.dispatchEvent(new Event('show')); + await Promise.resolve(); + + // There's no spinner, so setTimeout should not be called + expect(setTimeout).not.toHaveBeenCalled(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + + // At this point, there should only be one fetch call (when the panel is opened) + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Initially, the iframe src should be empty so it doesn't load the preview + // until after the request is complete + const iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0].src).toEqual(''); + + // Simulate the request completing + await Promise.resolve(); + + await expectIframeReloaded(expectedUrl); + expect(events).toMatchObject({ + update: [expect.any(Event)], + json: [expect.any(Event)], + error: [], + load: [expect.any(Event)], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event)], + }); + }; + + describe('controlling the preview size', () => { + it('should load the last device size from localStorage', async () => { + localStorage.setItem('wagtail:preview-panel-device', 'tablet'); + application = Application.start(); + application.register(identifier, PreviewController); + + const element = document.querySelector('[data-controller="w-preview"]'); + await Promise.resolve(); + const selectedSizeInput = document.querySelector( + 'input[name="preview-size"]:checked', + ); + expect(selectedSizeInput.value).toEqual('tablet'); + const selectedSizeLabel = selectedSizeInput.labels[0]; + expect( + selectedSizeLabel.classList.contains( + 'w-preview__size-button--selected', + ), + ).toBe(true); + }); + + it('should set the device size accordingly when the input changes', async () => { + application = Application.start(); + application.register(identifier, PreviewController); + + const element = document.querySelector('[data-controller="w-preview"]'); + await Promise.resolve(); + + // Initial size should be mobile, with the localStorage value unset + const selectedSizeInput = document.querySelector( + 'input[name="preview-size"]:checked', + ); + const selectedSizeLabel = selectedSizeInput.labels[0]; + expect(selectedSizeInput.value).toEqual('mobile'); + expect( + selectedSizeLabel.classList.contains( + 'w-preview__size-button--selected', + ), + ).toBe(true); + expect(localStorage.getItem('wagtail:preview-panel-device')).toBeNull(); + + const desktopSizeInput = document.querySelector( + 'input[name="preview-size"][value="desktop"]', + ); + desktopSizeInput.click(); + await Promise.resolve(); + const newSizeInput = document.querySelector( + 'input[name="preview-size"]:checked', + ); + expect(newSizeInput.value).toEqual('desktop'); + const newSizeLabel = newSizeInput.labels[0]; + expect( + newSizeLabel.classList.contains('w-preview__size-button--selected'), + ).toBe(true); + expect(localStorage.getItem('wagtail:preview-panel-device')).toEqual( + 'desktop', + ); + expect(element.style.getPropertyValue('--preview-device-width')).toEqual( + '1280', + ); + }); + + it('should observe its own size so it can set the preview width accordingly', async () => { + expect(ResizeObserverMock).not.toHaveBeenCalled(); + + application = Application.start(); + application.register(identifier, PreviewController); + + await Promise.resolve(); + + const previewPanel = document.querySelector('.w-preview'); + + expect(ResizeObserverMock).toHaveBeenCalledTimes(1); + expect(ResizeObserverMock).toHaveBeenCalledWith(expect.any(Function)); + + const observer = ResizeObserverMock.mock.results[0].value; + expect(observer.observe).toHaveBeenCalledWith(previewPanel); + + observer.notify([{ contentRect: { width: 5463 } }]); + expect(previewPanel.style.getPropertyValue('--preview-panel-width')).toBe( + '5463', + ); + }); + }); + + describe('basic behaviour', () => { + it('should initialize the preview when the side panel is opened', async () => { + expect(global.fetch).not.toHaveBeenCalled(); + expect(events.ready).toHaveLength(0); + + application = Application.start(); + application.register(identifier, PreviewController); + await Promise.resolve(); + + // Should not have fetched the preview URL + expect(global.fetch).not.toHaveBeenCalled(); + expect(events.update).toHaveLength(0); + + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Open the side panel + const sidePanelContainer = document.querySelector( + '[data-side-panel="preview"]', + ); + sidePanelContainer.dispatchEvent(new Event('show')); + await Promise.resolve(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + expect(events.update).toHaveLength(1); + expect(events.json).toHaveLength(0); + expect(events.load).toHaveLength(0); + + // Initially, the iframe src should be empty so it doesn't load the preview + // until after the request is complete + let iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0].src).toEqual(''); + + await Promise.resolve(); + expect(events.json).toHaveLength(1); + expect(events.load).toHaveLength(1); + + const expectedUrl = `http://localhost${url}?in_preview_panel=true`; + + // Should create a new invisible iframe with the correct URL + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(2); + const oldIframe = iframes[0]; + const newIframe = iframes[1]; + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.classList.contains('w-preview__proxy')).toBe(true); + + // Mock the iframe's scroll method + newIframe.contentWindow.scroll = jest.fn(); + + await Promise.resolve(); + expect(events.loaded).toHaveLength(0); + expect(events.ready).toHaveLength(0); + expect(events.updated).toHaveLength(0); + + // Simulate the iframe loading + newIframe.dispatchEvent(new Event('load')); + + // Should remove the old iframe and make the new one visible + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0]).toBe(newIframe); + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.getAttribute('style')).toBeNull(); + expect(newIframe.contentWindow.scroll).toHaveBeenCalledWith( + oldIframe.contentWindow.scrollX, + oldIframe.contentWindow.scrollY, + ); + + // Should set the device width property to the selected size (the default) + const element = document.querySelector('[data-controller="w-preview"]'); + expect(element.style.getPropertyValue('--preview-device-width')).toEqual( + '375', + ); + + // By the end, there should only be one fetch call + expect(global.fetch).toHaveBeenCalledTimes(1); + + expect(events).toMatchObject({ + update: [expect.any(Event)], + json: [expect.any(Event)], + error: [], + load: [expect.any(Event)], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event)], + }); + }); + + it('should not clear the preview data if the data is invalid and unavailable on initial load', async () => { + expect(global.fetch).not.toHaveBeenCalled(); + expect(events.ready).toHaveLength(0); + + // Set to a non-default preview size + localStorage.setItem('wagtail:preview-panel-device', 'desktop'); + + application = Application.start(); + application.register(identifier, PreviewController); + await Promise.resolve(); + + const element = document.querySelector('[data-controller="w-preview"]'); + const selectedSizeInput = document.querySelector( + 'input[name="preview-size"]:checked', + ); + expect(selectedSizeInput.value).toEqual('desktop'); + const selectedSizeLabel = selectedSizeInput.labels[0]; + expect( + selectedSizeLabel.classList.contains( + 'w-preview__size-button--selected', + ), + ).toBe(true); + + // Should not have fetched the preview URL + expect(global.fetch).not.toHaveBeenCalled(); + expect(events.update).toHaveLength(0); + + // Mock invalid data but no stale preview available + fetch.mockResponseSuccessJSON(unavailableResponse); + + // Open the side panel + const sidePanelContainer = document.querySelector( + '[data-side-panel="preview"]', + ); + sidePanelContainer.dispatchEvent(new Event('show')); + await Promise.resolve(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + expect(events.update).toHaveLength(1); + expect(events.json).toHaveLength(0); + + // Initially, the iframe src should be empty so it doesn't load the preview + // until after the request is complete + let iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0].src).toEqual(''); + expect(events.load).toHaveLength(0); + + // Simulate the POST request completing with unavailableResponse + await Promise.resolve(); + expect(events.json).toHaveLength(1); + + await Promise.resolve(); + + // Should NOT send a request to clear the preview data, as there is no + // stale data that needs to be cleared + expect(global.fetch).not.toHaveBeenCalledWith(url, { + headers: { + 'X-CSRFToken': 'test-token', + }, + method: 'DELETE', + }); + + // Should now try to reload the iframe + expect(events.load).toHaveLength(1); + + const expectedUrl = `http://localhost${url}?in_preview_panel=true`; + + // Should create a new invisible iframe with the correct URL + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(2); + const oldIframe = iframes[0]; + const newIframe = iframes[1]; + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.classList.contains('w-preview__proxy')).toBe(true); + expect(events.ready).toHaveLength(0); + + // Mock the iframe's scroll method + newIframe.contentWindow.scroll = jest.fn(); + + await Promise.resolve(); + expect(events.loaded).toHaveLength(0); + expect(events.ready).toHaveLength(0); + expect(events.updated).toHaveLength(0); + + // Simulate the iframe loading + newIframe.dispatchEvent(new Event('load')); + + // Should remove the old iframe and make the new one visible + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0]).toBe(newIframe); + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.getAttribute('style')).toBeNull(); + expect(newIframe.contentWindow.scroll).toHaveBeenCalledWith( + oldIframe.contentWindow.scrollX, + oldIframe.contentWindow.scrollY, + ); + + // Should set the has-errors class on the controlled element + expect(element.classList).toContain('w-preview--has-errors'); + + // The "selected" preview size button should remain the same (desktop) + const currentSizeInput = document.querySelector( + 'input[name="preview-size"]:checked', + ); + expect(currentSizeInput.value).toEqual('desktop'); + const currentSizeLabel = currentSizeInput.labels[0]; + expect( + currentSizeLabel.classList.contains('w-preview__size-button--selected'), + ).toBe(true); + const defaultSizeInput = document.querySelector( + 'input[name="preview-size"][data-default-size]', + ); + expect(defaultSizeInput.value).toEqual('mobile'); + const defaultSizeLabel = defaultSizeInput.labels[0]; + expect( + defaultSizeLabel.classList.contains('w-preview__size-button--selected'), + ).toBe(false); + + // However, the actual rendered size should be the default size + // (This is because the "Preview is unavailable" screen is actually the + // rendered preview response in the iframe instead of elements directly + // rendered in the controller's DOM. To ensure the screen is readable and + // not scaled down, the iframe is set to the default size.) + expect(element.style.getPropertyValue('--preview-device-width')).toEqual( + '375', + ); + + // By the end, there should only be one fetch call: one to send the initial invalid + // preview data. No fetch calls to clear the preview data should have been made, + // as there was no stale data to clear. + expect(global.fetch).toHaveBeenCalledTimes(1); + + expect(events).toMatchObject({ + update: [expect.any(Event)], + json: [ + // Initial is invalid but there is an existing preview available, + // so it should be cleared + expect.objectContaining({ + detail: { data: { is_valid: false, is_available: false } }, + }), + ], + error: [], + load: [expect.any(Event)], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event)], + }); + }); + + it('should clear the preview data if the data is invalid but available on initial load', async () => { + expect(global.fetch).not.toHaveBeenCalled(); + expect(events.ready).toHaveLength(0); + + // Set to a non-default preview size + localStorage.setItem('wagtail:preview-panel-device', 'desktop'); + + application = Application.start(); + application.register(identifier, PreviewController); + await Promise.resolve(); + + const element = document.querySelector('[data-controller="w-preview"]'); + const selectedSizeInput = document.querySelector( + 'input[name="preview-size"]:checked', + ); + expect(selectedSizeInput.value).toEqual('desktop'); + const selectedSizeLabel = selectedSizeInput.labels[0]; + expect( + selectedSizeLabel.classList.contains( + 'w-preview__size-button--selected', + ), + ).toBe(true); + + // Should not have fetched the preview URL + expect(global.fetch).not.toHaveBeenCalled(); + expect(events.update).toHaveLength(0); + + // Mock stale preview data + fetch.mockResponseSuccessJSON(invalidAvailableResponse); + + // Open the side panel + const sidePanelContainer = document.querySelector( + '[data-side-panel="preview"]', + ); + sidePanelContainer.dispatchEvent(new Event('show')); + await Promise.resolve(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + expect(events.update).toHaveLength(1); + expect(events.json).toHaveLength(0); + + // Mock successful response to clear the preview data + fetch.mockResponseSuccessJSON(`{ "success": true }`); + + // Simulate the POST request completing with invalidAvailableResponse, + // which will kick off a DELETE request immediately to clear the stale data + await Promise.resolve(); + expect(events.json).toHaveLength(1); + + // Should send a request to clear the preview data + expect(global.fetch).toHaveBeenCalledWith(url, { + headers: { + 'X-CSRFToken': 'test-token', + }, + method: 'DELETE', + }); + // Should not try to reload the iframe yet + expect(events.load).toHaveLength(0); + + // Initially, the iframe src should be empty so it doesn't load the preview + // until after the request is complete + let iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0].src).toEqual(''); + + // Simulate the DELETE request completing + await Promise.resolve(); + expect(events.load).toHaveLength(1); + + const expectedUrl = `http://localhost${url}?in_preview_panel=true`; + + // Should create a new invisible iframe with the correct URL + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(2); + const oldIframe = iframes[0]; + const newIframe = iframes[1]; + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.classList.contains('w-preview__proxy')).toBe(true); + expect(events.ready).toHaveLength(0); + + // Mock the iframe's scroll method + newIframe.contentWindow.scroll = jest.fn(); + + await Promise.resolve(); + expect(events.loaded).toHaveLength(0); + expect(events.ready).toHaveLength(0); + expect(events.updated).toHaveLength(0); + + // Simulate the iframe loading + newIframe.dispatchEvent(new Event('load')); + + // Should remove the old iframe and make the new one visible + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0]).toBe(newIframe); + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.getAttribute('style')).toBeNull(); + expect(newIframe.contentWindow.scroll).toHaveBeenCalledWith( + oldIframe.contentWindow.scrollX, + oldIframe.contentWindow.scrollY, + ); + + // Should set the has-errors class on the controlled element + expect(element.classList).toContain('w-preview--has-errors'); + + // The "selected" preview size button should remain the same (desktop) + const currentSizeInput = document.querySelector( + 'input[name="preview-size"]:checked', + ); + expect(currentSizeInput.value).toEqual('desktop'); + const currentSizeLabel = currentSizeInput.labels[0]; + expect( + currentSizeLabel.classList.contains('w-preview__size-button--selected'), + ).toBe(true); + const defaultSizeInput = document.querySelector( + 'input[name="preview-size"][data-default-size]', + ); + expect(defaultSizeInput.value).toEqual('mobile'); + const defaultSizeLabel = defaultSizeInput.labels[0]; + expect( + defaultSizeLabel.classList.contains('w-preview__size-button--selected'), + ).toBe(false); + + // However, the actual rendered size should be the default size + // (This is because the "Preview is unavailable" screen is actually the + // rendered preview response in the iframe instead of elements directly + // rendered in the controller's DOM. To ensure the screen is readable and + // not scaled down, the iframe is set to the default size.) + expect(element.style.getPropertyValue('--preview-device-width')).toEqual( + '375', + ); + + // By the end, there should only be two fetch calls: one to send the invalid + // preview data and one to clear the preview data + expect(global.fetch).toHaveBeenCalledTimes(2); + + expect(events).toMatchObject({ + update: [expect.any(Event)], + json: [ + // Initial is invalid but there is an existing preview available, + // so it should be cleared + expect.objectContaining({ + detail: { data: { is_valid: false, is_available: true } }, + }), + ], + error: [], + load: [expect.any(Event)], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event)], + }); + }); + + it('should update the preview data when opening in a new tab', async () => { + await initializeOpenedPanel(); + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Open the preview in a new tab + const newTabLink = document.querySelector( + '[data-w-preview-target="newTab"]', + ); + newTabLink.click(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + + mockWindow({ open: jest.fn() }); + // Run all timers and promises + await jest.runAllTimersAsync(); + + // Should call window.open() with the correct URL, and the base URL should + // be used as the second argument to ensure the same tab is reused if it's + // already open even when the URL is different, e.g. when the user changes + // the preview mode + const absoluteUrl = `http://localhost${url}`; + expect(window.open).toHaveBeenCalledWith(absoluteUrl, absoluteUrl); + }); + + it('should show an alert if the update request fails when opening in a new tab', async () => { + await initializeOpenedPanel(); + fetch.mockResponseFailure(); + + // Open the preview in a new tab + const newTabLink = document.querySelector( + '[data-w-preview-target="newTab"]', + ); + newTabLink.click(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + expect(events.update).toHaveLength(2); + + mockWindow({ open: jest.fn(), alert: jest.fn() }); + // Run all timers and promises + await jest.runAllTimersAsync(); + + // Should call window.alert() with the correct message + expect(window.alert).toHaveBeenCalledWith( + 'Error while sending preview data.', + ); + + // Should still open the new tab anyway + const absoluteUrl = `http://localhost${url}`; + expect(window.open).toHaveBeenCalledWith(absoluteUrl, absoluteUrl); + + expect(events).toMatchObject({ + update: [expect.any(Event), expect.any(Event)], // Initial, error + json: [expect.any(Event)], // Initial + error: [ + // Error + expect.objectContaining({ detail: { error: expect.any(Error) } }), + ], + load: [expect.any(Event)], // Initial + loaded: [expect.any(Event)], // Initial + ready: [expect.any(Event)], + updated: [expect.any(Event), expect.any(Event)], // Initial, error + }); + }); + + it('should only show the spinner after 2s when refreshing the preview', async () => { + // Add the spinner to the preview panel + const element = document.querySelector('[data-controller="w-preview"]'); + element.insertAdjacentHTML('beforeend', spinner); + const spinnerElement = element.querySelector( + '[data-w-preview-target="spinner"]', + ); + + expect(global.fetch).not.toHaveBeenCalled(); + + application = Application.start(); + application.register(identifier, PreviewController); + await Promise.resolve(); + + // Should not have fetched the preview URL + expect(global.fetch).not.toHaveBeenCalled(); + + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Open the side panel + const sidePanelContainer = document.querySelector( + '[data-side-panel="preview"]', + ); + sidePanelContainer.dispatchEvent(new Event('show')); + await Promise.resolve(); + + // Should set the timeout for the spinner to appear after 2s + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 2000); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + + // Initially, the iframe src should be empty so it doesn't load the preview + // until after the request is complete + let iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0].src).toEqual(''); + + // Should not show the spinner initially + expect(spinnerElement.hidden).toBe(true); + + // Mock a 2s successful request + jest.advanceTimersByTime(2000); + await Promise.resolve(); + + // Should show the spinner after 2s + expect(spinnerElement.hidden).toBe(false); + await Promise.resolve(); + + const expectedUrl = `http://localhost${url}?in_preview_panel=true`; + + // Should create a new invisible iframe with the correct URL + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(2); + const oldIframe = iframes[0]; + const newIframe = iframes[1]; + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.classList.contains('w-preview__proxy')).toBe(true); + // The spinner should still be visible while the iframe is loading + expect(spinnerElement.hidden).toBe(false); + + // Mock the iframe's scroll method + newIframe.contentWindow.scroll = jest.fn(); + + // Simulate the iframe loading + await Promise.resolve(); + newIframe.dispatchEvent(new Event('load')); + + // Should remove the old iframe and make the new one visible + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + expect(iframes[0]).toBe(newIframe); + expect(newIframe.src).toEqual(expectedUrl); + expect(newIframe.getAttribute('style')).toBeNull(); + expect(newIframe.contentWindow.scroll).toHaveBeenCalledWith( + oldIframe.contentWindow.scrollX, + oldIframe.contentWindow.scrollY, + ); + // The spinner should be hidden after the iframe loads + expect(spinnerElement.hidden).toBe(true); + + // Should set the device width property to the selected size (the default) + expect(element.style.getPropertyValue('--preview-device-width')).toEqual( + '375', + ); + + // By the end, there should only be one fetch call + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('should assume the first device size is the default if none are marked as default', async () => { + // Remove the default size marker + document + .querySelector('[data-default-size]') + .removeAttribute('data-default-size'); + expect(document.querySelectorAll('[data-default-size]')).toHaveLength(0); + + const element = document.querySelector('[data-controller="w-preview"]'); + + // Move the tablet size to the first position + const tabletSize = document.querySelector( + 'label:has(input[name="preview-size"][value="tablet"])', + ); + element.prepend(tabletSize); + + await initializeOpenedPanel(); + const tabletInput = tabletSize.querySelector('input'); + expect(tabletInput.checked).toBe(true); + expect( + tabletSize.classList.contains('w-preview__size-button--selected'), + ).toBe(true); + }); + + it('should clean up event listeners on disconnect', async () => { + await initializeOpenedPanel(); + + const element = document.querySelector('[data-controller="w-preview"]'); + const controller = application.getControllerForElementAndIdentifier( + element, + identifier, + ); + jest.spyOn(element.parentElement, 'removeEventListener'); + + element.removeAttribute('data-controller'); + await Promise.resolve(); + + expect(element.parentElement.removeEventListener).toHaveBeenCalledWith( + 'show', + controller.activatePreview, + ); + expect(element.parentElement.removeEventListener).toHaveBeenCalledWith( + 'hide', + controller.deactivatePreview, + ); + }); + + it('should require the url value to be set', async () => { + const element = document.querySelector('[data-controller="w-preview"]'); + const handleError = jest.fn(); + element.removeAttribute('data-w-preview-url-value'); + + application = Application.start(); + application.handleError = handleError; + application.register(identifier, PreviewController); + await Promise.resolve(); + + expect(handleError).toHaveBeenCalledWith( + expect.objectContaining({ + message: + 'The preview panel controller requires the data-w-preview-url-value attribute to be set', + }), + 'Error connecting controller', + expect.objectContaining({ identifier }), + ); + }); + + it('should not immediately dispatch the loaded event if the iframe src is empty', async () => { + application = Application.start(); + application.register(identifier, PreviewController); + await Promise.resolve(); + + // Simulate Firefox's behaviour where the initial iframe without the src + // attribute immediately dispatches the load event + let iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + const iframe = iframes[0]; + iframe.dispatchEvent(new Event('load')); + await Promise.resolve(); + + // Should not dispatch the loaded event, because we haven't actualy loaded + // the preview yet + expect(events.loaded).toHaveLength(0); + + // Should not create a new iframe or remove the existing one + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + }); + + it('should not remove the only iframe if the load event is fired', async () => { + await initializeOpenedPanel(); + + let iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + const iframe = iframes[0]; + + // Simulate changing the iframe src, which will reload the iframe + iframe.setAttribute('src', iframe.src + '&test=1'); + await Promise.resolve(); + iframe.contentWindow.scroll = jest.fn(); + iframe.dispatchEvent(new Event('load')); + await Promise.resolve(); + + // Should dispatch the loaded event + expect(events.loaded).toHaveLength(2); + + // Should not create a new iframe or remove the existing one + iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + }); + }); + + describe('auto update cycle from opening the panel with a valid form -> invalid form -> valid form -> closing the panel', () => { + it('should behave correctly', async () => { + expect(events.ready).toHaveLength(0); + const element = document.querySelector('[data-controller="w-preview"]'); + element.setAttribute('data-w-preview-auto-update-interval-value', '500'); + await initializeOpenedPanel(); + + // If there are no changes, should not send any request to update the preview + await jest.advanceTimersByTime(10000); + expect(global.fetch).not.toHaveBeenCalled(); + expect(events.update).toHaveLength(1); // Only contains the initial fetch + + // Simulate an invalid form submission + const input = document.querySelector('input[name="title"'); + input.value = ''; + fetch.mockResponseSuccessJSON(invalidAvailableResponse); + + // After 1s (500ms for check interval, 500ms for request debounce), + // should send the preview data to the preview URL + await jest.advanceTimersByTime(1000); + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(events.update).toHaveLength(2); + + // Should not yet have the has-errors class on the controlled element + expect(element.classList).not.toContain('w-preview--has-errors'); + + // Simulate the request completing + await Promise.resolve(); + expect(events.json).toHaveLength(2); + + // Should set the has-errors class on the controlled element + expect(element.classList).toContain('w-preview--has-errors'); + + // Should not create a new iframe for reloading the preview + const iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + // Should not dispatch a load event (only the initial load event exists) + expect(events.load).toHaveLength(1); + + fetch.mockClear(); + + // If there are no changes, should not send any request to update the preview + await jest.advanceTimersByTime(10000); + expect(global.fetch).not.toHaveBeenCalled(); + + // Simulate a change in the form + input.value = 'New title'; + + // After 800ms, the check interval should be triggered but the request + // should not be fired yet to wait for the debounce + await jest.advanceTimersByTime(800); + expect(global.fetch).not.toHaveBeenCalled(); + + // Simulate another change (that is valid) in the form + input.value = 'New title version two'; + + // After 400ms (>1s since the first change), the request should still not + // be sent due to the debounce + await jest.advanceTimersByTime(400); + expect(global.fetch).not.toHaveBeenCalled(); + + // If we wait another 300ms, the request should be sent as it has been + // 500ms since the last change + fetch.mockResponseSuccessJSON(validAvailableResponse); + await jest.advanceTimersByTime(300); + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(events.update).toHaveLength(3); + + // Simulate the request completing + expect(events.json).toHaveLength(2); + expect(events.load).toHaveLength(1); + await Promise.resolve(); + expect(events.json).toHaveLength(3); + expect(events.load).toHaveLength(2); + + // Should no longer have the has-errors class on the controlled element + expect(element.classList).not.toContain('w-preview--has-errors'); + + // Expect the iframe to be reloaded + expect(events.loaded).toHaveLength(1); + await expectIframeReloaded(); + expect(events.loaded).toHaveLength(2); + + // Close the side panel + const sidePanelContainer = document.querySelector( + '[data-side-panel="preview"]', + ); + sidePanelContainer.dispatchEvent(new Event('hide')); + await Promise.resolve(); + + // Any further changes should not trigger the auto update + input.value = 'Changes should be ignored'; + await jest.advanceTimersByTime(10000); + expect(global.fetch).not.toHaveBeenCalled(); + + expect(events).toMatchObject({ + // Initial, invalid, valid + update: [expect.any(Event), expect.any(Event), expect.any(Event)], + json: [ + expect.objectContaining({ + detail: { data: { is_valid: true, is_available: true } }, + }), + expect.objectContaining({ + detail: { data: { is_valid: false, is_available: true } }, + }), + expect.objectContaining({ + detail: { data: { is_valid: true, is_available: true } }, + }), + ], + error: [], + // Initial, valid (the invalid form submission does not reload the iframe) + load: [expect.any(Event), expect.any(Event)], + loaded: [expect.any(Event), expect.any(Event)], + ready: [expect.any(Event)], + // Initial, invalid, valid + updated: [expect.any(Event), expect.any(Event), expect.any(Event)], + }); + }); + }); + + describe('manual update using a button', () => { + let refreshButtonElement; + + beforeEach(async () => { + await initializeOpenedPanel(); + application.register('w-progress', ProgressController); + + // Add the refresh button to the preview panel + const element = document.querySelector('[data-controller="w-preview"]'); + element.insertAdjacentHTML('beforeend', refreshButton); + refreshButtonElement = element.querySelector( + '[data-controller="w-progress"]', + ); + }); + + it('should update the preview when the button is clicked', async () => { + const input = document.querySelector('input[name="title"'); + input.value = 'Changes should not trigger anything'; + + // Should not send any request to update the preview + await jest.advanceTimersByTime(10000); + expect(global.fetch).not.toHaveBeenCalled(); + + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Simulate a click on the refresh button + refreshButtonElement.click(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + + expect(refreshButtonElement.disabled).toBe(true); + + // Simulate the request completing + await Promise.resolve(); + + // Should create a new iframe for reloading the preview + await expectIframeReloaded(); + + expect(refreshButtonElement.disabled).toBe(false); + + jest.clearAllMocks(); + + // Close the side panel + const sidePanelContainer = document.querySelector( + '[data-side-panel="preview"]', + ); + sidePanelContainer.dispatchEvent(new Event('hide')); + await Promise.resolve(); + + // Any further changes should also not trigger the auto update + input.value = 'Changes should never trigger an update'; + await jest.advanceTimersByTime(10000); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should show an alert if the request fails when clicking the refresh button', async () => { + const input = document.querySelector('input[name="title"'); + input.value = 'Changes should not trigger anything'; + + // Should not send any request to update the preview + await jest.advanceTimersByTime(10000); + expect(global.fetch).not.toHaveBeenCalled(); + + fetch.mockResponseFailure(); + + // Simulate a click on the refresh button + refreshButtonElement.click(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + + mockWindow({ open: jest.fn(), alert: jest.fn() }); + // Run all timers and promises + await jest.runAllTimersAsync(); + + // Should call window.alert() with the correct message + expect(window.alert).toHaveBeenCalledWith( + 'Error while sending preview data.', + ); + }); + }); + + describe('switching between different preview modes', () => { + let previewModeElement; + + beforeEach(async () => { + // Add the preview mode selector to the preview panel + const element = document.querySelector('[data-controller="w-preview"]'); + element.insertAdjacentHTML('beforeend', modeSelect); + previewModeElement = element.querySelector( + '[data-w-preview-target="mode"]', + ); + + await initializeOpenedPanel( + `http://localhost${url}?mode=form&in_preview_panel=true`, + ); + }); + + it('should update the preview with the correct URL when switching to a different mode', async () => { + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Simulate changing the preview mode + previewModeElement.value = 'landing'; + previewModeElement.dispatchEvent(new Event('change')); + await Promise.resolve(); + + // Should immediately send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + + // Simulate the request completing + await Promise.resolve(); + + // Should create a new iframe for reloading the preview using the new URL + // with the correct mode query parameter + await expectIframeReloaded( + `http://localhost${url}?mode=landing&in_preview_panel=true`, + ); + + jest.clearAllMocks(); + }); + + it('should show an alert if the request fails when changing the preview mode', async () => { + fetch.mockResponseFailure(); + + // Simulate changing the preview mode + previewModeElement.value = 'landing'; + previewModeElement.dispatchEvent(new Event('change')); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledWith(url, { + body: expect.any(Object), + method: 'POST', + }); + + mockWindow({ open: jest.fn(), alert: jest.fn() }); + // Run all timers and promises + await jest.runAllTimersAsync(); + + // Should call window.alert() with the correct message + expect(window.alert).toHaveBeenCalledWith( + 'Error while sending preview data.', + ); + }); + }); + + describe('using different URLs for sending vs rendering the preview data', () => { + beforeEach(() => { + // Add the render URL value to the preview controller + const element = document.querySelector('[data-controller="w-preview"]'); + element.setAttribute( + 'data-w-preview-render-url-value', + 'https://app.example.com/preview/foo/7/', + ); + }); + + it('should send the preview data to the urlValue and the render should load with renderUrlValue', async () => { + await initializeOpenedPanel( + `https://app.example.com/preview/foo/7/?in_preview_panel=true`, + ); + + // Should also make sure the new tab link uses the render URL value + // (without the in_preview_panel param) + const newTabLink = document.querySelector( + '[data-w-preview-target="newTab"]', + ); + expect(newTabLink.href).toEqual('https://app.example.com/preview/foo/7/'); + }); + + it('should also set the preview mode query param on the render URL if the mode selector exists', async () => { + const element = document.querySelector('[data-controller="w-preview"]'); + element.insertAdjacentHTML('beforeend', modeSelect); + await initializeOpenedPanel( + `https://app.example.com/preview/foo/7/?mode=form&in_preview_panel=true`, + ); + + // Should also make sure the new tab link uses the render URL value + // (with the mode param but without the in_preview_panel param) + const newTabLink = document.querySelector( + '[data-w-preview-target="newTab"]', + ); + expect(newTabLink.href).toEqual( + 'https://app.example.com/preview/foo/7/?mode=form', + ); + }); + }); + + describe('cancelling specific parts of the process using events', () => { + let refreshButtonElement; + + beforeEach(async () => { + await initializeOpenedPanel(); + application.register('w-progress', ProgressController); + + // Add the refresh button to the preview panel to ease testing + const element = document.querySelector('[data-controller="w-preview"]'); + element.insertAdjacentHTML('beforeend', refreshButton); + refreshButtonElement = element.querySelector( + '[data-controller="w-progress"]', + ); + }); + + it('should allow an entire update request to be cancelled', async () => { + document.addEventListener( + 'w-preview:update', + (event) => { + event.preventDefault(); + }, + { once: true }, + ); + + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Simulate a click on the refresh button + refreshButtonElement.click(); + + await jest.runAllTimersAsync(); + + // Should not send the preview data to the preview URL + expect(global.fetch).not.toHaveBeenCalled(); + + // Should have an additional update event, but the rest stay the same + // as after the initial load + expect(events).toMatchObject({ + update: [ + expect.any(Event), + expect.objectContaining({ defaultPrevented: true }), + ], + json: [expect.any(Event)], + error: [], + load: [expect.any(Event)], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event)], + }); + + refreshButtonElement.click(); + + await jest.runAllTimersAsync(); + + // Should update the preview, as the event listener is only called once + // and at this point we're waiting for the iframe to load + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(events).toMatchObject({ + update: [ + expect.any(Event), + expect.objectContaining({ defaultPrevented: true }), + expect.objectContaining({ defaultPrevented: false }), + ], + json: [expect.any(Event), expect.any(Event)], + error: [], + load: [expect.any(Event), expect.any(Event)], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event)], + }); + + await expectIframeReloaded(); + expect(events).toMatchObject({ + update: [ + expect.any(Event), + expect.objectContaining({ defaultPrevented: true }), + expect.objectContaining({ defaultPrevented: false }), + ], + json: [expect.any(Event), expect.any(Event)], + error: [], + load: [expect.any(Event), expect.any(Event)], + loaded: [expect.any(Event), expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event), expect.any(Event)], + }); + }); + + it('should allow only the iframe reload to be cancelled', async () => { + document.addEventListener( + 'w-preview:load', + (event) => { + event.preventDefault(); + }, + { once: true }, + ); + + fetch.mockResponseSuccessJSON(validAvailableResponse); + + // Simulate a click on the refresh button + refreshButtonElement.click(); + + await jest.runAllTimersAsync(); + + // Should send the preview data to the preview URL + expect(global.fetch).toHaveBeenCalledTimes(1); + + // Should have additional events like a regular update, except the loaded + // event as the iframe load was cancelled by the load event listener. + // The updated event should still be dispatched to indicate that the + // process has finished. + expect(events).toMatchObject({ + update: [expect.any(Event), expect.any(Event)], + json: [expect.any(Event), expect.any(Event)], + error: [], + load: [ + expect.any(Event), + expect.objectContaining({ defaultPrevented: true }), + ], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event), expect.any(Event)], + }); + + // Should not create a new iframe for reloading the preview + const iframes = document.querySelectorAll('iframe'); + expect(iframes.length).toEqual(1); + + fetch.mockResponseSuccessJSON(validAvailableResponse); + + refreshButtonElement.click(); + + await jest.runAllTimersAsync(); + + // Should update the preview and reload the iframe, as the event listener + // is only called once and at this point we're waiting for the iframe to load + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(events).toMatchObject({ + update: [expect.any(Event), expect.any(Event), expect.any(Event)], + json: [expect.any(Event), expect.any(Event), expect.any(Event)], + error: [], + load: [ + expect.any(Event), + expect.objectContaining({ defaultPrevented: true }), + expect.objectContaining({ defaultPrevented: false }), + ], + loaded: [expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event), expect.any(Event)], + }); + + await expectIframeReloaded(); + expect(events).toMatchObject({ + update: [expect.any(Event), expect.any(Event), expect.any(Event)], + json: [expect.any(Event), expect.any(Event), expect.any(Event)], + error: [], + load: [ + expect.any(Event), + expect.objectContaining({ defaultPrevented: true }), + expect.objectContaining({ defaultPrevented: false }), + ], + loaded: [expect.any(Event), expect.any(Event)], + ready: [expect.any(Event)], + updated: [expect.any(Event), expect.any(Event), expect.any(Event)], + }); + }); }); }); diff --git a/client/src/controllers/PreviewController.ts b/client/src/controllers/PreviewController.ts index db65be9241..4d174c9fb5 100644 --- a/client/src/controllers/PreviewController.ts +++ b/client/src/controllers/PreviewController.ts @@ -204,12 +204,20 @@ export class PreviewController extends Controller { const deviceWidth = target.dataset.deviceWidth; this.setPreviewWidth(deviceWidth); + this.applySelectedSizeClass(device); try { localStorage.setItem(this.deviceLocalStorageKeyValue, device); } catch (e) { // Skip saving the device if localStorage fails. } + } + /** + * Applies the selected size class to the specified device input's label, and + * removes the class from all other device inputs' labels. + * @param device Selected device name + */ + applySelectedSizeClass(device: string) { // Ensure only one selected class is applied this.sizeTargets.forEach((input) => { // The is invisible and we're using a