diff --git a/app/javascript/alchemy_admin/components/preview_window.js b/app/javascript/alchemy_admin/components/preview_window.js index dceeb03aec..3a340c195a 100644 --- a/app/javascript/alchemy_admin/components/preview_window.js +++ b/app/javascript/alchemy_admin/components/preview_window.js @@ -1,23 +1,39 @@ +import { growl } from "alchemy_admin/growler" +import { translate } from "alchemy_admin/i18n" + class PreviewWindow extends HTMLIFrameElement { #afterLoad #reloadIcon + #loadTimeout + #previewReadyHandler constructor() { super() this.addEventListener("load", this) + this.#previewReadyHandler = this.#handlePreviewReadyMessage.bind(this) } handleEvent(evt) { if (evt.type === "load") { + this.#clearLoadTimeout() this.#stopSpinner() this.#afterLoad?.call(this, evt) } } + #handlePreviewReadyMessage(event) { + if (event.data.message === "Alchemy.previewReady") { + this.#clearLoadTimeout() + this.#stopSpinner() + this.#afterLoad?.call(this, event) + } + } + connectedCallback() { let url = this.url this.#attachEvents() + window.addEventListener("message", this.#previewReadyHandler) if (window.localStorage.getItem("alchemy-preview-url")) { url = window.localStorage.getItem("alchemy-preview-url") @@ -29,6 +45,7 @@ class PreviewWindow extends HTMLIFrameElement { disconnectedCallback() { key.unbind("alt+r") + window.removeEventListener("message", this.#previewReadyHandler) } postMessage(data) { @@ -48,6 +65,13 @@ class PreviewWindow extends HTMLIFrameElement { this.src = this.url } + // Set 5s timeout as fallback - if iframe doesn't load, stop spinner anyway + this.#clearLoadTimeout() + this.#loadTimeout = setTimeout(() => { + this.#stopSpinner() + growl(translate("Preview failed to load"), "warning") + }, 5000) + return new Promise((resolve) => { this.#afterLoad = resolve }) @@ -85,7 +109,10 @@ class PreviewWindow extends HTMLIFrameElement { } #startSpinner() { - this.#reloadIcon = this.reloadButton.innerHTML + // Only save the reload icon if we're not already showing a spinner + if (!this.reloadButton.innerHTML.includes("alchemy-spinner")) { + this.#reloadIcon = this.reloadButton.innerHTML + } this.reloadButton.innerHTML = `` } @@ -93,6 +120,13 @@ class PreviewWindow extends HTMLIFrameElement { this.reloadButton.innerHTML = this.#reloadIcon } + #clearLoadTimeout() { + if (this.#loadTimeout) { + clearTimeout(this.#loadTimeout) + this.#loadTimeout = null + } + } + get url() { return this.getAttribute("url") } diff --git a/app/javascript/preview.js b/app/javascript/preview.js index c4277c2b14..9f27dbb03c 100644 --- a/app/javascript/preview.js +++ b/app/javascript/preview.js @@ -115,3 +115,11 @@ Object.assign(Alchemy, { }) Alchemy.ElementSelector.init() + +// Notify parent window that preview is ready +window.parent.postMessage( + { + message: "Alchemy.previewReady" + }, + window.location.origin +) diff --git a/app/views/alchemy/admin/translations/_en.js b/app/views/alchemy/admin/translations/_en.js index fb4ce0914d..bd564ed5f9 100644 --- a/app/views/alchemy/admin/translations/_en.js +++ b/app/views/alchemy/admin/translations/_en.js @@ -13,6 +13,7 @@ Alchemy.translations = { page_found: "Page found", pages_found: "Pages found", "Please confirm": "Please confirm", + "Preview failed to load": "Preview failed to load. Please try again.", url_validation_failed: "The url has no valid format.", warning: "Warning!", "File is too large": "File is too large", diff --git a/spec/javascript/alchemy_admin/components/preview_window.spec.js b/spec/javascript/alchemy_admin/components/preview_window.spec.js new file mode 100644 index 0000000000..0496fc3728 --- /dev/null +++ b/spec/javascript/alchemy_admin/components/preview_window.spec.js @@ -0,0 +1,198 @@ +import "alchemy_admin/components/preview_window" +import * as growler from "alchemy_admin/growler" +import { setupLanguage } from "./component.helper" + +describe("alchemy-preview-window", () => { + /** + * @type {HTMLIFrameElement | undefined} + */ + let previewWindow = undefined + let reloadButton = undefined + + beforeEach(() => { + document.body.innerHTML = ` +
+ + + + ` + previewWindow = document.querySelector('[is="alchemy-preview-window"]') + reloadButton = document.getElementById("reload_preview_button") + }) + + afterEach(() => { + document.body.innerHTML = "" + }) + + describe("refresh with timeout", () => { + let growlSpy + + beforeEach(() => { + setupLanguage() + Alchemy.translations["Preview failed to load"] = + "Preview failed to load. Please try again." + vi.useFakeTimers() + growlSpy = vi.spyOn(growler, "growl") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("starts spinner when refresh is called", () => { + const originalContent = reloadButton.innerHTML + + previewWindow.refresh() + + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + expect(reloadButton.innerHTML).not.toBe(originalContent) + }) + + it("stops spinner when iframe loads successfully", async () => { + const originalContent = reloadButton.innerHTML + + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Simulate iframe load event + previewWindow.dispatchEvent(new Event("load")) + + expect(reloadButton.innerHTML).toBe(originalContent) + expect(reloadButton.innerHTML).not.toContain("alchemy-spinner") + }) + + it("stops spinner after 5s timeout if iframe doesn't load", () => { + const originalContent = reloadButton.innerHTML + + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Fast-forward time by 5 seconds + vi.advanceTimersByTime(5000) + + expect(reloadButton.innerHTML).toBe(originalContent) + expect(reloadButton.innerHTML).not.toContain("alchemy-spinner") + expect(growlSpy).toHaveBeenCalledWith( + "Preview failed to load. Please try again.", + "warning" + ) + }) + + it("clears timeout when iframe loads before timeout expires", () => { + const originalContent = reloadButton.innerHTML + + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Fast-forward time by 2 seconds (less than timeout) + vi.advanceTimersByTime(2000) + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Simulate iframe load event + previewWindow.dispatchEvent(new Event("load")) + expect(reloadButton.innerHTML).toBe(originalContent) + + // Fast-forward remaining time - spinner should stay stopped + vi.advanceTimersByTime(3000) + expect(reloadButton.innerHTML).toBe(originalContent) + expect(reloadButton.innerHTML).not.toContain("alchemy-spinner") + // Growl should NOT be called since iframe loaded successfully + expect(growlSpy).not.toHaveBeenCalled() + }) + + it("clears previous timeout when refresh is called again", () => { + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Fast-forward 2 seconds + vi.advanceTimersByTime(2000) + + // Call refresh again before timeout + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Fast-forward 4 seconds (total 6s from first refresh, but only 4s from second) + vi.advanceTimersByTime(4000) + + // Spinner should still be showing because new 5s timeout hasn't expired + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Fast-forward 1 more second to complete new timeout + vi.advanceTimersByTime(1000) + expect(reloadButton.innerHTML).not.toContain("alchemy-spinner") + }) + + it("allows reload button to be clicked again after timeout", () => { + const originalContent = reloadButton.innerHTML + + // First refresh + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Wait for timeout + vi.advanceTimersByTime(5000) + expect(reloadButton.innerHTML).toBe(originalContent) + + // Click reload button again + reloadButton.click() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Verify it works again + vi.advanceTimersByTime(5000) + expect(reloadButton.innerHTML).toBe(originalContent) + }) + + it("stops spinner when receiving previewReady message from iframe", () => { + const originalContent = reloadButton.innerHTML + + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Fast-forward 2 seconds (less than timeout) + vi.advanceTimersByTime(2000) + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Simulate postMessage from iframe + window.dispatchEvent( + new MessageEvent("message", { + data: { message: "Alchemy.previewReady" } + }) + ) + + expect(reloadButton.innerHTML).toBe(originalContent) + expect(reloadButton.innerHTML).not.toContain("alchemy-spinner") + + // Fast-forward remaining time - spinner should stay stopped and no growl + vi.advanceTimersByTime(3000) + expect(reloadButton.innerHTML).toBe(originalContent) + expect(growlSpy).not.toHaveBeenCalled() + }) + + it("prefers postMessage over timeout when both occur", () => { + const originalContent = reloadButton.innerHTML + + previewWindow.refresh() + expect(reloadButton.innerHTML).toContain("alchemy-spinner") + + // Simulate postMessage from iframe before timeout + window.dispatchEvent( + new MessageEvent("message", { + data: { message: "Alchemy.previewReady" } + }) + ) + + expect(reloadButton.innerHTML).toBe(originalContent) + + // Fast-forward past timeout - nothing should happen, already stopped + vi.advanceTimersByTime(5000) + expect(reloadButton.innerHTML).toBe(originalContent) + expect(growlSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/spec/javascript/alchemy_admin/setup.js b/spec/javascript/alchemy_admin/setup.js index cf6d53ef61..3ef698fd9a 100644 --- a/spec/javascript/alchemy_admin/setup.js +++ b/spec/javascript/alchemy_admin/setup.js @@ -100,6 +100,10 @@ Object.defineProperty(window, "matchMedia", { })) }) +// Mock keymaster (key) for keyboard shortcuts +globalThis.key = vi.fn() +globalThis.key.unbind = vi.fn() + // Set up global Alchemy object that many tests expect globalThis.Alchemy = { translations: {},