Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion app/javascript/alchemy_admin/components/preview_window.js
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -29,6 +45,7 @@ class PreviewWindow extends HTMLIFrameElement {

disconnectedCallback() {
key.unbind("alt+r")
window.removeEventListener("message", this.#previewReadyHandler)
}

postMessage(data) {
Expand All @@ -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
})
Expand Down Expand Up @@ -85,14 +109,24 @@ 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 = `<alchemy-spinner size="small"></alchemy-spinner>`
}

#stopSpinner() {
this.reloadButton.innerHTML = this.#reloadIcon
}

#clearLoadTimeout() {
if (this.#loadTimeout) {
clearTimeout(this.#loadTimeout)
this.#loadTimeout = null
}
}

get url() {
return this.getAttribute("url")
}
Expand Down
8 changes: 8 additions & 0 deletions app/javascript/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
1 change: 1 addition & 0 deletions app/views/alchemy/admin/translations/_en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
198 changes: 198 additions & 0 deletions spec/javascript/alchemy_admin/components/preview_window.spec.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div id="flash_notices"></div>
<button id="reload_preview_button">
<span>Reload</span>
</button>
<select id="preview_size"></select>
<iframe
is="alchemy-preview-window"
id="alchemy_preview_window"
url="about:blank"
></iframe>
`
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()
})
})
})
4 changes: 4 additions & 0 deletions spec/javascript/alchemy_admin/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down