From 0ba36369b7f6fc52d0998a3c0848699e0106755d Mon Sep 17 00:00:00 2001 From: Mridul Sharma Date: Tue, 19 May 2026 15:00:46 +0530 Subject: [PATCH 01/16] test: added test cases for post message hook --- .../__test__/postMessageEvent.hooks.test.ts | 191 +++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts index f48b8bcc..cf465cdd 100644 --- a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts +++ b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts @@ -18,6 +18,7 @@ import { sendInitializeLivePreviewPostMessageEvent, } from "../postMessageEvent.hooks"; import { isOpeningInNewTab } from "../../../common/inIframe"; +import { ILivePreviewWindowType } from "../../../types/types"; // Mock dependencies vi.mock("../../../configManager/configManager", () => ({ @@ -202,6 +203,70 @@ describe("postMessageEvent.hooks", () => { expect(mockWindow.location.reload).not.toHaveBeenCalled(); expect(mockOnChange).not.toHaveBeenCalled(); }); + + describe("SSR + missing URL params → redirect", () => { + it("should set all params and redirect when URL has no params and event data has content_type_uid and entry_uid", () => { + mockConfig.ssr = true; + mockConfig.stackDetails = {}; + (Config.get as any).mockReturnValue(mockConfig); + mockWindow.location.href = "https://example.com"; + + const eventData: OnChangeLivePreviewPostMessageEventData = { + hash: "new-hash", + content_type_uid: "blog", + entry_uid: "entry-123", + }; + + const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; + callback({ data: eventData }); + + const redirectUrl = new URL(mockWindow.location.href); + expect(redirectUrl.searchParams.get("live_preview")).toBe("new-hash"); + expect(redirectUrl.searchParams.get("content_type_uid")).toBe("blog"); + expect(redirectUrl.searchParams.get("entry_uid")).toBe("entry-123"); + expect(mockWindow.location.reload).not.toHaveBeenCalled(); + }); + + it("should redirect without content_type_uid param when event data does not provide it", () => { + mockConfig.ssr = true; + mockConfig.stackDetails = {}; + (Config.get as any).mockReturnValue(mockConfig); + mockWindow.location.href = "https://example.com"; + + const eventData: OnChangeLivePreviewPostMessageEventData = { + hash: "new-hash", + entry_uid: "entry-123", + }; + + const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; + callback({ data: eventData }); + + const redirectUrl = new URL(mockWindow.location.href); + expect(redirectUrl.searchParams.get("live_preview")).toBe("new-hash"); + expect(redirectUrl.searchParams.has("content_type_uid")).toBe(false); + expect(redirectUrl.searchParams.get("entry_uid")).toBe("entry-123"); + }); + + it("should redirect without entry_uid param when event data does not provide it", () => { + mockConfig.ssr = true; + mockConfig.stackDetails = {}; + (Config.get as any).mockReturnValue(mockConfig); + mockWindow.location.href = "https://example.com"; + + const eventData: OnChangeLivePreviewPostMessageEventData = { + hash: "new-hash", + content_type_uid: "blog", + }; + + const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; + callback({ data: eventData }); + + const redirectUrl = new URL(mockWindow.location.href); + expect(redirectUrl.searchParams.get("live_preview")).toBe("new-hash"); + expect(redirectUrl.searchParams.get("content_type_uid")).toBe("blog"); + expect(redirectUrl.searchParams.has("entry_uid")).toBe(false); + }); + }); }); describe("HASH_CHANGE event type", () => { @@ -304,7 +369,7 @@ describe("postMessageEvent.hooks", () => { it("should log error and return when window is not defined", () => { // Mock isOpeningInNewTab to return true so we enter the if block (isOpeningInNewTab as any).mockReturnValue(true); - + // Mock window as undefined Object.defineProperty(global, "window", { value: undefined, @@ -364,6 +429,65 @@ describe("postMessageEvent.hooks", () => { ); }); }); + + describe("when NOT opening in new tab", () => { + it("should not reload when ssr is true, no event_type, and not opening in new tab", () => { + (isOpeningInNewTab as any).mockReturnValue(false); + mockConfig.ssr = true; + (Config.get as any).mockReturnValue(mockConfig); + + const eventData: OnChangeLivePreviewPostMessageEventData = { + hash: "test-hash", + }; + + const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; + callback({ data: eventData }); + + expect(setConfigFromParams).toHaveBeenCalledWith({ live_preview: "test-hash" }); + expect(mockWindow.location.reload).not.toHaveBeenCalled(); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it("should not call pushState when HASH_CHANGE and not opening in new tab", () => { + (isOpeningInNewTab as any).mockReturnValue(false); + mockConfig.ssr = false; + (Config.get as any).mockReturnValue(mockConfig); + + const eventData: OnChangeLivePreviewPostMessageEventData = { + hash: "new-hash", + _metadata: { + event_type: OnChangeLivePreviewPostMessageEventTypes.HASH_CHANGE, + }, + }; + + const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; + callback({ data: eventData }); + + expect(setConfigFromParams).toHaveBeenCalledWith({ live_preview: "new-hash" }); + expect(mockWindow.history.pushState).not.toHaveBeenCalled(); + }); + + it("should not navigate when URL_CHANGE with url and not opening in new tab", () => { + (isOpeningInNewTab as any).mockReturnValue(false); + const originalHref = mockWindow.location.href; + mockConfig.ssr = false; + (Config.get as any).mockReturnValue(mockConfig); + + const eventData: OnChangeLivePreviewPostMessageEventData = { + hash: "test-hash", + url: "https://newdomain.com/page", + _metadata: { + event_type: OnChangeLivePreviewPostMessageEventTypes.URL_CHANGE, + }, + }; + + const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; + callback({ data: eventData }); + + expect(setConfigFromParams).toHaveBeenCalledWith({ live_preview: "test-hash" }); + expect(mockWindow.location.href).toBe(originalHref); + }); + }); }); describe("useHistoryPostMessageEvent", () => { @@ -491,5 +615,70 @@ describe("postMessageEvent.hooks", () => { }) ); }); + + it("should call setConfigFromParams with content_type_uid and entry_uid when INIT response provides them", async () => { + mockConfig = { + ssr: true, + mode: 1, + }; + (Config.get as any).mockReturnValue(mockConfig); + (livePreviewPostMessage as any).send.mockResolvedValue({ + windowType: ILivePreviewWindowType.PREVIEW, + contentTypeUid: "blog", + entryUid: "entry-123", + }); + + await sendInitializeLivePreviewPostMessageEvent(); + await Promise.resolve(); + + expect(setConfigFromParams).toHaveBeenCalledWith({ + content_type_uid: "blog", + entry_uid: "entry-123", + }); + }); + + it("should return early and skip post-init setup when windowType is BUILDER", async () => { + mockConfig = { + ssr: true, + mode: 1, + windowType: ILivePreviewWindowType.BUILDER, + }; + (Config.get as any).mockReturnValue(mockConfig); + (livePreviewPostMessage as any).send.mockResolvedValue({ + windowType: ILivePreviewWindowType.BUILDER, + contentTypeUid: "blog", + entryUid: "entry-123", + }); + + await sendInitializeLivePreviewPostMessageEvent(); + await Promise.resolve(); + + expect(setConfigFromParams).not.toHaveBeenCalled(); + expect(Config.set).not.toHaveBeenCalled(); + }); + + it("should start CHECK_ENTRY_PAGE interval when ssr is false", async () => { + vi.useFakeTimers(); + mockConfig = { + ssr: false, + mode: 1, + }; + (Config.get as any).mockReturnValue(mockConfig); + (livePreviewPostMessage as any).send.mockResolvedValue({ + windowType: ILivePreviewWindowType.PREVIEW, + }); + + await sendInitializeLivePreviewPostMessageEvent(); + await Promise.resolve(); + + vi.advanceTimersByTime(1500); + + expect(livePreviewPostMessage?.send).toHaveBeenCalledWith( + LIVE_PREVIEW_POST_MESSAGE_EVENTS.CHECK_ENTRY_PAGE, + { href: "https://example.com" } + ); + + vi.useRealTimers(); + }); }); }); \ No newline at end of file From 73fc1918ba19ca2221ae6ccfb2c45473736fc97c Mon Sep 17 00:00:00 2001 From: Mridul Sharma Date: Tue, 19 May 2026 18:16:34 +0530 Subject: [PATCH 02/16] test: added unit test cases fo visual builder specific to mode --- .../__test__/postMessageEvent.hooks.test.ts | 36 ++++++++------- src/visualBuilder/__test__/index.test.ts | 46 +++++++++++++++++++ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts index cf465cdd..89b52885 100644 --- a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts +++ b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts @@ -659,26 +659,28 @@ describe("postMessageEvent.hooks", () => { it("should start CHECK_ENTRY_PAGE interval when ssr is false", async () => { vi.useFakeTimers(); - mockConfig = { - ssr: false, - mode: 1, - }; - (Config.get as any).mockReturnValue(mockConfig); - (livePreviewPostMessage as any).send.mockResolvedValue({ - windowType: ILivePreviewWindowType.PREVIEW, - }); - - await sendInitializeLivePreviewPostMessageEvent(); - await Promise.resolve(); + try { + mockConfig = { + ssr: false, + mode: 1, + }; + (Config.get as any).mockReturnValue(mockConfig); + (livePreviewPostMessage as any).send.mockResolvedValue({ + windowType: ILivePreviewWindowType.PREVIEW, + }); - vi.advanceTimersByTime(1500); + await sendInitializeLivePreviewPostMessageEvent(); + await Promise.resolve(); - expect(livePreviewPostMessage?.send).toHaveBeenCalledWith( - LIVE_PREVIEW_POST_MESSAGE_EVENTS.CHECK_ENTRY_PAGE, - { href: "https://example.com" } - ); + vi.advanceTimersByTime(1500); - vi.useRealTimers(); + expect(livePreviewPostMessage?.send).toHaveBeenCalledWith( + LIVE_PREVIEW_POST_MESSAGE_EVENTS.CHECK_ENTRY_PAGE, + { href: "https://example.com" } + ); + } finally { + vi.useRealTimers(); + } }); }); }); \ No newline at end of file diff --git a/src/visualBuilder/__test__/index.test.ts b/src/visualBuilder/__test__/index.test.ts index 981884a2..3e3b193b 100644 --- a/src/visualBuilder/__test__/index.test.ts +++ b/src/visualBuilder/__test__/index.test.ts @@ -15,6 +15,9 @@ import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types import { VisualBuilder } from "../index"; import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; import { Mock } from "vitest"; +import { ILivePreviewModeConfig } from "../../types/types"; +import * as kbModule from "../listeners/keyboardShortcuts"; +import * as variantModule from "../eventManager/useVariantsPostMessageEvent"; const INLINE_EDITABLE_FIELD_VALUE = "Hello World"; @@ -192,4 +195,47 @@ describe( x.destroy(); }); + + describe("VisualBuilder init — early return conditions", () => { + afterEach(() => { + Config.set("mode", 2); + Config.set("enable", true); + }); + + test("should not send init postMessage when enable is false", () => { + Config.set("enable", false); + new VisualBuilder(); + expect(visualBuilderPostMessage.send).not.toHaveBeenCalledWith( + "init", + expect.anything() + ); + }); + + test("should not send init postMessage when mode is below BUILDER", () => { + Config.set("mode", ILivePreviewModeConfig.PREVIEW); + new VisualBuilder(); + expect(visualBuilderPostMessage.send).not.toHaveBeenCalledWith( + "init", + expect.anything() + ); + }); + + test("should call addKeyboardShortcuts and getHighlightVariantFieldsStatus when windowType is BUILDER", async () => { + Config.set("mode", 2); + const kbSpy = vi + .spyOn(kbModule, "addKeyboardShortcuts") + .mockImplementation(() => {}); + const highlightSpy = vi + .spyOn(variantModule, "getHighlightVariantFieldsStatus") + .mockResolvedValue({ highlightVariantFields: false }); + + const vb = new VisualBuilder(); + await waitForBuilderSDKToBeInitialized(visualBuilderPostMessage); + await waitFor(() => { + expect(kbSpy).toHaveBeenCalled(); + }); + expect(highlightSpy).toHaveBeenCalled(); + vb.destroy(); + }); + }); }); From abe2729c291b9d2a3b5c69fd171caf76fad88607 Mon Sep 17 00:00:00 2001 From: Mridul Sharma Date: Thu, 21 May 2026 10:58:59 +0530 Subject: [PATCH 03/16] test: added test cases of useCollab / preview share --- .../eventManager/__test__/useCollab.test.ts | 412 ++++++++++++++++++ src/visualBuilder/eventManager/useCollab.ts | 10 +- 2 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 src/visualBuilder/eventManager/__test__/useCollab.test.ts diff --git a/src/visualBuilder/eventManager/__test__/useCollab.test.ts b/src/visualBuilder/eventManager/__test__/useCollab.test.ts new file mode 100644 index 00000000..1d33696c --- /dev/null +++ b/src/visualBuilder/eventManager/__test__/useCollab.test.ts @@ -0,0 +1,412 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { useCollab } from "../useCollab"; +import visualBuilderPostMessage from "../../utils/visualBuilderPostMessage"; +import Config from "../../../configManager/configManager"; +import { + removeAllCollabIcons, + hideAllCollabIcons, + removeCollabIcon, + HighlightThread, + showAllCollabIcons, + generateThread, + handleMissingThreads, +} from "../../generators/generateThread"; + +vi.mock("../../utils/visualBuilderPostMessage", () => ({ + default: { + on: vi.fn().mockImplementation(() => ({ unregister: vi.fn() })), + }, +})); + +vi.mock("../../../configManager/configManager", () => ({ + default: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock("../../generators/generateThread", () => ({ + removeAllCollabIcons: vi.fn(), + hideAllCollabIcons: vi.fn(), + removeCollabIcon: vi.fn(), + HighlightThread: vi.fn(), + showAllCollabIcons: vi.fn(), + generateThread: vi.fn(), + handleMissingThreads: vi.fn(), +})); + +// Returns the handler registered at on() call index (0–5). +// Always call useCollab() before using this — indices reset after vi.clearAllMocks(). +const getHandler = (index: number) => + vi.mocked(visualBuilderPostMessage!.on).mock.calls[index][1]; + +// Returns the unregister spy created for on() call at index. +// Uses mock.results because mockImplementation creates a fresh object per call. +const getUnregister = (index: number) => + vi.mocked(visualBuilderPostMessage!.on).mock.results[index].value.unregister; + +const mockThread = { + _id: "t1", + author: "u1", + inviteUid: "inv1", + position: { x: 0, y: 0 }, + elementXPath: "//div", + isElementPresent: true, + pageRoute: "/", + createdBy: "u1", + sequenceNumber: 1, + threadState: 0, + createdAt: "2026-01-01T00:00:00Z", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(Config.get).mockReturnValue({} as any); +}); + +describe("COLLAB_ENABLE", () => { + // COLLAB_ENABLE does not read closure-captured config — shared useCollab() call is safe. + let collabEnableHandler: (data: any) => void; + + beforeEach(() => { + useCollab(); + collabEnableHandler = getHandler(0); + }); + + it("should set enable, isFeedbackMode, pauseFeedback, inviteMetadata when fromShare is false", () => { + const inviteMetadata = { + users: [], + currentUser: { email: "a@b.com", uid: "u1" }, + inviteUid: "inv1", + }; + collabEnableHandler({ + data: { + collab: { + fromShare: false, + enable: true, + isFeedbackMode: false, + pauseFeedback: false, + inviteMetadata, + payload: [], + }, + }, + }); + expect(vi.mocked(Config.set)).toHaveBeenCalledWith("collab.enable", true); + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.isFeedbackMode", + false + ); + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.pauseFeedback", + false + ); + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.inviteMetadata", + inviteMetadata + ); + expect(vi.mocked(showAllCollabIcons)).not.toHaveBeenCalled(); + }); + + it("should set pauseFeedback and isFeedbackMode and call showAllCollabIcons when fromShare is true", () => { + collabEnableHandler({ + data: { + collab: { + fromShare: true, + pauseFeedback: true, + isFeedbackMode: true, + }, + }, + }); + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.pauseFeedback", + true + ); + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.isFeedbackMode", + true + ); + expect(vi.mocked(showAllCollabIcons)).toHaveBeenCalledOnce(); + expect(vi.mocked(Config.set)).not.toHaveBeenCalledWith( + "collab.enable", + expect.anything() + ); + expect(vi.mocked(Config.set)).not.toHaveBeenCalledWith( + "collab.inviteMetadata", + expect.anything() + ); + }); + + it("should log error when collab field is missing from event data", () => { + const badPayload = { data: { collab: null } }; + collabEnableHandler(badPayload); + expect(console.error).toHaveBeenCalledWith( + "Invalid collab data structure:", + badPayload + ); + expect(vi.mocked(Config.set)).not.toHaveBeenCalled(); + }); +}); + +describe("COLLAB_DATA_UPDATE", () => { + // Reads closure-captured config — each test calls useCollab() fresh after setting Config.get. + + it("should return early when collab is disabled in config", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: false }, + } as any); + useCollab(); + const handler = getHandler(1); + + handler({ data: { collab: { payload: [] } } }); + + expect(vi.mocked(Config.set)).not.toHaveBeenCalled(); + expect(vi.mocked(generateThread)).not.toHaveBeenCalled(); + }); + + it("should log error when collab field is missing and collab is enabled", () => { + // enable=true required — line 73 returns before reaching the null check otherwise + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true }, + } as any); + useCollab(); + const handler = getHandler(1); + + const badPayload = { data: { collab: null } }; + handler(badPayload); + + expect(console.error).toHaveBeenCalledWith( + "Invalid collab data structure:", + badPayload + ); + }); + + it("should set inviteMetadata and return early when inviteMetadata is present", () => { + const inviteMetadata = { + users: [], + currentUser: { email: "a@b.com", uid: "u1" }, + inviteUid: "inv1", + }; + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true }, + } as any); + useCollab(); + const handler = getHandler(1); + + handler({ data: { collab: { inviteMetadata } } }); + + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.inviteMetadata", + inviteMetadata + ); + expect(vi.mocked(generateThread)).not.toHaveBeenCalled(); + }); + + it("should call generateThread per payload item and handleMissingThreads when uids are returned", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true }, + } as any); + vi.mocked(generateThread).mockReturnValue("thread-uid-1"); + useCollab(); + const handler = getHandler(1); + + handler({ data: { collab: { payload: [mockThread] } } }); + + expect(vi.mocked(generateThread)).toHaveBeenCalledWith(mockThread); + expect(vi.mocked(handleMissingThreads)).toHaveBeenCalledWith({ + payload: { isElementPresent: false }, + threadUids: ["thread-uid-1"], + }); + }); + + it("should not call handleMissingThreads when all generateThread calls return undefined", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true }, + } as any); + vi.mocked(generateThread).mockReturnValue(undefined); + useCollab(); + const handler = getHandler(1); + + handler({ data: { collab: { payload: [mockThread] } } }); + + expect(vi.mocked(generateThread)).toHaveBeenCalledWith(mockThread); + expect(vi.mocked(handleMissingThreads)).not.toHaveBeenCalled(); + }); +}); + +describe("COLLAB_DISABLE", () => { + // COLLAB_DISABLE does not read closure-captured config — shared useCollab() call is safe. + let collabDisableHandler: (data: any) => void; + + beforeEach(() => { + useCollab(); + collabDisableHandler = getHandler(2); + }); + + it("should set pauseFeedback and call hideAllCollabIcons when fromShare is true", () => { + collabDisableHandler({ + data: { collab: { fromShare: true, pauseFeedback: true } }, + }); + + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.pauseFeedback", + true + ); + expect(vi.mocked(hideAllCollabIcons)).toHaveBeenCalledOnce(); + expect(vi.mocked(removeAllCollabIcons)).not.toHaveBeenCalled(); + }); + + it("should set enable and isFeedbackMode to false and call removeAllCollabIcons when fromShare is false", () => { + collabDisableHandler({ + data: { collab: { fromShare: false } }, + }); + + expect(vi.mocked(Config.set)).toHaveBeenCalledWith("collab.enable", false); + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.isFeedbackMode", + false + ); + expect(vi.mocked(removeAllCollabIcons)).toHaveBeenCalledOnce(); + expect(vi.mocked(hideAllCollabIcons)).not.toHaveBeenCalled(); + }); +}); + +describe("COLLAB_THREADS_REMOVE", () => { + // Reads closure-captured config — each test calls useCollab() fresh after setting Config.get. + // NOTE: threadUids must always be a valid array in test data. + // BUG-1: line 130 does threadUids.length without null guard — crashes if threadUids is undefined. + + it("should return early when collab is disabled in config", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: false }, + } as any); + useCollab(); + const handler = getHandler(3); + + handler({ data: { threadUids: [], updateConfig: false } }); + + expect(vi.mocked(removeCollabIcon)).not.toHaveBeenCalled(); + expect(vi.mocked(Config.set)).not.toHaveBeenCalled(); + }); + + it("should set isFeedbackMode to true when updateConfig is true", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true }, + } as any); + useCollab(); + const handler = getHandler(3); + + handler({ data: { threadUids: [], updateConfig: true } }); + + expect(vi.mocked(Config.set)).toHaveBeenCalledWith( + "collab.isFeedbackMode", + true + ); + }); + + it("should call removeCollabIcon for each threadUid when threadUids is non-empty", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true }, + } as any); + useCollab(); + const handler = getHandler(3); + + handler({ data: { threadUids: ["uid-1", "uid-2"], updateConfig: false } }); + + expect(vi.mocked(removeCollabIcon)).toHaveBeenCalledWith("uid-1"); + expect(vi.mocked(removeCollabIcon)).toHaveBeenCalledWith("uid-2"); + expect(vi.mocked(removeCollabIcon)).toHaveBeenCalledTimes(2); + }); +}); + +describe("COLLAB_THREAD_REOPEN", () => { + // Reads closure-captured config — each test calls useCollab() fresh after setting Config.get. + // NOTE: data.data must always be provided. + // BUG-2: line 141 does data.data.thread without optional chaining — crashes if data.data is null. + + it("should return early when collab is disabled in config", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: false }, + } as any); + useCollab(); + const handler = getHandler(4); + + handler({ data: { thread: mockThread } }); + + expect(vi.mocked(generateThread)).not.toHaveBeenCalled(); + }); + + it("should call generateThread and handleMissingThreads when collab is enabled and a uid is returned", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true, pauseFeedback: false }, + } as any); + vi.mocked(generateThread).mockReturnValue("reopen-uid-1"); + useCollab(); + const handler = getHandler(4); + + handler({ data: { thread: mockThread } }); + + expect(vi.mocked(generateThread)).toHaveBeenCalledWith(mockThread, { + hidden: false, + }); + expect(vi.mocked(handleMissingThreads)).toHaveBeenCalledWith({ + payload: { isElementPresent: false }, + threadUids: ["reopen-uid-1"], + }); + }); +}); + +describe("COLLAB_THREAD_HIGHLIGHT", () => { + // Reads closure-captured config — each test calls useCollab() fresh after setting Config.get. + // NOTE: data.data must always be provided. + // BUG-3: line 160 does { threadUid } = data.data without optional chaining — crashes if data.data is null. + + it("should return early when collab is disabled in config", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: false, pauseFeedback: false }, + } as any); + useCollab(); + const handler = getHandler(5); + + handler({ data: { threadUid: "t1" } }); + + expect(vi.mocked(HighlightThread)).not.toHaveBeenCalled(); + }); + + it("should return early when pauseFeedback is true", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true, pauseFeedback: true }, + } as any); + useCollab(); + const handler = getHandler(5); + + handler({ data: { threadUid: "t1" } }); + + expect(vi.mocked(HighlightThread)).not.toHaveBeenCalled(); + }); + + it("should call HighlightThread with threadUid when collab is enabled and not paused", () => { + vi.mocked(Config.get).mockReturnValue({ + collab: { enable: true, pauseFeedback: false }, + } as any); + useCollab(); + const handler = getHandler(5); + + handler({ data: { threadUid: "highlight-uid-1" } }); + + expect(vi.mocked(HighlightThread)).toHaveBeenCalledWith("highlight-uid-1"); + }); +}); + +describe("cleanup", () => { + it("should call unregister on all 6 handlers when the cleanup function is invoked", () => { + const cleanup = useCollab(); + const unregisters = [0, 1, 2, 3, 4, 5].map(getUnregister); + + cleanup(); + + unregisters.forEach((unregister) => { + expect(unregister).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/visualBuilder/eventManager/useCollab.ts b/src/visualBuilder/eventManager/useCollab.ts index 7f05cc2b..aee470b7 100644 --- a/src/visualBuilder/eventManager/useCollab.ts +++ b/src/visualBuilder/eventManager/useCollab.ts @@ -127,7 +127,7 @@ export const useCollab = () => { if (data?.data?.updateConfig) { Config.set("collab.isFeedbackMode", true); } - if (threadUids.length > 0) { + if (threadUids?.length > 0) { threadUids.forEach((threadUid) => { removeCollabIcon(threadUid); }); @@ -138,9 +138,9 @@ export const useCollab = () => { const collabThreadReopen = visualBuilderPostMessage?.on( VisualBuilderPostMessageEvents.COLLAB_THREAD_REOPEN, (data: OnEvent) => { - const thread = data.data.thread; + const thread = data?.data?.thread; - if (!config?.collab?.enable) return; + if (!config?.collab?.enable || !thread) return; const result = generateThread(thread, { hidden: Boolean(config?.collab?.pauseFeedback), @@ -157,8 +157,8 @@ export const useCollab = () => { const collabThreadHighlight = visualBuilderPostMessage?.on( VisualBuilderPostMessageEvents.COLLAB_THREAD_HIGHLIGHT, (data: OnEvent) => { - const { threadUid } = data.data; - if (!config?.collab?.enable || config?.collab?.pauseFeedback) + const threadUid = data?.data?.threadUid; + if (!config?.collab?.enable || config?.collab?.pauseFeedback || !threadUid) return; HighlightThread(threadUid); From c12312d97e4a0b9a973750aa1cbbd642018b28b6 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Wed, 20 May 2026 12:00:32 +0530 Subject: [PATCH 04/16] feat(VB-1583): restrict toolbar actions for multiple custom field instances For multiple custom fields, show the edit modal button only on whole-field selection and suppress all toolbar actions (move, delete, form, add-instance) for individual instances. Co-Authored-By: Claude Sonnet 4.6 --- src/__test__/data/fields.ts | 26 ++++ src/visualBuilder/components/FieldToolbar.tsx | 34 ++++- .../components/__test__/fieldToolbar.test.tsx | 124 ++++++++++++++++++ .../__test__/handleIndividualFields.test.ts | 25 ++++ .../isCustomFieldMultipleInstance.test.ts | 74 +++++++++++ .../utils/handleIndividualFields.ts | 3 +- .../utils/isCustomFieldMultipleInstance.ts | 16 +++ src/visualBuilder/visualBuilder.style.ts | 12 ++ 8 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 src/visualBuilder/utils/__test__/isCustomFieldMultipleInstance.test.ts create mode 100644 src/visualBuilder/utils/isCustomFieldMultipleInstance.ts diff --git a/src/__test__/data/fields.ts b/src/__test__/data/fields.ts index 868a2d23..bd2b929e 100644 --- a/src/__test__/data/fields.ts +++ b/src/__test__/data/fields.ts @@ -50,3 +50,29 @@ export const mockMultipleFileFieldSchema: ISchemaFieldMap = { non_localizable: false, unique: false, }; + +export const mockMultipleCustomFieldSchema: ISchemaFieldMap = { + extension_uid: "test_extension_uid", + field_metadata: { extension: true }, + config: {}, + data_type: "number", + display_name: "Custom Field", + uid: "custom_field", + mandatory: false, + multiple: true, + non_localizable: false, + unique: false, +} as unknown as ISchemaFieldMap; + +export const mockSingleCustomFieldSchema: ISchemaFieldMap = { + extension_uid: "test_extension_uid", + field_metadata: { extension: true }, + config: {}, + data_type: "number", + display_name: "Custom Field", + uid: "custom_field", + mandatory: false, + multiple: false, + non_localizable: false, + unique: false, +} as unknown as ISchemaFieldMap; diff --git a/src/visualBuilder/components/FieldToolbar.tsx b/src/visualBuilder/components/FieldToolbar.tsx index 35d04443..49cd2a7f 100644 --- a/src/visualBuilder/components/FieldToolbar.tsx +++ b/src/visualBuilder/components/FieldToolbar.tsx @@ -44,6 +44,7 @@ import { FieldLocationAppList } from "./FieldLocationAppList"; import { FieldLocationIcon } from "./FieldLocationIcon"; import { WorkflowStageDetails } from "../utils/getWorkflowStageDetails"; import { ResolvedVariantPermissions } from "../utils/getResolvedVariantPermissions"; +import { isCustomFieldMultipleInstance as checkIsCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; export type FieldDetails = Pick< VisualBuilderCslpEventDetails, @@ -153,6 +154,8 @@ function FieldToolbarComponent( const APP_LIST_MIN_WIDTH = 230; let disableFieldActions = false; + let isCustomFieldMultipleInstance = false; + let isCustomFieldWholeMultiple = false; if (fieldSchema) { const { isDisabled } = isFieldDisabled( fieldSchema, @@ -188,7 +191,15 @@ function FieldToolbarComponent( fieldMetadata.instance.fieldPathWithIndex || fieldMetadata.multipleFieldMetadata?.index === -1); - isModalEditable = ALLOWED_MODAL_EDITABLE_FIELD.includes(fieldType) && !isWholeMultipleField; + isCustomFieldMultipleInstance = checkIsCustomFieldMultipleInstance(fieldSchema, fieldMetadata); + isCustomFieldWholeMultiple = + fieldType === FieldDataType.CUSTOM_FIELD && isMultiple && isWholeMultipleField; + + if (isCustomFieldWholeMultiple) { + isModalEditable = true; + } else { + isModalEditable = ALLOWED_MODAL_EDITABLE_FIELD.includes(fieldType) && !isWholeMultipleField; + } isReplaceAllowed = ALLOWED_REPLACE_FIELDS.includes(fieldType) && !isWholeMultipleField; @@ -425,6 +436,27 @@ function FieldToolbarComponent( } ); + if (isCustomFieldMultipleInstance) { + return ( +
+
+ You're on a custom field item. Select the entire custom field to edit or manage it. +
+
+ ); + } + return (
{ }); }); + describe("Custom field multiple — toolbar visibility", () => { + const customFieldInstanceMetadata: CslpData = { + ...mockMultipleFieldMetadata, + fieldPathWithIndex: "custom_field", + multipleFieldMetadata: { + index: 0, + parentDetails: { + parentPath: "custom_field", + parentCslpValue: "entry.ct.en-us", + }, + }, + instance: { fieldPathWithIndex: "custom_field.0" }, + }; + + const customFieldWholeMetadata: CslpData = { + ...mockMultipleFieldMetadata, + fieldPathWithIndex: "custom_field", + instance: { fieldPathWithIndex: "custom_field" }, + }; + + test("renders info message for a multiple custom field instance", async () => { + vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() => + Promise.resolve(mockMultipleCustomFieldSchema) + ); + + const { container } = render( + + ); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect( + await findByTestId( + container, + "visual-builder__custom-field-instance-message", + {}, + { timeout: 1000 } + ) + ).toBeInTheDocument(); + + expect( + container.querySelector( + '[data-testid="visual-builder__focused-toolbar__multiple-field-toolbar"]' + ) + ).not.toBeInTheDocument(); + }); + + test("shows edit button for a multiple custom field whole-field selection", async () => { + vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() => + Promise.resolve(mockMultipleCustomFieldSchema) + ); + + const { container } = render( + + ); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const editButton = await findByTestId( + container, + "visual-builder__focused-toolbar__multiple-field-toolbar__edit-button", + {}, + { timeout: 1000 } + ); + expect(editButton).toBeInTheDocument(); + }); + + test("shows edit button for a single (non-multiple) custom field", async () => { + vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() => + Promise.resolve(mockSingleCustomFieldSchema) + ); + + const singleCustomFieldMetadata: CslpData = { + ...mockMultipleFieldMetadata, + fieldPathWithIndex: "custom_field", + multipleFieldMetadata: { + index: -1, + parentDetails: null, + }, + instance: { fieldPathWithIndex: "custom_field" }, + }; + + const { container } = render( + + ); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + const editButton = await findByTestId( + container, + "visual-builder__focused-toolbar__multiple-field-toolbar__edit-button", + {}, + { timeout: 1000 } + ); + expect(editButton).toBeInTheDocument(); + }); + }); + describe("'Replace button' visibility for multiple file fields", () => { beforeEach(() => { // Override the mock for this describe block - resolve immediately diff --git a/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts b/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts index eee6ec19..cf09f5e0 100644 --- a/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts +++ b/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts @@ -133,6 +133,31 @@ describe("handleIndividualFields", () => { expect(handleAddButtonsForMultiple).toHaveBeenCalled(); }); + it("should NOT call handleAddButtonsForMultiple for a multiple custom field instance", async () => { + const fieldSchema = { + extension_uid: "test_ext", + field_metadata: { extension: true }, + data_type: "number", + multiple: true, + }; + const isDisabled = { isDisabled: false }; + + (FieldSchemaMap.getFieldSchema as Mock).mockResolvedValue(fieldSchema); + (getFieldData as Mock).mockResolvedValue([]); + (getFieldType as Mock).mockReturnValue(FieldDataType.CUSTOM_FIELD); + (isFieldDisabled as Mock).mockReturnValue(isDisabled); + + // Instance: different paths + valid index + eventDetails.fieldMetadata.multipleFieldMetadata = { + index: 0, + parentDetails: { parentPath: "fieldPath", parentCslpValue: "" }, + }; + + await handleIndividualFields(eventDetails, elements); + + expect(handleAddButtonsForMultiple).not.toHaveBeenCalled(); + }); + it("should handle inline editing for supported fields", async () => { const fieldSchema = { data_type: FieldDataType.SINGLELINE, diff --git a/src/visualBuilder/utils/__test__/isCustomFieldMultipleInstance.test.ts b/src/visualBuilder/utils/__test__/isCustomFieldMultipleInstance.test.ts new file mode 100644 index 00000000..8a3ecb3c --- /dev/null +++ b/src/visualBuilder/utils/__test__/isCustomFieldMultipleInstance.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { isCustomFieldMultipleInstance } from "../isCustomFieldMultipleInstance"; +import { + mockMultipleCustomFieldSchema, + mockSingleCustomFieldSchema, + mockMultipleLinkFieldSchema, +} from "../../../__test__/data/fields"; +import { CslpData } from "../../../cslp/types/cslp.types"; + +const instanceMetadata: CslpData = { + entry_uid: "entry", + content_type_uid: "ct", + cslpValue: "", + locale: "en-us", + variant: undefined, + fieldPath: "custom_field", + fieldPathWithIndex: "custom_field", + multipleFieldMetadata: { + index: 0, + parentDetails: { + parentPath: "custom_field", + parentCslpValue: "entry.ct.en-us", + }, + }, + instance: { + fieldPathWithIndex: "custom_field.0", + }, +}; + +const wholeFieldMetadata: CslpData = { + ...instanceMetadata, + fieldPathWithIndex: "custom_field", + instance: { fieldPathWithIndex: "custom_field" }, +}; + +const wholeFieldByIndexMetadata: CslpData = { + ...instanceMetadata, + multipleFieldMetadata: { + index: -1, + parentDetails: instanceMetadata.multipleFieldMetadata!.parentDetails, + }, +}; + +describe("isCustomFieldMultipleInstance", () => { + it("returns true for a multiple custom field instance", () => { + expect( + isCustomFieldMultipleInstance(mockMultipleCustomFieldSchema, instanceMetadata) + ).toBe(true); + }); + + it("returns false when whole field is selected (same paths)", () => { + expect( + isCustomFieldMultipleInstance(mockMultipleCustomFieldSchema, wholeFieldMetadata) + ).toBe(false); + }); + + it("returns false when whole field is selected (index === -1)", () => { + expect( + isCustomFieldMultipleInstance(mockMultipleCustomFieldSchema, wholeFieldByIndexMetadata) + ).toBe(false); + }); + + it("returns false for a single (non-multiple) custom field", () => { + expect( + isCustomFieldMultipleInstance(mockSingleCustomFieldSchema, instanceMetadata) + ).toBe(false); + }); + + it("returns false for a non-custom multiple field (e.g. link)", () => { + expect( + isCustomFieldMultipleInstance(mockMultipleLinkFieldSchema, instanceMetadata) + ).toBe(false); + }); +}); diff --git a/src/visualBuilder/utils/handleIndividualFields.ts b/src/visualBuilder/utils/handleIndividualFields.ts index 4ae9529b..3ddb6763 100644 --- a/src/visualBuilder/utils/handleIndividualFields.ts +++ b/src/visualBuilder/utils/handleIndividualFields.ts @@ -13,6 +13,7 @@ import { import { isFieldMultiple } from "./isFieldMultiple"; import { handleInlineEditableField } from "./handleInlineEditableField"; import { VisualBuilderEditContext } from "./types/index.types"; +import { isCustomFieldMultipleInstance } from "./isCustomFieldMultipleInstance"; import { pasteAsPlainText } from "./pasteAsPlainText"; import { removeFieldToolbar } from "../generators/generateToolbar"; import { fetchEntryPermissionsAndStageDetails } from "./fetchEntryPermissionsAndStageDetails"; @@ -70,7 +71,7 @@ export async function handleIndividualFields( ); if (isFieldMultiple(fieldSchema)) { - if (lastEditedField !== editableElement) { + if (!isCustomFieldMultipleInstance(fieldSchema, fieldMetadata) && lastEditedField !== editableElement) { const addButtonLabel = fieldSchema.data_type === "blocks" ? // ? `Add ${fieldSchema.display_name ?? "Modular Block"}` diff --git a/src/visualBuilder/utils/isCustomFieldMultipleInstance.ts b/src/visualBuilder/utils/isCustomFieldMultipleInstance.ts new file mode 100644 index 00000000..8f89210b --- /dev/null +++ b/src/visualBuilder/utils/isCustomFieldMultipleInstance.ts @@ -0,0 +1,16 @@ +import { CslpData } from "../../cslp/types/cslp.types"; +import { FieldDataType, ISchemaFieldMap } from "./types/index.types"; +import { getFieldType } from "./getFieldType"; +import { isFieldMultiple } from "./isFieldMultiple"; + +export function isCustomFieldMultipleInstance( + fieldSchema: ISchemaFieldMap, + fieldMetadata: CslpData +): boolean { + return ( + getFieldType(fieldSchema) === FieldDataType.CUSTOM_FIELD && + isFieldMultiple(fieldSchema) && + fieldMetadata.fieldPathWithIndex !== fieldMetadata.instance.fieldPathWithIndex && + (fieldMetadata.multipleFieldMetadata?.index ?? -1) !== -1 + ); +} diff --git a/src/visualBuilder/visualBuilder.style.ts b/src/visualBuilder/visualBuilder.style.ts index 96a5ce7f..3265d27e 100644 --- a/src/visualBuilder/visualBuilder.style.ts +++ b/src/visualBuilder/visualBuilder.style.ts @@ -828,6 +828,18 @@ export function visualBuilderStyles() { z-index: 2147483647 !important; position: relative; `, + "visual-builder__custom-field-instance-message": css` + display: flex; + align-items: center; + height: 40px; + padding: 0 12px; + background: #fff; + border-radius: 4px; + box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15); + font-size: 12px; + color: #475161; + white-space: nowrap; + `, "visual-builder__variant-button": css` display: flex; min-width: 3rem !important; From aa57ba951081a2726122fa9d0fd489a65bb7bdee Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Wed, 20 May 2026 17:47:58 +0530 Subject: [PATCH 05/16] feat(visual-builder): add overlayPropagation init flag for cslp hover/click When event.target.closest("[data-cslp]") returns null, fall back to document.elementsFromPoint(clientX, clientY) so Visual Builder hover and click detection can find a data-cslp element beneath an unrelated sibling overlay (e.g. an empty CSS-grid spacer cell visually overlapping a text column). Opt-in via overlayPropagation: { enable: true } in the LP init config; default off so existing apps see no behavior change. - src/types/types.ts: IConfigOverlayPropagation interface, added to IInitData and IConfig. - src/configManager/config.default.ts: default { enable: false } in both getUserInitData and getDefaultConfig. - src/configManager/handleUserConfig.ts: merge initData.overlayPropagation onto Config, following the existing editButton fallback pattern. - src/visualBuilder/utils/getCsDataOfElement.ts: elementsFromPoint fallback when closest() misses, gated on the flag. - Tests: 3 new cases in getCsDataOfElement.test.ts (fallback off, on, on-with-no-match) and 3 cases in handleUserConfig.test.ts. vitest.setup extends the existing elementFromPoint stub with elementsFromPoint. - docs/live-preview-configs.md + README.md: new section describing the flag and when to enable it; note that standalone LP Edit button support is tracked separately (VB-1623). Refs VB-1583. Related: VB-1616 (multi-instance focus), VB-1623 (standalone LP edit button overlayPropagation follow-up). Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + docs/live-preview-configs.md | 27 ++++++ .../__test__/handleUserConfig.test.ts | 36 ++++++++ src/configManager/config.default.ts | 6 ++ src/configManager/handleUserConfig.ts | 8 +- src/types/types.ts | 15 ++++ .../utils/__test__/getCsDataOfElement.test.ts | 87 +++++++++++++++++++ src/visualBuilder/utils/getCsDataOfElement.ts | 22 ++++- vitest.setup.ts | 1 + 9 files changed, 200 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 217c231f..4eee2344 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ You can configure the SDK using the following options: - [`mode`](docs/live-preview-configs.md#mode) (`preview` vs `builder`) - [`editButton`](docs/live-preview-configs.md#editbutton) - [`editInVisualBuilderButton`](docs/live-preview-configs.md#editinvisualbuilderbutton) (Start Editing outside Visual Editor) +- [`overlayPropagation`](docs/live-preview-configs.md#overlaypropagation) (opt-in fallback to pierce blocking sibling overlays during hover/click detection) - [`cleanCslpOnProduction`](docs/live-preview-configs.md#cleancslponproduction) - [`stackDetails`](docs/live-preview-configs.md#stackdetails) ([`apiKey`](docs/live-preview-configs.md#apikey), [`environment`](docs/live-preview-configs.md#environment)) - [`clientUrlParams`](docs/live-preview-configs.md#clienturlparams) — [NA](docs/live-preview-configs.md#na-config) / [EU](docs/live-preview-configs.md#eu-config) diff --git a/docs/live-preview-configs.md b/docs/live-preview-configs.md index a5a9048f..a44cbd43 100644 --- a/docs/live-preview-configs.md +++ b/docs/live-preview-configs.md @@ -145,6 +145,33 @@ The editInVisualBuilderButton object contains two keys: The user can place the "Start Editing" button in four predefined positions top-left, top-right, bottom-left, and bottom-right. +### `overlayPropagation` + +The `overlayPropagation` object enables Visual Builder hover/click detection to pierce through sibling elements that visually overlap a `data-cslp` field but intercept the mouse event before it reaches the field. This is an opt-in fallback intended for apps where unrelated DOM elements (for example, empty CSS-grid spacer cells in a multi-column layout) sit on top of `data-cslp` containers and block the SDK from detecting the field on hover or click. + +When the flag is **enabled** and `event.target.closest("[data-cslp]")` returns `null`, the SDK falls back to `document.elementsFromPoint(clientX, clientY)` and selects the topmost element in that stack that carries a `data-cslp` attribute. When the flag is **disabled** (default), behavior is unchanged. + +> **Note:** This flag currently affects the **Visual Builder** hover/click pipeline only. Equivalent support for the standalone Live Preview Edit button (the floating Edit button outside Visual Builder) is tracked separately. + +The `overlayPropagation` object contains one key: + +1. #### `enable` + | type | default | optional | + | ------- | ------- | -------- | + | boolean | false | yes | + + Set to `true` to activate the `elementsFromPoint` fallback for hover and click detection. The fallback runs only when the standard `closest("[data-cslp]")` lookup returns `null`, so there is no performance impact on the normal path. + +**For example:** +```ts +ContentstackLivePreview.init({ + ... + overlayPropagation: { + enable: true, + } +}); +``` + ### `cleanCslpOnProduction` | type | default | optional | diff --git a/src/configManager/__test__/handleUserConfig.test.ts b/src/configManager/__test__/handleUserConfig.test.ts index 3d61e808..08072c39 100644 --- a/src/configManager/__test__/handleUserConfig.test.ts +++ b/src/configManager/__test__/handleUserConfig.test.ts @@ -409,6 +409,42 @@ describe("handleInitData()", () => { expect(config.editButton.includeByQueryParameter).toBe(false); }); }); + + describe("handleInitData() - overlayPropagation configuration", () => { + let config: DeepSignal; + + beforeEach(() => { + Config.reset(); + config = Config.get(); + }); + + afterAll(() => { + Config.reset(); + }); + + test("should default overlayPropagation.enable to false when not provided", () => { + handleInitData({}); + expect(config.overlayPropagation.enable).toBe(false); + }); + + test("should set overlayPropagation.enable from initData when true", () => { + const initData: Partial = { + overlayPropagation: { enable: true }, + }; + + handleInitData(initData); + expect(config.overlayPropagation.enable).toBe(true); + }); + + test("should keep overlayPropagation.enable false when initData sets it false explicitly", () => { + const initData: Partial = { + overlayPropagation: { enable: false }, + }; + + handleInitData(initData); + expect(config.overlayPropagation.enable).toBe(false); + }); + }); }); describe("handleInitData() - enableLivePreviewOutsideIframe", () => { diff --git a/src/configManager/config.default.ts b/src/configManager/config.default.ts index 5a62351b..e07e1a82 100644 --- a/src/configManager/config.default.ts +++ b/src/configManager/config.default.ts @@ -20,6 +20,9 @@ export function getUserInitData(): IInitData { enable: true, position: "bottom-right" }, + overlayPropagation: { + enable: false, + }, mode: "preview", @@ -59,6 +62,9 @@ export function getDefaultConfig(): IConfig { enable: true, position: "bottom-right" }, + overlayPropagation: { + enable: false, + }, hash: "", mode: 1, diff --git a/src/configManager/handleUserConfig.ts b/src/configManager/handleUserConfig.ts index c13a1038..650bca5d 100644 --- a/src/configManager/handleUserConfig.ts +++ b/src/configManager/handleUserConfig.ts @@ -114,13 +114,19 @@ export const handleInitData = (initData: Partial): void => { initData.editInVisualBuilderButton?.enable ?? stackSdk.live_preview?.editInVisualBuilderButton?.enable ?? config.editInVisualBuilderButton.enable, - position: + position: initData.editInVisualBuilderButton?.position ?? stackSdk.live_preview?.position ?? config.editInVisualBuilderButton.position ?? "bottom-right", }) + Config.set("overlayPropagation", { + enable: + initData.overlayPropagation?.enable ?? + config.overlayPropagation.enable, + }); + Config.set( "enableLivePreviewOutsideIframe", initData.enableLivePreviewOutsideIframe ?? diff --git a/src/types/types.ts b/src/types/types.ts index 93602ecd..d601094d 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -82,6 +82,7 @@ export declare interface IConfig { hash: string; editButton: IConfigEditButton; editInVisualBuilderButton: IConfigEditInVisualBuilderButton; + overlayPropagation: IConfigOverlayPropagation; mode: ILivePreviewModeConfig; elements: { highlightedElement: HTMLElement | null; @@ -100,6 +101,19 @@ export declare interface IConfigEditInVisualBuilderButton { | "bottom-right" } +export declare interface IConfigOverlayPropagation { + /** + * When `true`, Visual Builder hover/click detection falls back to + * `document.elementsFromPoint()` if the immediate `event.target` has no + * ancestor with `data-cslp`. This allows the SDK to pierce sibling + * elements (e.g. empty CSS-grid spacer cells) that visually overlap a + * `data-cslp` field and would otherwise intercept the mouse event. + * + * @default false + */ + enable: boolean; +} + export declare interface IConfigEditButton { enable: boolean; @@ -132,6 +146,7 @@ export declare interface IInitData { stackSdk: IStackSdk; editButton: IConfigEditButton; editInVisualBuilderButton: IConfigEditInVisualBuilderButton; + overlayPropagation: IConfigOverlayPropagation; mode: ILivePreviewMode; enableLivePreviewOutsideIframe: boolean | undefined; // default: undefined } diff --git a/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts b/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts index ad387095..91b5c471 100644 --- a/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts +++ b/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts @@ -1,5 +1,6 @@ import { getCsDataOfElement, getDOMEditStack } from "../getCsDataOfElement"; import { JSDOM } from "jsdom"; +import Config from "../../../configManager/configManager"; describe("getCsDataOfElement", () => { let targetElement: Element; @@ -121,6 +122,92 @@ describe("getCsDataOfElement", () => { }, }); }); + + describe("overlayPropagation fallback", () => { + let blocker: HTMLDivElement; + let cslpEl: HTMLDivElement; + let overlayEvent: MouseEvent; + + beforeEach(() => { + document.body.innerHTML = ""; + Config.set("overlayPropagation", { enable: false }); + + // sibling layout: blocker overlaps cslpEl visually + cslpEl = document.createElement("div"); + cslpEl.setAttribute( + "data-cslp", + "all_fields.bltentryuid.en-us.title" + ); + blocker = document.createElement("div"); + + document.body.appendChild(cslpEl); + document.body.appendChild(blocker); + + overlayEvent = new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + clientX: 100, + clientY: 100, + }); + // mousemove target = blocker (no data-cslp ancestor) + Object.defineProperty(overlayEvent, "target", { + value: blocker, + writable: false, + }); + }); + + afterEach(() => { + Config.set("overlayPropagation", { enable: false }); + vi.clearAllMocks(); + }); + + test("returns undefined when overlayPropagation is disabled (default)", () => { + (document.elementsFromPoint as ReturnType) + .mockReturnValue([blocker, cslpEl]); + + const result = getCsDataOfElement(overlayEvent); + + expect(result).toBeUndefined(); + // fallback path must not run when flag is off + expect(document.elementsFromPoint).not.toHaveBeenCalled(); + }); + + test("falls back to elementsFromPoint when overlayPropagation is enabled and resolves the underlying cslp element", () => { + Config.set("overlayPropagation", { enable: true }); + (document.elementsFromPoint as ReturnType) + .mockReturnValue([blocker, cslpEl]); + + const result = getCsDataOfElement(overlayEvent); + + expect(document.elementsFromPoint).toHaveBeenCalledWith(100, 100); + expect(result).toEqual({ + editableElement: cslpEl, + cslpData: "all_fields.bltentryuid.en-us.title", + fieldMetadata: { + entry_uid: "bltentryuid", + content_type_uid: "all_fields", + locale: "en-us", + cslpValue: "all_fields.bltentryuid.en-us.title", + fieldPath: "title", + fieldPathWithIndex: "title", + multipleFieldMetadata: { parentDetails: null, index: -1 }, + instance: { fieldPathWithIndex: "title" }, + }, + }); + }); + + test("returns undefined when fallback is enabled but no element under the cursor has data-cslp", () => { + Config.set("overlayPropagation", { enable: true }); + const otherBlocker = document.createElement("div"); + (document.elementsFromPoint as ReturnType) + .mockReturnValue([blocker, otherBlocker]); + + const result = getCsDataOfElement(overlayEvent); + + expect(document.elementsFromPoint).toHaveBeenCalledWith(100, 100); + expect(result).toBeUndefined(); + }); + }); }); describe("getDOMEditStack", () => { diff --git a/src/visualBuilder/utils/getCsDataOfElement.ts b/src/visualBuilder/utils/getCsDataOfElement.ts index a4d952dc..50cc76ca 100644 --- a/src/visualBuilder/utils/getCsDataOfElement.ts +++ b/src/visualBuilder/utils/getCsDataOfElement.ts @@ -2,12 +2,20 @@ import { CslpData } from "../../cslp/types/cslp.types"; import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import { extractDetailsFromCslp, isValidCslp } from "../../cslp/cslpdata"; import { DATA_CSLP_ATTR_SELECTOR } from "./constants"; +import Config from "../../configManager/configManager"; /** * Returns the CSLP data of the closest ancestor element with a `data-cslp` attribute * to the target element of a mouse event. + * + * When `overlayPropagation.enable` is `true` and the target element has no + * `data-cslp` ancestor, falls back to `document.elementsFromPoint()` so the + * lookup can pierce sibling elements (e.g. empty CSS-grid spacer cells) that + * visually overlap a `data-cslp` field but would otherwise intercept the + * mouse event. + * * @param event - The mouse event. - * @returns The CSLP data of the closest ancestor element with a `data-cslp` attribute, + * @returns The CSLP data of the resolved element with a `data-cslp` attribute, * along with metadata and schema information for the corresponding field. */ export function getCsDataOfElement( @@ -17,7 +25,17 @@ export function getCsDataOfElement( if (!targetElement) { return; } - const editableElement = targetElement.closest("[data-cslp]"); + let editableElement: Element | null = + targetElement.closest("[data-cslp]"); + + if (!editableElement && Config.get().overlayPropagation.enable) { + const stack = document.elementsFromPoint( + event.clientX, + event.clientY + ); + editableElement = + stack.find((el) => el.hasAttribute("data-cslp")) ?? null; + } if (!editableElement) { return; diff --git a/vitest.setup.ts b/vitest.setup.ts index f7d2a346..07884479 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -43,6 +43,7 @@ beforeAll(() => { installGlobalObserverMocks(); document.elementFromPoint = vi.fn(); + document.elementsFromPoint = vi.fn().mockReturnValue([]); }); afterAll(() => { From b30f41f6b1bb86a36bc5ea2bfcc3d76cb3061e43 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 11:56:08 +0530 Subject: [PATCH 06/16] docs(main.mustache): add overlayPropagation entry to config list Port the overlayPropagation bullet added to README.md in commit 44e6d4c so the template stays in sync and the entry survives README regeneration. Co-Authored-By: Claude Sonnet 4.6 --- main.mustache | 1 + 1 file changed, 1 insertion(+) diff --git a/main.mustache b/main.mustache index 4f5ab59a..337fd82f 100644 --- a/main.mustache +++ b/main.mustache @@ -71,6 +71,7 @@ You can configure the SDK using the following options: - [`mode`](docs/live-preview-configs.md#mode) (`preview` vs `builder`) - [`editButton`](docs/live-preview-configs.md#editbutton) - [`editInVisualBuilderButton`](docs/live-preview-configs.md#editinvisualbuilderbutton) (Start Editing outside Visual Editor) +- [`overlayPropagation`](docs/live-preview-configs.md#overlaypropagation) (opt-in fallback to pierce blocking sibling overlays during hover/click detection) - [`cleanCslpOnProduction`](docs/live-preview-configs.md#cleancslponproduction) - [`stackDetails`](docs/live-preview-configs.md#stackdetails) ([`apiKey`](docs/live-preview-configs.md#apikey), [`environment`](docs/live-preview-configs.md#environment)) - [`clientUrlParams`](docs/live-preview-configs.md#clienturlparams) — [NA](docs/live-preview-configs.md#na-config) / [EU](docs/live-preview-configs.md#eu-config) From be27497b69e7a08f95c5ec96ffddf11e22b35029 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 12:19:07 +0530 Subject: [PATCH 07/16] feat(VB-1623): extend overlayPropagation to standalone Live Preview Edit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add elementsFromPoint fallback inside addCslpOutline() gated on Config.get().overlayPropagation.enable so the floating Edit button can pierce blocking sibling overlays, matching the existing Visual Builder fix in getCsDataOfElement.ts. Add a throttled mousemove listener (100ms) in LivePreviewEditButton constructor (also gated on the flag) so the Edit button tracks the cursor while it moves within the interior of a blocking overlay element — mouseover alone does not re-fire in that case. Listener is cleaned up in destroy(). Extract a highlightCslpElement helper in cslpdata.ts to avoid duplicating the highlight logic between the composedPath loop and the elementsFromPoint fallback. Update docs/live-preview-configs.md to document both pipelines covered by the flag and remove the "Visual Builder only" note. Co-Authored-By: Claude Sonnet 4.6 --- docs/live-preview-configs.md | 9 +++-- src/cslp/cslpdata.ts | 50 ++++++++++++++++-------- src/livePreview/editButton/editButton.ts | 20 ++++++++++ 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/docs/live-preview-configs.md b/docs/live-preview-configs.md index a44cbd43..9e554824 100644 --- a/docs/live-preview-configs.md +++ b/docs/live-preview-configs.md @@ -147,11 +147,14 @@ The editInVisualBuilderButton object contains two keys: ### `overlayPropagation` -The `overlayPropagation` object enables Visual Builder hover/click detection to pierce through sibling elements that visually overlap a `data-cslp` field but intercept the mouse event before it reaches the field. This is an opt-in fallback intended for apps where unrelated DOM elements (for example, empty CSS-grid spacer cells in a multi-column layout) sit on top of `data-cslp` containers and block the SDK from detecting the field on hover or click. +The `overlayPropagation` object enables hover/click detection to pierce through sibling elements that visually overlap a `data-cslp` field but intercept the mouse event before it reaches the field. This is an opt-in fallback intended for apps where unrelated DOM elements (for example, empty CSS-grid spacer cells in a multi-column layout) sit on top of `data-cslp` containers and block the SDK from detecting the field on hover or click. -When the flag is **enabled** and `event.target.closest("[data-cslp]")` returns `null`, the SDK falls back to `document.elementsFromPoint(clientX, clientY)` and selects the topmost element in that stack that carries a `data-cslp` attribute. When the flag is **disabled** (default), behavior is unchanged. +When the flag is **enabled** and the standard event path contains no `data-cslp` element, the SDK falls back to `document.elementsFromPoint(clientX, clientY)` and selects the topmost element in that stack that carries a `data-cslp` attribute. When the flag is **disabled** (default), behavior is unchanged. -> **Note:** This flag currently affects the **Visual Builder** hover/click pipeline only. Equivalent support for the standalone Live Preview Edit button (the floating Edit button outside Visual Builder) is tracked separately. +This flag covers both pipelines: + +- **Visual Builder** — hover outline and click-to-focus detection (`getCsDataOfElement`) +- **Standalone Live Preview Edit button** — the floating Edit button outside Visual Builder (`addCslpOutline`); a companion throttled `mousemove` listener ensures the button tracks the cursor while it moves within a blocking overlay The `overlayPropagation` object contains one key: diff --git a/src/cslp/cslpdata.ts b/src/cslp/cslpdata.ts index 661aed61..8baaf4ad 100644 --- a/src/cslp/cslpdata.ts +++ b/src/cslp/cslpdata.ts @@ -214,6 +214,23 @@ function getMultipleFieldMetadata( * @param e - The MouseEvent object representing the click event. * @param callback - An optional callback function that will be called with the CSLP tag and highlighted element as arguments. */ +function highlightCslpElement( + element: HTMLElement, + cslpTag: string, + elements: ReturnType["elements"], + callback?: (args: { cslpTag: string; highlightedElement: HTMLElement }) => void +): void { + if (elements.highlightedElement) + elements.highlightedElement.classList.remove( + cslpTagStyles()["cslp-edit-mode"] + ); + element.classList.add(cslpTagStyles()["cslp-edit-mode"]); + const updatedElements = elements; + updatedElements.highlightedElement = element as DeepSignal; + Config.set("elements", updatedElements); + callback?.({ cslpTag, highlightedElement: element }); +} + export function addCslpOutline( e: MouseEvent, callback?: (args: { @@ -234,25 +251,26 @@ export function addCslpOutline( const cslpTag = element.getAttribute("data-cslp"); if (trigger && isValidCslp(cslpTag)) { - if (elements.highlightedElement) - elements.highlightedElement.classList.remove( - cslpTagStyles()["cslp-edit-mode"] - ); - element.classList.add(cslpTagStyles()["cslp-edit-mode"]); - - const updatedElements = elements; - updatedElements.highlightedElement = - element as DeepSignal; - Config.set("elements", updatedElements); - - callback?.({ - cslpTag: cslpTag, - highlightedElement: element, - }); - + highlightCslpElement(element, cslpTag, elements, callback); trigger = false; } else if (!trigger) { element.classList.remove(cslpTagStyles()["cslp-edit-mode"]); } } + + // composedPath() misses elements that are visually under a sibling overlay; + // fall back to elementsFromPoint so the Edit button can still find the field. + if (trigger && Config.get().overlayPropagation?.enable) { + const pointElements = document.elementsFromPoint(e.clientX, e.clientY); + for (const el of pointElements) { + const element = el as HTMLElement; + if (element.nodeName === "BODY") break; + if (typeof element?.getAttribute !== "function") continue; + const cslpTag = element.getAttribute("data-cslp"); + if (isValidCslp(cslpTag)) { + highlightCslpElement(element, cslpTag, elements, callback); + break; + } + } + } } diff --git a/src/livePreview/editButton/editButton.ts b/src/livePreview/editButton/editButton.ts index b3c46f9a..69da9204 100644 --- a/src/livePreview/editButton/editButton.ts +++ b/src/livePreview/editButton/editButton.ts @@ -1,3 +1,4 @@ +import { throttle } from "lodash-es"; import { effect } from "@preact/signals"; import { inIframe, isOpeningInNewTab } from "../../common/inIframe"; import Config from "../../configManager/configManager"; @@ -281,6 +282,7 @@ export class LivePreviewEditButton { singular: null, multiple: null, }; + private overlayMouseMoveHandler: ((e: MouseEvent) => void) | null = null; static livePreviewEditButton: LivePreviewEditButton | null = null; constructor() { @@ -297,6 +299,17 @@ export class LivePreviewEditButton { window.addEventListener("scroll", this.updateTooltipPosition); window.addEventListener("mouseover", this.addEditStyleOnHover); + + if (Config.get().overlayPropagation?.enable) { + this.overlayMouseMoveHandler = throttle( + this.addEditStyleOnHover, + 100 + ); + window.addEventListener( + "mousemove", + this.overlayMouseMoveHandler + ); + } } } @@ -562,6 +575,13 @@ export class LivePreviewEditButton { destroy(): void { window.removeEventListener("scroll", this.updateTooltipPosition); window.removeEventListener("mouseover", this.addEditStyleOnHover); + if (this.overlayMouseMoveHandler) { + window.removeEventListener( + "mousemove", + this.overlayMouseMoveHandler + ); + this.overlayMouseMoveHandler = null; + } this.tooltip?.remove(); } } From f402b41b69454a59a9a6c143b698d06d5ab1459f Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 14:17:51 +0530 Subject: [PATCH 08/16] feat(VB-1623): extend overlayPropagation to standalone Live Preview Edit button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add elementsFromPoint fallback inside addCslpOutline() gated on Config.get().overlayPropagation.enable so the floating Edit button can pierce blocking sibling overlays, matching the existing Visual Builder fix in getCsDataOfElement.ts. Add a throttled mousemove listener (100ms) in LivePreviewEditButton constructor (also gated on the flag) so the Edit button tracks the cursor while it moves within the interior of a blocking overlay element — mouseover alone does not re-fire in that case. Listener is cleaned up in destroy(). Fix a timing issue: the module-load effect creates LivePreviewEditButton before init() runs (config defaults apply). LivePreview constructor now calls destroy() on the effect-created instance before creating a new one with the correct init config, ensuring overlayPropagation and other user-provided config are honoured. Extract a highlightCslpElement helper in cslpdata.ts to avoid duplicating the highlight logic between the composedPath loop and the elementsFromPoint fallback. Update docs/live-preview-configs.md to document both pipelines covered by the flag and remove the "Visual Builder only" note. Tests: 3 new cases in cslpdata.test.ts (fallback off, on-finds-element, on-no-match) and 3 cases in editButtonAction.test.ts (no listener when disabled, listener added when enabled, listener removed on destroy). Co-Authored-By: Claude Sonnet 4.6 --- docs/live-preview-configs.md | 9 ++- src/cslp/__test__/cslpdata.test.ts | 67 ++++++++++++++++++- src/cslp/cslpdata.ts | 50 +++++++++----- .../__test__/editButtonAction.test.ts | 59 ++++++++++++++++ src/livePreview/editButton/editButton.ts | 19 ++++++ src/livePreview/live-preview.ts | 6 +- 6 files changed, 189 insertions(+), 21 deletions(-) diff --git a/docs/live-preview-configs.md b/docs/live-preview-configs.md index a44cbd43..9e554824 100644 --- a/docs/live-preview-configs.md +++ b/docs/live-preview-configs.md @@ -147,11 +147,14 @@ The editInVisualBuilderButton object contains two keys: ### `overlayPropagation` -The `overlayPropagation` object enables Visual Builder hover/click detection to pierce through sibling elements that visually overlap a `data-cslp` field but intercept the mouse event before it reaches the field. This is an opt-in fallback intended for apps where unrelated DOM elements (for example, empty CSS-grid spacer cells in a multi-column layout) sit on top of `data-cslp` containers and block the SDK from detecting the field on hover or click. +The `overlayPropagation` object enables hover/click detection to pierce through sibling elements that visually overlap a `data-cslp` field but intercept the mouse event before it reaches the field. This is an opt-in fallback intended for apps where unrelated DOM elements (for example, empty CSS-grid spacer cells in a multi-column layout) sit on top of `data-cslp` containers and block the SDK from detecting the field on hover or click. -When the flag is **enabled** and `event.target.closest("[data-cslp]")` returns `null`, the SDK falls back to `document.elementsFromPoint(clientX, clientY)` and selects the topmost element in that stack that carries a `data-cslp` attribute. When the flag is **disabled** (default), behavior is unchanged. +When the flag is **enabled** and the standard event path contains no `data-cslp` element, the SDK falls back to `document.elementsFromPoint(clientX, clientY)` and selects the topmost element in that stack that carries a `data-cslp` attribute. When the flag is **disabled** (default), behavior is unchanged. -> **Note:** This flag currently affects the **Visual Builder** hover/click pipeline only. Equivalent support for the standalone Live Preview Edit button (the floating Edit button outside Visual Builder) is tracked separately. +This flag covers both pipelines: + +- **Visual Builder** — hover outline and click-to-focus detection (`getCsDataOfElement`) +- **Standalone Live Preview Edit button** — the floating Edit button outside Visual Builder (`addCslpOutline`); a companion throttled `mousemove` listener ensures the button tracks the cursor while it moves within a blocking overlay The `overlayPropagation` object contains one key: diff --git a/src/cslp/__test__/cslpdata.test.ts b/src/cslp/__test__/cslpdata.test.ts index b297bbd5..f82a1299 100644 --- a/src/cslp/__test__/cslpdata.test.ts +++ b/src/cslp/__test__/cslpdata.test.ts @@ -1,4 +1,5 @@ -import { extractDetailsFromCslp, isValidCslp } from "../cslpdata"; +import { addCslpOutline, extractDetailsFromCslp, isValidCslp } from "../cslpdata"; +import Config from "../../configManager/configManager"; describe("isValidCslp", () => { describe("valid cases", () => { @@ -217,3 +218,67 @@ describe("extractDetailsFromCslp", () => { expect(extractDetailsFromCslp(cslpValue)).toEqual(expected); }); }); + +describe("addCslpOutline — overlayPropagation fallback", () => { + const CSLP = "ct.entry.en-us.field"; + + function makeEvent(x = 10, y = 10, target?: HTMLElement): MouseEvent { + return { + composedPath: () => (target ? [target, document.body] : [document.body]), + clientX: x, + clientY: y, + } as unknown as MouseEvent; + } + + beforeEach(() => { + Config.reset(); + document.body.innerHTML = ""; + }); + + afterEach(() => { + Config.reset(); + vi.restoreAllMocks(); + }); + + test("does NOT call elementsFromPoint when flag is off and composedPath finds element", () => { + const el = document.createElement("div"); + el.setAttribute("data-cslp", CSLP); + document.body.appendChild(el); + const spy = vi.spyOn(document, "elementsFromPoint"); + + Config.set("overlayPropagation", { enable: false }); + addCslpOutline(makeEvent(10, 10, el)); + + expect(spy).not.toHaveBeenCalled(); + }); + + test("calls elementsFromPoint fallback when flag is ON and composedPath misses cslp", () => { + const el = document.createElement("div"); + el.setAttribute("data-cslp", CSLP); + document.body.appendChild(el); + + Config.set("overlayPropagation", { enable: true }); + vi.spyOn(document, "elementsFromPoint").mockReturnValue([el]); + + const callback = vi.fn(); + addCslpOutline(makeEvent(10, 10), callback); + + expect(document.elementsFromPoint).toHaveBeenCalledWith(10, 10); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ cslpTag: CSLP, highlightedElement: el }) + ); + }); + + test("fallback finds no cslp element — callback not called", () => { + const blocker = document.createElement("div"); + document.body.appendChild(blocker); + + Config.set("overlayPropagation", { enable: true }); + vi.spyOn(document, "elementsFromPoint").mockReturnValue([blocker]); + + const callback = vi.fn(); + addCslpOutline(makeEvent(10, 10), callback); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cslp/cslpdata.ts b/src/cslp/cslpdata.ts index 661aed61..8baaf4ad 100644 --- a/src/cslp/cslpdata.ts +++ b/src/cslp/cslpdata.ts @@ -214,6 +214,23 @@ function getMultipleFieldMetadata( * @param e - The MouseEvent object representing the click event. * @param callback - An optional callback function that will be called with the CSLP tag and highlighted element as arguments. */ +function highlightCslpElement( + element: HTMLElement, + cslpTag: string, + elements: ReturnType["elements"], + callback?: (args: { cslpTag: string; highlightedElement: HTMLElement }) => void +): void { + if (elements.highlightedElement) + elements.highlightedElement.classList.remove( + cslpTagStyles()["cslp-edit-mode"] + ); + element.classList.add(cslpTagStyles()["cslp-edit-mode"]); + const updatedElements = elements; + updatedElements.highlightedElement = element as DeepSignal; + Config.set("elements", updatedElements); + callback?.({ cslpTag, highlightedElement: element }); +} + export function addCslpOutline( e: MouseEvent, callback?: (args: { @@ -234,25 +251,26 @@ export function addCslpOutline( const cslpTag = element.getAttribute("data-cslp"); if (trigger && isValidCslp(cslpTag)) { - if (elements.highlightedElement) - elements.highlightedElement.classList.remove( - cslpTagStyles()["cslp-edit-mode"] - ); - element.classList.add(cslpTagStyles()["cslp-edit-mode"]); - - const updatedElements = elements; - updatedElements.highlightedElement = - element as DeepSignal; - Config.set("elements", updatedElements); - - callback?.({ - cslpTag: cslpTag, - highlightedElement: element, - }); - + highlightCslpElement(element, cslpTag, elements, callback); trigger = false; } else if (!trigger) { element.classList.remove(cslpTagStyles()["cslp-edit-mode"]); } } + + // composedPath() misses elements that are visually under a sibling overlay; + // fall back to elementsFromPoint so the Edit button can still find the field. + if (trigger && Config.get().overlayPropagation?.enable) { + const pointElements = document.elementsFromPoint(e.clientX, e.clientY); + for (const el of pointElements) { + const element = el as HTMLElement; + if (element.nodeName === "BODY") break; + if (typeof element?.getAttribute !== "function") continue; + const cslpTag = element.getAttribute("data-cslp"); + if (isValidCslp(cslpTag)) { + highlightCslpElement(element, cslpTag, elements, callback); + break; + } + } + } } diff --git a/src/livePreview/editButton/__test__/editButtonAction.test.ts b/src/livePreview/editButton/__test__/editButtonAction.test.ts index db2acca8..4c468439 100644 --- a/src/livePreview/editButton/__test__/editButtonAction.test.ts +++ b/src/livePreview/editButton/__test__/editButtonAction.test.ts @@ -627,3 +627,62 @@ describe("cslp tooltip", () => { expect(document.getElementById('cslp-tooltip')).toHaveAttribute('current-data-cslp', DESC_CSLP_TAG); }); }); + +describe("LivePreviewEditButton — overlayPropagation mousemove listener", () => { + beforeEach(() => { + Config.reset(); + document.body.innerHTML = ""; + Config.set("editButton", { + enable: true, + exclude: [], + position: "top", + includeByQueryParameter: true, + }); + Config.set("windowType", ILivePreviewWindowType.PREVIEW); + }); + + afterEach(() => { + LivePreviewEditButton.livePreviewEditButton?.destroy(); + LivePreviewEditButton.livePreviewEditButton = null; + document.body.innerHTML = ""; + Config.reset(); + vi.restoreAllMocks(); + }); + + test("does NOT add mousemove listener when overlayPropagation is disabled", () => { + Config.set("overlayPropagation", { enable: false }); + const addSpy = vi.spyOn(window, "addEventListener"); + + new LivePreviewEditButton(); + + const mousemoveCalls = addSpy.mock.calls.filter( + ([event]) => event === "mousemove" + ); + expect(mousemoveCalls).toHaveLength(0); + }); + + test("adds mousemove listener when overlayPropagation is enabled", () => { + Config.set("overlayPropagation", { enable: true }); + const addSpy = vi.spyOn(window, "addEventListener"); + + new LivePreviewEditButton(); + + const mousemoveCalls = addSpy.mock.calls.filter( + ([event]) => event === "mousemove" + ); + expect(mousemoveCalls).toHaveLength(1); + }); + + test("removes mousemove listener on destroy", () => { + Config.set("overlayPropagation", { enable: true }); + const removeSpy = vi.spyOn(window, "removeEventListener"); + + const btn = new LivePreviewEditButton(); + btn.destroy(); + + const mousemoveCalls = removeSpy.mock.calls.filter( + ([event]) => event === "mousemove" + ); + expect(mousemoveCalls).toHaveLength(1); + }); +}); diff --git a/src/livePreview/editButton/editButton.ts b/src/livePreview/editButton/editButton.ts index b3c46f9a..fbf9652c 100644 --- a/src/livePreview/editButton/editButton.ts +++ b/src/livePreview/editButton/editButton.ts @@ -1,3 +1,4 @@ +import { throttle } from "lodash-es"; import { effect } from "@preact/signals"; import { inIframe, isOpeningInNewTab } from "../../common/inIframe"; import Config from "../../configManager/configManager"; @@ -281,6 +282,7 @@ export class LivePreviewEditButton { singular: null, multiple: null, }; + private overlayMouseMoveHandler: ((e: MouseEvent) => void) | null = null; static livePreviewEditButton: LivePreviewEditButton | null = null; constructor() { @@ -297,6 +299,16 @@ export class LivePreviewEditButton { window.addEventListener("scroll", this.updateTooltipPosition); window.addEventListener("mouseover", this.addEditStyleOnHover); + if (Config.get().overlayPropagation?.enable) { + this.overlayMouseMoveHandler = throttle( + this.addEditStyleOnHover, + 100 + ); + window.addEventListener( + "mousemove", + this.overlayMouseMoveHandler + ); + } } } @@ -562,6 +574,13 @@ export class LivePreviewEditButton { destroy(): void { window.removeEventListener("scroll", this.updateTooltipPosition); window.removeEventListener("mouseover", this.addEditStyleOnHover); + if (this.overlayMouseMoveHandler) { + window.removeEventListener( + "mousemove", + this.overlayMouseMoveHandler + ); + this.overlayMouseMoveHandler = null; + } this.tooltip?.remove(); } } diff --git a/src/livePreview/live-preview.ts b/src/livePreview/live-preview.ts index 243f9272..b13d7ede 100644 --- a/src/livePreview/live-preview.ts +++ b/src/livePreview/live-preview.ts @@ -56,10 +56,14 @@ export default class LivePreview { // render the hover outline only when edit button enable if ( - !isOpeningInTimeline() && + !isOpeningInTimeline() && (config.editButton.enable || config.mode >= ILivePreviewModeConfig.BUILDER) ) { + // Destroy any instance created by the module-load effect before + // config was applied, so this init-time construction gets the + // correct config (including overlayPropagation). + LivePreviewEditButton.livePreviewEditButton?.destroy(); LivePreviewEditButton.livePreviewEditButton = new LivePreviewEditButton(); } From 81196845e2c01b3ca023ddae575cb3d67d21bd4f Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 16:20:32 +0530 Subject: [PATCH 09/16] feat(VB-1583): suppress hover and click interactions for multiple custom field instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat multiple custom field instances as non-interactive elements — no hover outline, no custom cursor, no focus overlay or toolbar on click. Uses cached schema check (hasFieldSchema guard) to avoid blocking UI on cold cache. Co-Authored-By: Claude Sonnet 4.6 --- .../listeners/__test__/mouseClick.test.ts | 193 ++++++++++++++++++ .../listeners/__test__/mouseHover.test.ts | 55 +++++ src/visualBuilder/listeners/mouseClick.ts | 11 + src/visualBuilder/listeners/mouseHover.ts | 10 + 4 files changed, 269 insertions(+) create mode 100644 src/visualBuilder/listeners/__test__/mouseClick.test.ts diff --git a/src/visualBuilder/listeners/__test__/mouseClick.test.ts b/src/visualBuilder/listeners/__test__/mouseClick.test.ts new file mode 100644 index 00000000..952cabdb --- /dev/null +++ b/src/visualBuilder/listeners/__test__/mouseClick.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import handleBuilderInteraction from "../mouseClick"; +import { VisualBuilder } from "../../index"; +import { FieldSchemaMap } from "../../utils/fieldSchemaMap"; + +vi.mock("../../utils/handleIndividualFields", () => ({ + handleIndividualFields: vi.fn().mockResolvedValue(undefined), + cleanIndividualFieldResidual: vi.fn(), +})); + +vi.mock("../../utils/getCsDataOfElement", () => ({ + getCsDataOfElement: vi.fn(), + getDOMEditStack: vi.fn().mockReturnValue([]), +})); + +vi.mock("../../cslp", () => ({ + isValidCslp: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../generators/generateToolbar", () => ({ + appendFocusedToolbar: vi.fn(), + removeFieldToolbar: vi.fn(), +})); + +vi.mock("../../generators/generateOverlay", () => ({ + addFocusOverlay: vi.fn(), + hideOverlay: vi.fn(), +})); + +vi.mock("../../utils/visualBuilderPostMessage", () => ({ + default: { send: vi.fn().mockResolvedValue(undefined) }, +})); + +vi.mock("../../utils/types/postMessage.types", () => ({ + VisualBuilderPostMessageEvents: { MOUSE_CLICK: "MOUSE_CLICK", FOCUS_FIELD: "FOCUS_FIELD" }, +})); + +vi.mock("../../index", () => ({ + VisualBuilder: { + VisualBuilderGlobalState: { + value: { + previousSelectedEditableDOM: null, + previousHoveredTargetDOM: null, + isFocussed: false, + focusElementObserver: null, + }, + }, + }, +})); + +vi.mock("../../utils/fieldSchemaMap", () => ({ + FieldSchemaMap: { + getFieldSchema: vi.fn().mockResolvedValue({ data_type: "text", field_metadata: {} }), + hasFieldSchema: vi.fn().mockReturnValue(true), + }, +})); + +vi.mock("../../utils/isFieldDisabled", () => ({ + isFieldDisabled: vi.fn().mockReturnValue({ isDisabled: false }), +})); + +vi.mock("../../generators/generateHighlightedComment", () => ({ + toggleHighlightedCommentIconDisplay: vi.fn(), +})); + +vi.mock("../../..", () => ({ + VB_EmptyBlockParentClass: "vb-empty-block-parent", +})); + +vi.mock("../../components/FieldRevert/FieldRevertComponent", () => ({ + getFieldVariantStatus: vi.fn().mockResolvedValue(null), +})); + +vi.mock("get-xpath", () => ({ default: vi.fn().mockReturnValue("/div") })); + +vi.mock("../../configManager/configManager", () => ({ + default: { + get: vi.fn().mockReturnValue({ + collab: { enable: false, isFeedbackMode: false, pauseFeedback: false }, + }), + set: vi.fn(), + }, +})); + +vi.mock("../../generators/generateThread", () => ({ + generateThread: vi.fn(), + isCollabThread: vi.fn().mockReturnValue(false), + toggleCollabPopup: vi.fn(), +})); + +vi.mock("../../utils/collabUtils", () => ({ + fixSvgXPath: vi.fn((x) => x), +})); + +vi.mock("uuid", () => ({ v4: vi.fn().mockReturnValue("test-uuid") })); + +vi.mock("../../utils/fetchEntryPermissionsAndStageDetails", () => ({ + fetchEntryPermissionsAndStageDetails: vi.fn().mockResolvedValue({ + acl: { update: { create: true, read: true, update: true, delete: true, publish: true } }, + workflowStage: { stage: undefined, permissions: { entry: { update: true } } }, + resolvedVariantPermissions: { update: true }, + }), +})); + +vi.mock("../../utils/isCustomFieldMultipleInstance", () => ({ + isCustomFieldMultipleInstance: vi.fn().mockReturnValue(false), +})); + +const { getCsDataOfElement } = await import("../../utils/getCsDataOfElement"); +const { addFocusOverlay } = await import("../../generators/generateOverlay"); +const { handleIndividualFields } = await import("../../utils/handleIndividualFields"); +const { isCustomFieldMultipleInstance } = await import("../../utils/isCustomFieldMultipleInstance"); + +function makeEditableElement(): HTMLElement { + const el = document.createElement("div"); + el.setAttribute("data-cslp", "ct.entry1.en-us.field.0"); + document.body.appendChild(el); + return el; +} + +function makeEventDetails(editableElement: HTMLElement) { + return { + editableElement, + fieldMetadata: { + entry_uid: "entry1", + content_type_uid: "ct", + locale: "en-us", + fieldPath: "field", + fieldPathWithIndex: "field.0", + cslpValue: "ct.entry1.en-us.field.0", + variant: undefined, + instance: { fieldPathWithIndex: "field.0" }, + multipleFieldMetadata: { index: 0, parentDetails: null }, + }, + }; +} + +function makeParams(editableElement: HTMLElement) { + const event = new MouseEvent("click") as MouseEvent & { altKey: boolean }; + Object.defineProperty(event, "target", { value: editableElement, writable: false }); + return { + event, + overlayWrapper: document.createElement("div"), + visualBuilderContainer: document.createElement("div"), + focusedToolbar: document.createElement("div"), + resizeObserver: new ResizeObserver(() => {}), + }; +} + +describe("handleBuilderInteraction — custom field multiple instance suppression", () => { + let editableElement: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + editableElement = makeEditableElement(); + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = null; + VisualBuilder.VisualBuilderGlobalState.value.isFocussed = false; + vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(true); + vi.mocked(FieldSchemaMap.getFieldSchema).mockResolvedValue({ data_type: "text", field_metadata: {} } as any); + }); + + it("returns early without overlay or toolbar for custom field multiple instance", async () => { + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); + vi.mocked(getCsDataOfElement).mockReturnValue(makeEventDetails(editableElement) as any); + + await handleBuilderInteraction(makeParams(editableElement)); + + expect(addFocusOverlay).not.toHaveBeenCalled(); + expect(handleIndividualFields).not.toHaveBeenCalled(); + expect(VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM).toBeNull(); + }); + + it("proceeds normally for whole custom field (isCustomFieldMultipleInstance returns false)", async () => { + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(false); + vi.mocked(getCsDataOfElement).mockReturnValue(makeEventDetails(editableElement) as any); + + await handleBuilderInteraction(makeParams(editableElement)); + + expect(addFocusOverlay).toHaveBeenCalled(); + }); + + it("does not suppress when schema not yet cached (hasFieldSchema returns false)", async () => { + vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(false); + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); + vi.mocked(getCsDataOfElement).mockReturnValue(makeEventDetails(editableElement) as any); + + await handleBuilderInteraction(makeParams(editableElement)); + + expect(isCustomFieldMultipleInstance).not.toHaveBeenCalled(); + expect(addFocusOverlay).toHaveBeenCalled(); + }); +}); diff --git a/src/visualBuilder/listeners/__test__/mouseHover.test.ts b/src/visualBuilder/listeners/__test__/mouseHover.test.ts index deb514b5..55ea5628 100644 --- a/src/visualBuilder/listeners/__test__/mouseHover.test.ts +++ b/src/visualBuilder/listeners/__test__/mouseHover.test.ts @@ -82,11 +82,18 @@ vi.mock("../../utils/getFieldType", () => ({ getFieldType: vi.fn().mockReturnValue("singleline"), })); +vi.mock("../../utils/isCustomFieldMultipleInstance", () => ({ + isCustomFieldMultipleInstance: vi.fn().mockReturnValue(false), +})); + const { getCsDataOfElement } = await import("../../utils/getCsDataOfElement"); const mockedGetCsDataOfElement = vi.mocked(getCsDataOfElement); const mockedFetchEntryPermissions = vi.mocked( fetchEntryPermissionsModule.fetchEntryPermissionsAndStageDetails ); +const { addHoverOutline } = await import("../../generators/generateHoverOutline"); +const { generateCustomCursor } = await import("../../generators/generateCustomCursor"); +const { isCustomFieldMultipleInstance } = await import("../../utils/isCustomFieldMultipleInstance"); function makeElement(): HTMLElement { const el = document.createElement("div"); @@ -120,6 +127,54 @@ function makeParams(editableElement: HTMLElement, customCursor: HTMLDivElement) }; } +describe("mouseHover — custom field multiple instance suppression", () => { + let editableElement: HTMLElement; + let customCursor: HTMLDivElement; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + editableElement = makeElement(); + customCursor = document.createElement("div"); + VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM = null; + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = null; + VisualBuilder.VisualBuilderGlobalState.value.isFocussed = false; + vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(true); + }); + + it("resets cursor and skips outline for custom field multiple instance", async () => { + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); + mockedGetCsDataOfElement.mockReturnValue(makeEventDetails(editableElement) as any); + + await handleMouseHover(makeParams(editableElement, customCursor)); + + expect(addHoverOutline).not.toHaveBeenCalled(); + expect(vi.mocked(generateCustomCursor)).toHaveBeenCalledWith( + expect.objectContaining({ fieldType: "empty" }) + ); + }); + + it("shows outline normally when isCustomFieldMultipleInstance returns false", async () => { + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(false); + mockedGetCsDataOfElement.mockReturnValue(makeEventDetails(editableElement) as any); + + await handleMouseHover(makeParams(editableElement, customCursor)); + + expect(addHoverOutline).toHaveBeenCalled(); + }); + + it("does not suppress when schema is not yet cached (hasFieldSchema returns false)", async () => { + vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(false); + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); + mockedGetCsDataOfElement.mockReturnValue(makeEventDetails(editableElement) as any); + + await handleMouseHover(makeParams(editableElement, customCursor)); + + expect(isCustomFieldMultipleInstance).not.toHaveBeenCalled(); + expect(addHoverOutline).toHaveBeenCalled(); + }); +}); + describe("mouseHover — generateCursor same-element guard", () => { let editableElement: HTMLElement; let customCursor: HTMLDivElement; diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts index c78bd820..d7f774ed 100644 --- a/src/visualBuilder/listeners/mouseClick.ts +++ b/src/visualBuilder/listeners/mouseClick.ts @@ -33,6 +33,7 @@ import { fixSvgXPath } from "../utils/collabUtils"; import { v4 as uuidV4 } from "uuid"; import { CslpData } from "../../cslp/types/cslp.types"; import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; +import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; export type HandleBuilderInteractionParams = Omit< EventListenerHandlerParams, @@ -176,6 +177,16 @@ export async function handleBuilderInteraction( } const { editableElement, fieldMetadata } = eventDetails; + + // Suppress click interaction for multiple custom field instances (cached schema only) + const { content_type_uid, fieldPath } = fieldMetadata; + if (FieldSchemaMap.hasFieldSchema(content_type_uid, fieldPath)) { + const fieldSchemaForCheck = await FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath); + if (fieldSchemaForCheck && isCustomFieldMultipleInstance(fieldSchemaForCheck, fieldMetadata)) { + return; + } + } + const variantStatus = await getFieldVariantStatus(fieldMetadata); const isVariant = variantStatus ? Object.values(variantStatus).some((value) => value === true) diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts index bf717b2c..98c431e7 100644 --- a/src/visualBuilder/listeners/mouseHover.ts +++ b/src/visualBuilder/listeners/mouseHover.ts @@ -18,6 +18,7 @@ import { appendFieldPathDropdown } from "../generators/generateToolbar"; import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import { CslpData } from "../../cslp/types/cslp.types"; import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; +import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; const config = Config.get(); export interface HandleMouseHoverParams @@ -261,6 +262,15 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { const { editableElement, fieldMetadata } = eventDetails; const { content_type_uid, fieldPath } = fieldMetadata; + if (FieldSchemaMap.hasFieldSchema(content_type_uid, fieldPath)) { + const fieldSchema = await FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath); + if (fieldSchema && isCustomFieldMultipleInstance(fieldSchema, fieldMetadata)) { + resetCustomCursor(params.customCursor); + handleCursorPosition(params.event, params.customCursor); + return; + } + } + if ( VisualBuilder.VisualBuilderGlobalState.value .previousSelectedEditableDOM && From 55532b22a7c1a9ba47538a23945c3b25a0c83fdd Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 16:26:46 +0530 Subject: [PATCH 10/16] fix(editButton): increase throttle delay for overlay mouse move handler --- src/livePreview/editButton/editButton.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/livePreview/editButton/editButton.ts b/src/livePreview/editButton/editButton.ts index fbf9652c..e3c8303d 100644 --- a/src/livePreview/editButton/editButton.ts +++ b/src/livePreview/editButton/editButton.ts @@ -302,7 +302,7 @@ export class LivePreviewEditButton { if (Config.get().overlayPropagation?.enable) { this.overlayMouseMoveHandler = throttle( this.addEditStyleOnHover, - 100 + 200 ); window.addEventListener( "mousemove", From f0219d72b31c8e06b2a93185a20cde791a157667 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 16:38:59 +0530 Subject: [PATCH 11/16] fix(isCustomFieldMultipleInstance): handle optional chaining for instance fieldPathWithIndex --- .../__test__/handleUserConfig.test.ts | 27 +++++++++++++++++++ src/configManager/handleUserConfig.ts | 1 + .../utils/isCustomFieldMultipleInstance.ts | 7 ++--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/configManager/__test__/handleUserConfig.test.ts b/src/configManager/__test__/handleUserConfig.test.ts index 08072c39..b4cda152 100644 --- a/src/configManager/__test__/handleUserConfig.test.ts +++ b/src/configManager/__test__/handleUserConfig.test.ts @@ -444,6 +444,33 @@ describe("handleInitData()", () => { handleInitData(initData); expect(config.overlayPropagation.enable).toBe(false); }); + + test("should set overlayPropagation.enable from stackSdk when initData does not provide it", () => { + const initData: Partial = { + stackSdk: { + live_preview: { + overlayPropagation: { enable: true }, + }, + } as any, + }; + + handleInitData(initData); + expect(config.overlayPropagation.enable).toBe(true); + }); + + test("initData overlayPropagation takes precedence over stackSdk", () => { + const initData: Partial = { + overlayPropagation: { enable: false }, + stackSdk: { + live_preview: { + overlayPropagation: { enable: true }, + }, + } as any, + }; + + handleInitData(initData); + expect(config.overlayPropagation.enable).toBe(false); + }); }); }); diff --git a/src/configManager/handleUserConfig.ts b/src/configManager/handleUserConfig.ts index 650bca5d..8f819a3a 100644 --- a/src/configManager/handleUserConfig.ts +++ b/src/configManager/handleUserConfig.ts @@ -124,6 +124,7 @@ export const handleInitData = (initData: Partial): void => { Config.set("overlayPropagation", { enable: initData.overlayPropagation?.enable ?? + stackSdk.live_preview?.overlayPropagation?.enable ?? config.overlayPropagation.enable, }); diff --git a/src/visualBuilder/utils/isCustomFieldMultipleInstance.ts b/src/visualBuilder/utils/isCustomFieldMultipleInstance.ts index 8f89210b..26b32dcf 100644 --- a/src/visualBuilder/utils/isCustomFieldMultipleInstance.ts +++ b/src/visualBuilder/utils/isCustomFieldMultipleInstance.ts @@ -4,13 +4,14 @@ import { getFieldType } from "./getFieldType"; import { isFieldMultiple } from "./isFieldMultiple"; export function isCustomFieldMultipleInstance( - fieldSchema: ISchemaFieldMap, - fieldMetadata: CslpData + fieldSchema: ISchemaFieldMap | null | undefined, + fieldMetadata: CslpData | null | undefined ): boolean { + if (!fieldSchema || !fieldMetadata) return false; return ( getFieldType(fieldSchema) === FieldDataType.CUSTOM_FIELD && isFieldMultiple(fieldSchema) && - fieldMetadata.fieldPathWithIndex !== fieldMetadata.instance.fieldPathWithIndex && + fieldMetadata.fieldPathWithIndex !== fieldMetadata.instance?.fieldPathWithIndex && (fieldMetadata.multipleFieldMetadata?.index ?? -1) !== -1 ); } From e88aee3602a03e093751178bc10c5ce85e694e20 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 16:45:39 +0530 Subject: [PATCH 12/16] docs: clarify overlayPropagation functionality for hover/click detection --- docs/live-preview-configs.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/live-preview-configs.md b/docs/live-preview-configs.md index 9e554824..1b80c772 100644 --- a/docs/live-preview-configs.md +++ b/docs/live-preview-configs.md @@ -147,14 +147,11 @@ The editInVisualBuilderButton object contains two keys: ### `overlayPropagation` -The `overlayPropagation` object enables hover/click detection to pierce through sibling elements that visually overlap a `data-cslp` field but intercept the mouse event before it reaches the field. This is an opt-in fallback intended for apps where unrelated DOM elements (for example, empty CSS-grid spacer cells in a multi-column layout) sit on top of `data-cslp` containers and block the SDK from detecting the field on hover or click. +Use this option when hovering or clicking on a field in your app does nothing — no outline appears and the Edit button doesn't show up. This typically happens when another element (such as a navigation overlay, image layer, or layout spacer) sits on top of the field and captures the mouse event instead. -When the flag is **enabled** and the standard event path contains no `data-cslp` element, the SDK falls back to `document.elementsFromPoint(clientX, clientY)` and selects the topmost element in that stack that carries a `data-cslp` attribute. When the flag is **disabled** (default), behavior is unchanged. +Enabling `overlayPropagation` tells the SDK to look through stacked elements at the cursor position to find the field underneath. It works in both Visual Builder and the standalone Live Preview Edit button. -This flag covers both pipelines: - -- **Visual Builder** — hover outline and click-to-focus detection (`getCsDataOfElement`) -- **Standalone Live Preview Edit button** — the floating Edit button outside Visual Builder (`addCslpOutline`); a companion throttled `mousemove` listener ensures the button tracks the cursor while it moves within a blocking overlay +> **When to enable:** Only turn this on if you notice fields that are invisible to hover/click in your specific app layout. It is off by default. The `overlayPropagation` object contains one key: @@ -163,7 +160,7 @@ The `overlayPropagation` object contains one key: | ------- | ------- | -------- | | boolean | false | yes | - Set to `true` to activate the `elementsFromPoint` fallback for hover and click detection. The fallback runs only when the standard `closest("[data-cslp]")` lookup returns `null`, so there is no performance impact on the normal path. + Set to `true` to allow the SDK to detect fields that are visually covered by other elements. **For example:** ```ts From 15f10ed90ad0b95759833d549659cc12ab38ffc6 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Thu, 21 May 2026 17:25:23 +0530 Subject: [PATCH 13/16] refactor(VB-1583): redirect custom field instances to whole-field parent via closest() - Extract getParentCslp and getWholeFieldElement utils to derive parent CSLP and locate DOM ancestor without global querySelector - Handles V1 and V2 CSLP formats by stripping the trailing index segment from fieldMetadata.cslpValue instead of constructing CSLP from parts - Use closest() to find nearest matching ancestor, preventing wrong element dispatch when same CSLP appears multiple times in the page - Log debug message when whole-field parent is not found in DOM - Add unit tests for getWholeFieldElement utility Co-Authored-By: Claude Sonnet 4.6 --- .../listeners/__test__/mouseClick.test.ts | 23 +++++++- .../listeners/__test__/mouseHover.test.ts | 27 ++++++++-- src/visualBuilder/listeners/mouseClick.ts | 20 ++++++- src/visualBuilder/listeners/mouseHover.ts | 24 ++++++++- .../__test__/getWholeFieldElement.test.ts | 52 +++++++++++++++++++ .../utils/getWholeFieldElement.ts | 13 +++++ 6 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 src/visualBuilder/utils/__test__/getWholeFieldElement.test.ts create mode 100644 src/visualBuilder/utils/getWholeFieldElement.ts diff --git a/src/visualBuilder/listeners/__test__/mouseClick.test.ts b/src/visualBuilder/listeners/__test__/mouseClick.test.ts index 952cabdb..c203cb8c 100644 --- a/src/visualBuilder/listeners/__test__/mouseClick.test.ts +++ b/src/visualBuilder/listeners/__test__/mouseClick.test.ts @@ -160,14 +160,33 @@ describe("handleBuilderInteraction — custom field multiple instance suppressio vi.mocked(FieldSchemaMap.getFieldSchema).mockResolvedValue({ data_type: "text", field_metadata: {} } as any); }); - it("returns early without overlay or toolbar for custom field multiple instance", async () => { + it("redirects click to whole-field parent for custom field multiple instance", async () => { vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); vi.mocked(getCsDataOfElement).mockReturnValue(makeEventDetails(editableElement) as any); + // nest instance inside whole-field so closest() traversal finds the parent + const wholeFieldEl = document.createElement("div"); + wholeFieldEl.setAttribute("data-cslp", "ct.entry1.en-us.field"); + wholeFieldEl.appendChild(editableElement); + document.body.appendChild(wholeFieldEl); + + const dispatchSpy = vi.spyOn(wholeFieldEl, "dispatchEvent"); + + await handleBuilderInteraction(makeParams(editableElement)); + + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(MouseEvent)); + expect(addFocusOverlay).not.toHaveBeenCalled(); + expect(VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM).toBeNull(); + }); + + it("returns early without redirect when whole-field element not found in DOM", async () => { + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); + vi.mocked(getCsDataOfElement).mockReturnValue(makeEventDetails(editableElement) as any); + // no whole-field element in DOM + await handleBuilderInteraction(makeParams(editableElement)); expect(addFocusOverlay).not.toHaveBeenCalled(); - expect(handleIndividualFields).not.toHaveBeenCalled(); expect(VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM).toBeNull(); }); diff --git a/src/visualBuilder/listeners/__test__/mouseHover.test.ts b/src/visualBuilder/listeners/__test__/mouseHover.test.ts index 55ea5628..9f2ac67d 100644 --- a/src/visualBuilder/listeners/__test__/mouseHover.test.ts +++ b/src/visualBuilder/listeners/__test__/mouseHover.test.ts @@ -102,7 +102,7 @@ function makeElement(): HTMLElement { return el; } -function makeEventDetails(editableElement: HTMLElement) { +function makeEventDetails(editableElement: HTMLElement, cslpValue = "all_fields.entry1.en-us.title") { return { editableElement, fieldMetadata: { @@ -111,6 +111,7 @@ function makeEventDetails(editableElement: HTMLElement) { locale: "en-us", fieldPath: "title", fieldPathWithIndex: "title", + cslpValue, variant: undefined, }, }; @@ -142,9 +143,29 @@ describe("mouseHover — custom field multiple instance suppression", () => { vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(true); }); - it("resets cursor and skips outline for custom field multiple instance", async () => { + it("dispatches mousemove on whole-field element for custom field multiple instance", async () => { vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); - mockedGetCsDataOfElement.mockReturnValue(makeEventDetails(editableElement) as any); + editableElement.setAttribute("data-cslp", "all_fields.entry1.en-us.title.0"); + mockedGetCsDataOfElement.mockReturnValue(makeEventDetails(editableElement, "all_fields.entry1.en-us.title.0") as any); + + // nest instance inside whole-field so closest() traversal finds the parent + const wholeFieldEl = document.createElement("div"); + wholeFieldEl.setAttribute("data-cslp", "all_fields.entry1.en-us.title"); + wholeFieldEl.appendChild(editableElement); + document.body.appendChild(wholeFieldEl); + const dispatchSpy = vi.spyOn(wholeFieldEl, "dispatchEvent"); + + await handleMouseHover(makeParams(editableElement, customCursor)); + + expect(dispatchSpy).toHaveBeenCalledWith(expect.any(MouseEvent)); + expect(addHoverOutline).not.toHaveBeenCalled(); + }); + + it("resets cursor when whole-field element not found in DOM", async () => { + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); + editableElement.setAttribute("data-cslp", "all_fields.entry1.en-us.title.0"); + mockedGetCsDataOfElement.mockReturnValue(makeEventDetails(editableElement, "all_fields.entry1.en-us.title.0") as any); + // no whole-field ancestor in DOM — closest() returns null await handleMouseHover(makeParams(editableElement, customCursor)); diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts index d7f774ed..2f34550f 100644 --- a/src/visualBuilder/listeners/mouseClick.ts +++ b/src/visualBuilder/listeners/mouseClick.ts @@ -34,6 +34,7 @@ import { v4 as uuidV4 } from "uuid"; import { CslpData } from "../../cslp/types/cslp.types"; import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; +import { getParentCslp, getWholeFieldElement } from "../utils/getWholeFieldElement"; export type HandleBuilderInteractionParams = Omit< EventListenerHandlerParams, @@ -178,11 +179,28 @@ export async function handleBuilderInteraction( const { editableElement, fieldMetadata } = eventDetails; - // Suppress click interaction for multiple custom field instances (cached schema only) + // Redirect click on multiple custom field instance to its whole-field parent (cached schema only) const { content_type_uid, fieldPath } = fieldMetadata; if (FieldSchemaMap.hasFieldSchema(content_type_uid, fieldPath)) { const fieldSchemaForCheck = await FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath); if (fieldSchemaForCheck && isCustomFieldMultipleInstance(fieldSchemaForCheck, fieldMetadata)) { + const parentCslp = getParentCslp(fieldMetadata.cslpValue); + const wholeFieldElement = getWholeFieldElement(editableElement, parentCslp); + if (wholeFieldElement) { + wholeFieldElement.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + clientX: params.event.clientX, + clientY: params.event.clientY, + }) + ); + } else if (config.debug) { + console.debug( + "[Visual Builder] Custom field multiple instance: whole-field parent not found in DOM for CSLP", + parentCslp + ); + } return; } } diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts index 98c431e7..5c1ec43e 100644 --- a/src/visualBuilder/listeners/mouseHover.ts +++ b/src/visualBuilder/listeners/mouseHover.ts @@ -19,6 +19,7 @@ import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import { CslpData } from "../../cslp/types/cslp.types"; import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; +import { getParentCslp, getWholeFieldElement } from "../utils/getWholeFieldElement"; const config = Config.get(); export interface HandleMouseHoverParams @@ -265,8 +266,27 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { if (FieldSchemaMap.hasFieldSchema(content_type_uid, fieldPath)) { const fieldSchema = await FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath); if (fieldSchema && isCustomFieldMultipleInstance(fieldSchema, fieldMetadata)) { - resetCustomCursor(params.customCursor); - handleCursorPosition(params.event, params.customCursor); + const parentCslp = getParentCslp(fieldMetadata.cslpValue); + const wholeFieldElement = getWholeFieldElement(editableElement, parentCslp); + if (wholeFieldElement) { + wholeFieldElement.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + cancelable: true, + clientX: params.event.clientX, + clientY: params.event.clientY, + }) + ); + } else { + if (config.debug) { + console.debug( + "[Visual Builder] Custom field multiple instance: whole-field parent not found in DOM for CSLP", + parentCslp + ); + } + resetCustomCursor(params.customCursor); + handleCursorPosition(params.event, params.customCursor); + } return; } } diff --git a/src/visualBuilder/utils/__test__/getWholeFieldElement.test.ts b/src/visualBuilder/utils/__test__/getWholeFieldElement.test.ts new file mode 100644 index 00000000..f543a3bf --- /dev/null +++ b/src/visualBuilder/utils/__test__/getWholeFieldElement.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getParentCslp, getWholeFieldElement } from "../getWholeFieldElement"; + +describe("getParentCslp", () => { + it("strips the trailing index from a V1 CSLP", () => { + expect(getParentCslp("ct.entry.en-us.field.0")).toBe("ct.entry.en-us.field"); + }); + + it("strips the trailing index from a V2 CSLP", () => { + expect(getParentCslp("v2:ct.entry_variant.en-us.field.0")).toBe( + "v2:ct.entry_variant.en-us.field" + ); + }); + + it("handles nested field paths", () => { + expect(getParentCslp("ct.entry.en-us.group.subfield.2")).toBe( + "ct.entry.en-us.group.subfield" + ); + }); +}); + +describe("getWholeFieldElement", () => { + let instance: HTMLElement; + let parent: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ""; + parent = document.createElement("div"); + parent.setAttribute("data-cslp", "ct.entry.en-us.field"); + instance = document.createElement("div"); + instance.setAttribute("data-cslp", "ct.entry.en-us.field.0"); + parent.appendChild(instance); + document.body.appendChild(parent); + }); + + it("returns the ancestor element matching parentCslp", () => { + expect(getWholeFieldElement(instance, "ct.entry.en-us.field")).toBe(parent); + }); + + it("returns null when no ancestor matches", () => { + expect(getWholeFieldElement(instance, "ct.entry.en-us.other")).toBeNull(); + }); + + it("does not return a sibling — only traverses upward", () => { + const sibling = document.createElement("div"); + sibling.setAttribute("data-cslp", "ct.entry.en-us.field"); + document.body.appendChild(sibling); + + // instance's ancestor is parent, not the sibling appended to body + expect(getWholeFieldElement(instance, "ct.entry.en-us.field")).toBe(parent); + }); +}); diff --git a/src/visualBuilder/utils/getWholeFieldElement.ts b/src/visualBuilder/utils/getWholeFieldElement.ts new file mode 100644 index 00000000..9ae2bd4c --- /dev/null +++ b/src/visualBuilder/utils/getWholeFieldElement.ts @@ -0,0 +1,13 @@ +// Strips the trailing index segment to derive the whole-field CSLP. +// Works for both V1 (ct.entry.locale.field.0) and V2 (v2:ct.entry.locale.field.0) formats. +export function getParentCslp(cslpValue: string): string { + return cslpValue.split(".").slice(0, -1).join("."); +} + +// Finds the nearest ancestor whose data-cslp matches parentCslp via DOM traversal. +export function getWholeFieldElement( + instanceElement: Element, + parentCslp: string +): Element | null { + return instanceElement.closest(`[data-cslp="${parentCslp}"]`); +} From 6a64cc429a3fd063a3434f54e7c51c6255f520a5 Mon Sep 17 00:00:00 2001 From: Mridul Sharma Date: Fri, 22 May 2026 15:57:20 +0530 Subject: [PATCH 14/16] fix: added changes of new tab init related remaining code --- src/common/__test__/inIframe.test.ts | 41 ++++++++++++++++++- .../__test__/postMessageEvent.hooks.test.ts | 21 ++++++++++ .../eventManager/postMessageEvent.hooks.ts | 10 +++-- .../contentstack-live-preview-HOC.test.ts | 13 ++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/common/__test__/inIframe.test.ts b/src/common/__test__/inIframe.test.ts index c13f28a6..7b0fecc5 100644 --- a/src/common/__test__/inIframe.test.ts +++ b/src/common/__test__/inIframe.test.ts @@ -1,4 +1,7 @@ -import { inIframe } from "../inIframe"; +import { inIframe, isOpeningInNewTab } from "../inIframe"; +import { hasWindow } from "../../utils"; + +vi.mock("../../utils", () => ({ hasWindow: vi.fn() })); describe("inIframe", () => { let windowSpy: any; @@ -39,3 +42,39 @@ describe("inIframe", () => { expect(inIframe()).toBe(true); }); }); + +describe("isOpeningInNewTab", () => { + let windowSpy: any; + + beforeEach(() => { + vi.mocked(hasWindow).mockReturnValue(true); + windowSpy = vi.spyOn(window, "window", "get"); + }); + + afterEach(() => { + windowSpy.mockRestore(); + vi.mocked(hasWindow).mockReset(); + }); + + test("should return true when window.opener is truthy", () => { + windowSpy.mockReturnValue({ opener: {} }); + expect(isOpeningInNewTab()).toBe(true); + }); + + test("should return false when window.opener is null", () => { + windowSpy.mockReturnValue({ opener: null }); + expect(isOpeningInNewTab()).toBe(false); + }); + + test("should return false when hasWindow returns false", () => { + vi.mocked(hasWindow).mockReturnValue(false); + expect(isOpeningInNewTab()).toBe(false); + }); + + test("should return false when accessing window throws", () => { + windowSpy.mockImplementation(() => { + throw new Error("Test error"); + }); + expect(isOpeningInNewTab()).toBe(false); + }); +}); diff --git a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts index 89b52885..4970b6e3 100644 --- a/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts +++ b/src/livePreview/eventManager/__test__/postMessageEvent.hooks.test.ts @@ -266,6 +266,26 @@ describe("postMessageEvent.hooks", () => { expect(redirectUrl.searchParams.get("content_type_uid")).toBe("blog"); expect(redirectUrl.searchParams.has("entry_uid")).toBe(false); }); + + it("should use stackDetails.contentTypeUid and entryUid as fallback when event data omits them", () => { + mockConfig.ssr = true; + mockConfig.stackDetails = { contentTypeUid: "fallback-ct", entryUid: "fallback-entry" }; + (Config.get as any).mockReturnValue(mockConfig); + mockWindow.location.href = "https://example.com"; + + const eventData: OnChangeLivePreviewPostMessageEventData = { + hash: "h", + }; + + const callback = mockWindow._eventCallbacks[LIVE_PREVIEW_POST_MESSAGE_EVENTS.ON_CHANGE]; + callback({ data: eventData }); + + const redirectUrl = new URL(mockWindow.location.href); + expect(redirectUrl.searchParams.get("live_preview")).toBe("h"); + expect(redirectUrl.searchParams.get("content_type_uid")).toBe("fallback-ct"); + expect(redirectUrl.searchParams.get("entry_uid")).toBe("fallback-entry"); + expect(mockWindow.location.reload).not.toHaveBeenCalled(); + }); }); }); @@ -682,5 +702,6 @@ describe("postMessageEvent.hooks", () => { vi.useRealTimers(); } }); + }); }); \ No newline at end of file diff --git a/src/livePreview/eventManager/postMessageEvent.hooks.ts b/src/livePreview/eventManager/postMessageEvent.hooks.ts index 9e9f903d..d886fe59 100644 --- a/src/livePreview/eventManager/postMessageEvent.hooks.ts +++ b/src/livePreview/eventManager/postMessageEvent.hooks.ts @@ -78,8 +78,8 @@ export function useOnEntryUpdatePostMessageEvent(): void { window.location.reload(); } else { live_preview = event.data.hash; - content_type_uid = event.data.content_type_uid || stackDetails.$contentTypeUid?.toString() || ""; - entry_uid = event.data.entry_uid || stackDetails.$entryUid?.toString() || ""; + content_type_uid = event.data.content_type_uid || stackDetails.contentTypeUid?.toString() || ""; + entry_uid = event.data.entry_uid || stackDetails.entryUid?.toString() || ""; // Set missing params and redirect url.searchParams.set("live_preview", live_preview); if (content_type_uid) { @@ -169,7 +169,11 @@ export function sendInitializeLivePreviewPostMessageEvent(): void { // "init message did not contain contentTypeUid or entryUid." // ); } - if (Config.get().ssr || isOpeningInTimeline() || isOpeningInNewTab()) { + if ( + Config.get().ssr || + isOpeningInTimeline() || + isOpeningInNewTab() + ) { addParamsToUrl(); } Config.set("windowType", windowType); diff --git a/src/preview/__test__/contentstack-live-preview-HOC.test.ts b/src/preview/__test__/contentstack-live-preview-HOC.test.ts index fdb314e2..031b2ee1 100644 --- a/src/preview/__test__/contentstack-live-preview-HOC.test.ts +++ b/src/preview/__test__/contentstack-live-preview-HOC.test.ts @@ -120,6 +120,19 @@ describe("Live Preview HOC init", () => { "You have already initialized the Live Preview SDK. So, any subsequent initialization returns the existing SDK instance." ); }); + + test("should not send INIT postMessage when enable is false", () => { + if (!livePreviewPostMessage) { + throw new Error("livePreviewPostMessage is unavailable"); + } + + const livePreviewPostMessageSpy = vi.spyOn(livePreviewPostMessage, "send"); + + // no await needed — LivePreview constructor runs synchronously when document.readyState is "complete" in jsdom + ContentstackLivePreview.init({ enable: false }); + + expect(livePreviewPostMessageSpy).not.toHaveBeenCalled(); + }); }); describe("Live Preview HOC config", () => { From c9fb3e9196a2097a7ca9f6061d0666c0b5a1c5c9 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Fri, 29 May 2026 12:08:02 +0530 Subject: [PATCH 15/16] 4.4.3 --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++++++++++++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fce59c29..70feb7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,55 @@ # Changelog -## [v4.4.2](https://github.com/contentstack/live-preview-sdk/compare/v4.4.2...v4.4.2) +## [v4.4.3](https://github.com/contentstack/live-preview-sdk/compare/v4.4.2...v4.4.3) + +> 29 May 2026 + +### New Features + +- feat(VB-1583): restrict toolbar actions for multiple custom field instances (Hitesh Shetty - [#593](https://github.com/contentstack/live-preview-sdk/pull/593)) + +### Fixes + +- fix: added changes of new tab init related remaining code (Mridul Sharma - [#597](https://github.com/contentstack/live-preview-sdk/pull/597)) + +### Changes to Test Assests + +- test: added test cases of useCollab / preview share (Mridul Sharma - [#595](https://github.com/contentstack/live-preview-sdk/pull/595)) +- test: added unit test cases fo visual builder specific to mode (Mridul Sharma - [#592](https://github.com/contentstack/live-preview-sdk/pull/592)) +- test: added test cases for post message hook (Mridul Sharma - [#591](https://github.com/contentstack/live-preview-sdk/pull/591)) + +### General Changes + +- release: 4.4.2 (Hitesh Shetty - [#596](https://github.com/contentstack/live-preview-sdk/pull/596)) + +### New Features + +- feat(VB-1583): suppress hover and click interactions for multiple custom field instances (hitesh-shetty-cstk - [8119684](https://github.com/contentstack/live-preview-sdk/commit/81196845e2c01b3ca023ddae575cb3d67d21bd4f)) +- feat(VB-1623): extend overlayPropagation to standalone Live Preview Edit button (hitesh-shetty-cstk - [f402b41](https://github.com/contentstack/live-preview-sdk/commit/f402b41b69454a59a9a6c143b698d06d5ab1459f)) +- feat(visual-builder): add overlayPropagation init flag for cslp hover/click (hitesh-shetty-cstk - [aa57ba9](https://github.com/contentstack/live-preview-sdk/commit/aa57ba951081a2726122fa9d0fd489a65bb7bdee)) +- feat(VB-1623): extend overlayPropagation to standalone Live Preview Edit button (hitesh-shetty-cstk - [be27497](https://github.com/contentstack/live-preview-sdk/commit/be27497b69e7a08f95c5ec96ffddf11e22b35029)) + +### Fixes + +- fix(isCustomFieldMultipleInstance): handle optional chaining for instance fieldPathWithIndex (hitesh-shetty-cstk - [f0219d7](https://github.com/contentstack/live-preview-sdk/commit/f0219d72b31c8e06b2a93185a20cde791a157667)) +- fix: update CDN version to 4.4.2 in README (hitesh-shetty-cstk - [892a9c8](https://github.com/contentstack/live-preview-sdk/commit/892a9c8ba169a02830a311394a9a2a88a39d6c76)) +- fix(editButton): increase throttle delay for overlay mouse move handler (hitesh-shetty-cstk - [55532b2](https://github.com/contentstack/live-preview-sdk/commit/55532b22a7c1a9ba47538a23945c3b25a0c83fdd)) + +### Documentation Changes + +- docs: clarify overlayPropagation functionality for hover/click detection (hitesh-shetty-cstk - [e88aee3](https://github.com/contentstack/live-preview-sdk/commit/e88aee3602a03e093751178bc10c5ce85e694e20)) +- docs(main.mustache): add overlayPropagation entry to config list (hitesh-shetty-cstk - [b30f41f](https://github.com/contentstack/live-preview-sdk/commit/b30f41f6b1bb86a36bc5ea2bfcc3d76cb3061e43)) + +### Refactoring and Updates + +- refactor(VB-1583): redirect custom field instances to whole-field parent via closest() (hitesh-shetty-cstk - [15f10ed](https://github.com/contentstack/live-preview-sdk/commit/15f10ed90ad0b95759833d549659cc12ab38ffc6)) + +## [v4.4.2](https://github.com/contentstack/live-preview-sdk/compare/v4.4.1...v4.4.2) > 21 May 2026 ### Fixes -- fix: update CDN version to 4.4.2 in README (hitesh-shetty-cstk - [11bf951](https://github.com/contentstack/live-preview-sdk/commit/11bf9511a5deb7fb009ba9c01310504755a5f17c)) - fix(hover): guard generateCursor to fire only on new element hover (Hitesh Shetty - [#590](https://github.com/contentstack/live-preview-sdk/pull/590)) - fix(VB-1541): hide field extension and comment icons on update-restricted fields (Sahil Chalke - [#589](https://github.com/contentstack/live-preview-sdk/pull/589)) - fix(security): bump dompurify from ^3.4.0 to ^3.4.1 (Hitesh Shetty - [#588](https://github.com/contentstack/live-preview-sdk/pull/588)) diff --git a/package-lock.json b/package-lock.json index 07639dde..36a8b4d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/live-preview-utils", - "version": "4.4.2", + "version": "4.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/live-preview-utils", - "version": "4.4.2", + "version": "4.4.3", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.2", diff --git a/package.json b/package.json index e1ad7b39..31612674 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/live-preview-utils", - "version": "4.4.2", + "version": "4.4.3", "description": "Contentstack provides the Live Preview SDK to establish a communication channel between the various Contentstack SDKs and your website, transmitting live changes to the preview pane.", "type": "module", "types": "dist/legacy/index.d.ts", From 71214954f2a47d623ca719789b37bfae2e0f9453 Mon Sep 17 00:00:00 2001 From: hitesh-shetty-cstk Date: Fri, 29 May 2026 12:08:54 +0530 Subject: [PATCH 16/16] fix: update CDN version to 4.4.3 in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60560582..e49877ba 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,11 @@ npm install @contentstack/live-preview-utils ### Load from a CDN (advanced) -Pin the version to match your app (update `4.4.2` when you upgrade): +Pin the version to match your app (update `4.4.3` when you upgrade): ```html