From 93c93415c2b369c4d45bb24e643e5112bea120a6 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Mon, 15 Dec 2025 16:27:04 +0100 Subject: [PATCH] fix(ElementsWindow): Focus element if element dom_id is in the url hash 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. Signed-off-by: Thomas von Deyen (cherry picked from commit 12180b396f896ce732aa85b0930890ce02139c1a) --- .../components/elements_window.js | 15 +- .../components/elements_window.spec.js | 419 ++++++++++++++++++ 2 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 spec/javascript/alchemy_admin/components/elements_window.spec.js 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) + }) + }) + }) +})