diff --git a/app/javascript/alchemy_admin/components/elements_window.js b/app/javascript/alchemy_admin/components/elements_window.js index ea9e60fae3..0cb3da8e40 100644 --- a/app/javascript/alchemy_admin/components/elements_window.js +++ b/app/javascript/alchemy_admin/components/elements_window.js @@ -1,4 +1,5 @@ import SortableElements from "alchemy_admin/sortable_elements" +import { ElementEditor } from "alchemy_admin/components/element_editor" class ElementsWindow extends HTMLElement { #visible = true @@ -15,9 +16,7 @@ class ElementsWindow extends HTMLElement { this.toggle() }) if (window.location.hash) { - document - .querySelector(window.location.hash) - ?.trigger("FocusElementEditor.Alchemy") + this.focusElementEditor(window.location.hash) } SortableElements() this.resize() @@ -64,6 +63,16 @@ class ElementsWindow extends HTMLElement { } } + // Focus element editor and element in preview if URL contains hash to element editor. + // The link is coming from the assignment view when showing assigned files or pictures. + focusElementEditor(dom_id) { + const el = document.querySelector(dom_id) + + if (el instanceof ElementEditor) { + el.focusElement() && el.focusElementPreview() + } + } + get collapseButton() { return this.querySelector("#collapse-all-elements-button") } diff --git a/spec/javascript/alchemy_admin/components/elements_window.spec.js b/spec/javascript/alchemy_admin/components/elements_window.spec.js new file mode 100644 index 0000000000..946be85bb8 --- /dev/null +++ b/spec/javascript/alchemy_admin/components/elements_window.spec.js @@ -0,0 +1,419 @@ +import { vi } from "vitest" +import { renderComponent } from "./component.helper" + +vi.mock("alchemy_admin/sortable_elements", () => { + return { + __esModule: true, + default: vi.fn() + } +}) + +vi.mock("alchemy_admin/components/element_editor", () => { + return { + ElementEditor: class MockElementEditor extends HTMLElement { + focusElement = vi.fn(() => true) + focusElementPreview = vi.fn() + collapse = vi.fn() + } + } +}) + +import "alchemy_admin/components/elements_window" +import SortableElements from "alchemy_admin/sortable_elements" +import { ElementEditor } from "alchemy_admin/components/element_editor" + +// Register the mocked ElementEditor +customElements.define("alchemy-element-editor", ElementEditor) + +function getComponent(html) { + return renderComponent("alchemy-elements-window", html) +} + +describe("alchemy-elements-window", () => { + let elementsWindow + + const baseHtml = ` + + + + + + + + + + + + ` + + beforeEach(() => { + document.body.innerHTML = baseHtml + document.body.classList.add("elements-window-visible") + Alchemy = { + t: vi.fn((key) => key) + } + // Clear cookies + document.cookie = + "alchemy-elements-window-width=; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/;" + elementsWindow = document.querySelector("alchemy-elements-window") + // Mock postMessage on the preview window element + const previewWindow = document.getElementById("alchemy_preview_window") + previewWindow.postMessage = vi.fn() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("connectedCallback", () => { + it("initializes SortableElements", () => { + SortableElements.mockClear() + getComponent(baseHtml) + expect(SortableElements).toHaveBeenCalled() + }) + + it("calls resize", () => { + const resizeSpy = vi.spyOn(elementsWindow.constructor.prototype, "resize") + getComponent(baseHtml) + expect(resizeSpy).toHaveBeenCalled() + }) + + describe("with URL hash", () => { + it("focuses element editor matching hash", () => { + const originalHash = window.location.hash + window.location.hash = "#element_123" + const focusSpy = vi.spyOn( + elementsWindow.constructor.prototype, + "focusElementEditor" + ) + getComponent(baseHtml) + expect(focusSpy).toHaveBeenCalledWith("#element_123") + window.location.hash = originalHash + }) + }) + + describe("toggle button click", () => { + it("toggles elements window", () => { + const toggleSpy = vi.spyOn(elementsWindow, "toggle") + const toggleButton = document.querySelector("#element_window_button") + toggleButton.click() + expect(toggleSpy).toHaveBeenCalled() + }) + + it("prevents default event behavior", () => { + const toggleButton = document.querySelector("#element_window_button") + const event = new Event("click", { cancelable: true }) + const preventDefaultSpy = vi.spyOn(event, "preventDefault") + toggleButton.dispatchEvent(event) + expect(preventDefaultSpy).toHaveBeenCalled() + }) + }) + }) + + describe("collapseAllElements", () => { + it("collapses all non-compact, non-fixed elements", () => { + const element = document.querySelector("#element_123") + elementsWindow.collapseAllElements() + expect(element.collapse).toHaveBeenCalled() + }) + + it("does not collapse compact elements", () => { + const compactElement = document.querySelector("#element_456") + elementsWindow.collapseAllElements() + expect(compactElement.collapse).not.toHaveBeenCalled() + }) + + it("does not collapse fixed elements", () => { + const fixedElement = document.querySelector("#element_789") + elementsWindow.collapseAllElements() + expect(fixedElement.collapse).not.toHaveBeenCalled() + }) + }) + + describe("toggle", () => { + it("hides when visible", () => { + const hideSpy = vi.spyOn(elementsWindow, "hide") + elementsWindow.toggle() + expect(hideSpy).toHaveBeenCalled() + }) + + it("shows when hidden", () => { + elementsWindow.hide() + const showSpy = vi.spyOn(elementsWindow, "show") + elementsWindow.toggle() + expect(showSpy).toHaveBeenCalled() + }) + }) + + describe("show", () => { + beforeEach(() => { + elementsWindow.hide() + }) + + it("adds elements-window-visible class to body", () => { + elementsWindow.show() + expect(document.body.classList.contains("elements-window-visible")).toBe( + true + ) + }) + + it("updates toggle button tooltip", () => { + elementsWindow.show() + expect(Alchemy.t).toHaveBeenCalledWith("Hide elements") + }) + + it("updates toggle button icon to menu-unfold", () => { + elementsWindow.show() + const icon = elementsWindow.toggleButton.querySelector("alchemy-icon") + expect(icon.getAttribute("name")).toBe("menu-unfold") + }) + + it("calls resize", () => { + const resizeSpy = vi.spyOn(elementsWindow, "resize") + elementsWindow.show() + expect(resizeSpy).toHaveBeenCalled() + }) + }) + + describe("hide", () => { + it("removes elements-window-visible class from body", () => { + elementsWindow.hide() + expect(document.body.classList.contains("elements-window-visible")).toBe( + false + ) + }) + + it("removes --elements-window-width CSS property", () => { + document.body.style.setProperty("--elements-window-width", "400px") + elementsWindow.hide() + expect( + document.body.style.getPropertyValue("--elements-window-width") + ).toBe("") + }) + + it("updates toggle button tooltip", () => { + elementsWindow.hide() + expect(Alchemy.t).toHaveBeenCalledWith("Show elements") + }) + + it("updates toggle button icon to menu-fold", () => { + elementsWindow.hide() + const icon = elementsWindow.toggleButton.querySelector("alchemy-icon") + expect(icon.getAttribute("name")).toBe("menu-fold") + }) + }) + + describe("resize", () => { + it("sets CSS property with given width", () => { + elementsWindow.resize(450) + expect( + document.body.style.getPropertyValue("--elements-window-width") + ).toBe("450px") + }) + + it("sets cookie with given width", () => { + elementsWindow.resize(450) + expect(document.cookie).toContain("alchemy-elements-window-width=450") + }) + + it("uses width from cookie if no width given", () => { + document.cookie = "alchemy-elements-window-width=500; Path=/;" + elementsWindow.resize() + expect( + document.body.style.getPropertyValue("--elements-window-width") + ).toBe("500px") + }) + + it("does nothing if no width given and no cookie", () => { + // Clear any existing cookie first + document.cookie = + "alchemy-elements-window-width=; expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/;" + // Clear any existing width style + document.body.style.removeProperty("--elements-window-width") + elementsWindow.resize() + expect( + document.body.style.getPropertyValue("--elements-window-width") + ).toBe("") + }) + }) + + describe("focusElementEditor", () => { + it("focuses element and preview if element is an ElementEditor", () => { + const element = document.querySelector("#element_123") + elementsWindow.focusElementEditor("#element_123") + expect(element.focusElement).toHaveBeenCalled() + expect(element.focusElementPreview).toHaveBeenCalled() + }) + + it("does nothing if element is not found", () => { + elementsWindow.focusElementEditor("#nonexistent") + // Should not throw + }) + + it("does nothing if element is not an ElementEditor", () => { + const regularDiv = document.createElement("div") + regularDiv.id = "regular_div" + document.body.appendChild(regularDiv) + elementsWindow.focusElementEditor("#regular_div") + // Should not throw + }) + }) + + describe("getters", () => { + describe("collapseButton", () => { + it("returns the collapse all button", () => { + expect(elementsWindow.collapseButton).toBe( + document.querySelector("#collapse-all-elements-button") + ) + }) + }) + + describe("toggleButton", () => { + it("returns the element window button", () => { + expect(elementsWindow.toggleButton).toBe( + document.querySelector("#element_window_button") + ) + }) + }) + + describe("previewWindow", () => { + it("returns the preview window element", () => { + expect(elementsWindow.previewWindow).toBe( + document.getElementById("alchemy_preview_window") + ) + }) + }) + + describe("turboFrame", () => { + it("returns the closest turbo-frame", () => { + const html = ` + + + + ` + document.body.innerHTML = html + elementsWindow = document.querySelector("alchemy-elements-window") + expect(elementsWindow.turboFrame).toBe( + document.querySelector("turbo-frame") + ) + }) + + it("caches the turbo-frame reference", () => { + const html = ` + + + + ` + document.body.innerHTML = html + elementsWindow = document.querySelector("alchemy-elements-window") + const frame1 = elementsWindow.turboFrame + const frame2 = elementsWindow.turboFrame + expect(frame1).toBe(frame2) + }) + }) + + describe("widthFromCookie", () => { + it("returns width from cookie", () => { + document.cookie = "alchemy-elements-window-width=350; Path=/;" + expect(elementsWindow.widthFromCookie).toBe("350") + }) + + it("returns undefined if cookie not set", () => { + expect(elementsWindow.widthFromCookie).toBeUndefined() + }) + }) + }) + + describe("isDragged setter", () => { + beforeEach(() => { + document.body.innerHTML = ` + + + + ` + elementsWindow = document.querySelector("alchemy-elements-window") + }) + + it("disables transitions when dragging", () => { + elementsWindow.isDragged = true + expect(elementsWindow.turboFrame.style.transitionProperty).toBe("none") + }) + + it("disables pointer events when dragging", () => { + elementsWindow.isDragged = true + expect(elementsWindow.turboFrame.style.pointerEvents).toBe("none") + }) + + it("restores transitions when not dragging", () => { + elementsWindow.isDragged = true + elementsWindow.isDragged = false + expect(elementsWindow.turboFrame.style.transitionProperty).toBe("") + }) + + it("restores pointer events when not dragging", () => { + elementsWindow.isDragged = true + elementsWindow.isDragged = false + expect(elementsWindow.turboFrame.style.pointerEvents).toBe("") + }) + }) + + describe("event handling", () => { + describe("collapse button click", () => { + it("collapses all elements", () => { + const collapseSpy = vi.spyOn(elementsWindow, "collapseAllElements") + elementsWindow.collapseButton.click() + expect(collapseSpy).toHaveBeenCalled() + }) + }) + + describe("message event from window", () => { + it("shows window and focuses element on Alchemy.focusElementEditor message", () => { + const showSpy = vi.spyOn(elementsWindow, "show") + const element = document.querySelector("#element_123") + window.dispatchEvent( + new MessageEvent("message", { + data: { message: "Alchemy.focusElementEditor", element_id: "123" } + }) + ) + expect(showSpy).toHaveBeenCalled() + expect(element.focusElement).toHaveBeenCalled() + }) + + it("ignores messages without proper message type", () => { + const showSpy = vi.spyOn(elementsWindow, "show") + window.dispatchEvent( + new MessageEvent("message", { + data: { message: "SomeOtherMessage", element_id: "123" } + }) + ) + expect(showSpy).not.toHaveBeenCalled() + }) + }) + + describe("body click", () => { + it("deselects all element editors when clicking outside", () => { + const element = document.querySelector("#element_123") + element.classList.add("selected") + document.body.click() + expect(element.classList.contains("selected")).toBe(false) + }) + + it("posts blur message to preview window", () => { + const previewWindow = document.getElementById("alchemy_preview_window") + document.body.click() + expect(previewWindow.postMessage).toHaveBeenCalledWith({ + message: "Alchemy.blurElements" + }) + }) + + it("does not deselect when clicking inside element editor", () => { + const element = document.querySelector("#element_123") + element.classList.add("selected") + element.click() + expect(element.classList.contains("selected")).toBe(true) + }) + }) + }) +})