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