({
+ FieldSchemaMap: {
+ clearContentTypeSchema: vi.fn(),
+ clear: vi.fn(),
+ },
+}));
+
+vi.mock("../../../cslp", () => ({
+ extractDetailsFromCslp: vi.fn(),
+}));
+
+vi.mock("../../generators/generateOverlay", () => ({
+ hideFocusOverlay: vi.fn(),
+}));
+
+vi.mock("../../listeners/mouseClick", () => ({
+ handleBuilderInteraction: vi.fn(),
+}));
+
+// Mock window.location.reload
+Object.defineProperty(window, "location", {
+ value: {
+ reload: vi.fn(),
+ },
+ writable: true,
+});
+
+// Mock requestAnimationFrame
+global.requestAnimationFrame = vi.fn((cb) => {
+ cb(0);
+ return 0;
+});
+
+describe("handleRevalidateFieldData", () => {
+ let visualBuilderContainer: HTMLDivElement;
+ let overlayWrapper: HTMLDivElement;
+ let focusedToolbar: HTMLDivElement;
+
+ // Create DOM elements once for all tests (optimization)
+ beforeAll(() => {
+ visualBuilderContainer = document.createElement("div");
+ visualBuilderContainer.classList.add("visual-builder__container");
+ overlayWrapper = document.createElement("div");
+ overlayWrapper.classList.add("visual-builder__overlay__wrapper");
+ focusedToolbar = document.createElement("div");
+ focusedToolbar.classList.add("visual-builder__focused-toolbar");
+
+ document.body.appendChild(visualBuilderContainer);
+ document.body.appendChild(overlayWrapper);
+ document.body.appendChild(focusedToolbar);
+ });
+
+ beforeEach(() => {
+ // Clear mocks before each test to ensure clean state
+ vi.clearAllMocks();
+
+ // Reset VisualBuilder global state
+ VisualBuilder.VisualBuilderGlobalState = {
+ // @ts-expect-error mocking only required properties
+ value: {
+ previousHoveredTargetDOM: null,
+ previousSelectedEditableDOM: null,
+ isFocussed: false,
+ },
+ };
+ });
+
+ // Clean up DOM after all tests complete
+ afterAll(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("should revalidate specific field when hovered element exists", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM =
+ mockElement;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockReturnValue({
+ content_type_uid: "test_content_type",
+ entry_uid: "test_entry",
+ locale: "en-us",
+ fieldPath: "test_field",
+ fieldPathWithIndex: "test_field",
+ } as any);
+
+ await handleRevalidateFieldData();
+
+ expect(FieldSchemaMap.clearContentTypeSchema).toHaveBeenCalledWith(
+ "test_content_type"
+ );
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
+
+ it("should fallback to focused element when no hovered element", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM =
+ null;
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ mockElement;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockReturnValue({
+ content_type_uid: "test_content_type",
+ entry_uid: "test_entry",
+ locale: "en-us",
+ fieldPath: "test_field",
+ fieldPathWithIndex: "test_field",
+ } as any);
+
+ await handleRevalidateFieldData();
+
+ expect(FieldSchemaMap.clearContentTypeSchema).toHaveBeenCalledWith(
+ "test_content_type"
+ );
+ });
+
+ it("should clear all field schema cache when no target element", async () => {
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM =
+ null;
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ null;
+
+ await handleRevalidateFieldData();
+
+ expect(FieldSchemaMap.clear).toHaveBeenCalled();
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
+
+ it("should refresh iframe when clearing cache fails", async () => {
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM =
+ null;
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ null;
+
+ vi.mocked(FieldSchemaMap.clear).mockImplementation(() => {
+ throw new Error("Cache clear failed");
+ });
+
+ await handleRevalidateFieldData();
+
+ expect(FieldSchemaMap.clear).toHaveBeenCalled();
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+
+ it("should refresh iframe when any error occurs", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM =
+ mockElement;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockImplementation(() => {
+ throw new Error("CSLP parsing failed");
+ });
+
+ await handleRevalidateFieldData();
+
+ expect(window.location.reload).toHaveBeenCalled();
+ });
+
+ it("should handle elements without data-cslp attribute", async () => {
+ const mockElement = document.createElement("div");
+ // No data-cslp attribute
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM =
+ mockElement;
+
+ // Reset the clear mock to not throw error for this test
+ vi.mocked(FieldSchemaMap.clear).mockReset();
+ vi.mocked(FieldSchemaMap.clear).mockImplementation(() => {
+ // Successful clear - no error
+ });
+
+ await handleRevalidateFieldData();
+
+ expect(FieldSchemaMap.clear).toHaveBeenCalled();
+ expect(window.location.reload).not.toHaveBeenCalled();
+ });
+
+ describe("unfocus and refocus behavior", () => {
+ let hideFocusOverlay: any;
+ let handleBuilderInteraction: any;
+
+ // Import mocked modules once for all tests in this describe block
+ beforeAll(async () => {
+ const overlayModule = await import(
+ "../../generators/generateOverlay"
+ );
+ const mouseClickModule = await import("../../listeners/mouseClick");
+ hideFocusOverlay = vi.mocked(overlayModule.hideFocusOverlay);
+ handleBuilderInteraction = vi.mocked(
+ mouseClickModule.handleBuilderInteraction
+ );
+ });
+
+ it("should unfocus element before revalidation when focused element exists", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+ mockElement.setAttribute("data-cslp-unique-id", "unique-123");
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ mockElement;
+ VisualBuilder.VisualBuilderGlobalState.value.isFocussed = true;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockReturnValue({
+ content_type_uid: "test_content_type",
+ entry_uid: "test_entry",
+ locale: "en-us",
+ fieldPath: "test_field",
+ fieldPathWithIndex: "test_field",
+ } as any);
+
+ await handleRevalidateFieldData();
+
+ // Should call hideFocusOverlay to unfocus
+ expect(hideFocusOverlay).toHaveBeenCalledWith(
+ expect.objectContaining({
+ visualBuilderContainer: expect.any(HTMLDivElement),
+ visualBuilderOverlayWrapper: expect.any(HTMLDivElement),
+ focusedToolbar: expect.any(HTMLDivElement),
+ noTrigger: true,
+ })
+ );
+
+ // Should clear global state
+ expect(
+ VisualBuilder.VisualBuilderGlobalState.value
+ .previousSelectedEditableDOM
+ ).toBeNull();
+ expect(
+ VisualBuilder.VisualBuilderGlobalState.value.isFocussed
+ ).toBe(false);
+ });
+
+ it("should refocus element after revalidation completes", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+ mockElement.setAttribute("data-cslp-unique-id", "unique-123");
+ document.body.appendChild(mockElement);
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ mockElement;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockReturnValue({
+ content_type_uid: "test_content_type",
+ entry_uid: "test_entry",
+ locale: "en-us",
+ fieldPath: "test_field",
+ fieldPathWithIndex: "test_field",
+ } as any);
+
+ await handleRevalidateFieldData();
+
+ // Should refocus the element using unique ID
+ expect(handleBuilderInteraction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: expect.any(MouseEvent),
+ previousSelectedEditableDOM: null,
+ visualBuilderContainer: expect.any(HTMLDivElement),
+ overlayWrapper: expect.any(HTMLDivElement),
+ })
+ );
+
+ document.body.removeChild(mockElement);
+ });
+
+ it("should refocus using data-cslp when unique ID is not available", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+ // No unique ID
+ document.body.appendChild(mockElement);
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ mockElement;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockReturnValue({
+ content_type_uid: "test_content_type",
+ entry_uid: "test_entry",
+ locale: "en-us",
+ fieldPath: "test_field",
+ fieldPathWithIndex: "test_field",
+ } as any);
+
+ await handleRevalidateFieldData();
+
+ // Should still call handleBuilderInteraction
+ expect(handleBuilderInteraction).toHaveBeenCalled();
+
+ document.body.removeChild(mockElement);
+ });
+
+ it("should not refocus if element cannot be found after revalidation", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+ // Don't append to DOM - element won't be found
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ mockElement;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockReturnValue({
+ content_type_uid: "test_content_type",
+ entry_uid: "test_entry",
+ locale: "en-us",
+ fieldPath: "test_field",
+ fieldPathWithIndex: "test_field",
+ } as any);
+
+ await handleRevalidateFieldData();
+
+ // Should not call handleBuilderInteraction if element not found
+ expect(handleBuilderInteraction).not.toHaveBeenCalled();
+ });
+
+ it("should not unfocus or refocus when no element is focused", async () => {
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ null;
+
+ await handleRevalidateFieldData();
+
+ // Should not call unfocus/refocus functions
+ expect(hideFocusOverlay).not.toHaveBeenCalled();
+ expect(handleBuilderInteraction).not.toHaveBeenCalled();
+
+ // Should still clear cache
+ expect(FieldSchemaMap.clear).toHaveBeenCalled();
+ });
+
+ it("should handle refocus errors gracefully", async () => {
+ const mockElement = document.createElement("div");
+ mockElement.setAttribute("data-cslp", "content_type.entry.field");
+ document.body.appendChild(mockElement);
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ mockElement;
+
+ const mockExtractDetailsFromCslp = await import("../../../cslp");
+ vi.mocked(
+ mockExtractDetailsFromCslp.extractDetailsFromCslp
+ ).mockReturnValue({
+ content_type_uid: "test_content_type",
+ entry_uid: "test_entry",
+ locale: "en-us",
+ fieldPath: "test_field",
+ fieldPathWithIndex: "test_field",
+ } as any);
+
+ // Make handleBuilderInteraction throw an error
+ handleBuilderInteraction.mockRejectedValueOnce(
+ new Error("Refocus failed")
+ );
+
+ // Should not throw - error should be caught and logged
+ await expect(handleRevalidateFieldData()).resolves.not.toThrow();
+
+ document.body.removeChild(mockElement);
+ });
+ });
+});
diff --git a/src/visualBuilder/eventManager/useRevalidateFieldDataPostMessageEvent.ts b/src/visualBuilder/eventManager/useRevalidateFieldDataPostMessageEvent.ts
new file mode 100644
index 00000000..d984333a
--- /dev/null
+++ b/src/visualBuilder/eventManager/useRevalidateFieldDataPostMessageEvent.ts
@@ -0,0 +1,148 @@
+import { VisualBuilder } from "..";
+import { extractDetailsFromCslp } from "../../cslp";
+import { FieldSchemaMap } from "../utils/fieldSchemaMap";
+import { hideFocusOverlay } from "../generators/generateOverlay";
+import { handleBuilderInteraction } from "../listeners/mouseClick";
+
+/**
+ * Revalidates field data and schema after variant linking operations.
+ * Unfocuses the selected element, revalidates data, and then reselects it.
+ */
+export async function handleRevalidateFieldData(): Promise
{
+ const focusedElement =
+ VisualBuilder.VisualBuilderGlobalState.value
+ .previousSelectedEditableDOM;
+ const hoveredElement =
+ VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM;
+
+ // Store element identifiers for refocusing
+ const elementCslp = focusedElement?.getAttribute("data-cslp");
+ const elementCslpUniqueId =
+ focusedElement?.getAttribute("data-cslp-unique-id") || null;
+ const shouldRefocus = !!focusedElement;
+
+ try {
+ // Step 1: Unfocus the current element
+ if (shouldRefocus) {
+ await unfocusElement();
+ }
+
+ // Step 2: Revalidate field data
+ const targetElement = hoveredElement || focusedElement;
+
+ if (targetElement) {
+ const cslp = targetElement.getAttribute("data-cslp");
+ if (cslp) {
+ const fieldMetadata = extractDetailsFromCslp(cslp);
+
+ // Try to revalidate specific field schema and data
+ // Clear the entire content type schema from cache to force fresh fetch
+ FieldSchemaMap.clearContentTypeSchema(
+ fieldMetadata.content_type_uid
+ );
+ }
+ }
+
+ // Fallback 1: Clear all field schema cache
+ FieldSchemaMap.clear();
+ } catch (error) {
+ console.error("Error handling revalidate field data:", error);
+ // Final fallback - refresh the page
+ window.location.reload();
+ } finally {
+ // Step 3: Refocus the element if we had one focused before
+ if (shouldRefocus && elementCslp) {
+ await refocusElement(elementCslp, elementCslpUniqueId);
+ }
+ }
+}
+
+/**
+ * Unfocuses the currently selected element and clears focus state
+ */
+async function unfocusElement(): Promise {
+ const { visualBuilderContainer, overlayWrapper, focusedToolbar } =
+ getVisualBuilderElements();
+
+ if (!visualBuilderContainer || !overlayWrapper) return;
+
+ const dummyResizeObserver = new ResizeObserver(() => {});
+
+ // Hide focus overlay (cleanIndividualFieldResidual needs previousSelectedEditableDOM)
+ hideFocusOverlay({
+ visualBuilderContainer,
+ visualBuilderOverlayWrapper: overlayWrapper,
+ focusedToolbar,
+ resizeObserver: dummyResizeObserver,
+ noTrigger: true,
+ });
+
+ // Clear global state after cleanup
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ null;
+ VisualBuilder.VisualBuilderGlobalState.value.isFocussed = false;
+}
+
+/**
+ * Refocuses an element by its CSLP identifier
+ */
+async function refocusElement(
+ cslp: string,
+ uniqueId: string | null
+): Promise {
+ try {
+ // Find the element (prefer unique ID, fallback to CSLP)
+ const elementToRefocus =
+ (uniqueId &&
+ document.querySelector(
+ `[data-cslp-unique-id="${uniqueId}"]`
+ )) ||
+ document.querySelector(`[data-cslp="${cslp}"]`);
+
+ if (!elementToRefocus) return;
+
+ const { visualBuilderContainer, overlayWrapper, focusedToolbar } =
+ getVisualBuilderElements();
+
+ if (!visualBuilderContainer || !overlayWrapper) return;
+
+ // Create synthetic click event
+ const syntheticEvent = new MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ });
+ Object.defineProperty(syntheticEvent, "target", {
+ value: elementToRefocus,
+ enumerable: true,
+ });
+
+ // Refocus using handleBuilderInteraction
+ await handleBuilderInteraction({
+ event: syntheticEvent,
+ previousSelectedEditableDOM: null,
+ visualBuilderContainer,
+ overlayWrapper,
+ focusedToolbar,
+ resizeObserver: new ResizeObserver(() => {}),
+ });
+ } catch (error) {
+ console.warn("Could not refocus element after revalidation:", error);
+ }
+}
+
+/**
+ * Gets the main visual builder DOM elements
+ */
+function getVisualBuilderElements() {
+ return {
+ visualBuilderContainer: document.querySelector(
+ ".visual-builder__container"
+ ) as HTMLDivElement | null,
+ overlayWrapper: document.querySelector(
+ ".visual-builder__overlay__wrapper"
+ ) as HTMLDivElement | null,
+ focusedToolbar: document.querySelector(
+ ".visual-builder__focused-toolbar"
+ ) as HTMLDivElement | null,
+ };
+}
diff --git a/src/visualBuilder/generators/__test__/generateOverlay.test.ts b/src/visualBuilder/generators/__test__/generateOverlay.test.ts
new file mode 100644
index 00000000..02de3274
--- /dev/null
+++ b/src/visualBuilder/generators/__test__/generateOverlay.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { sendFieldEvent } from "../generateOverlay";
+import { VisualBuilder } from "../..";
+import { VisualBuilderPostMessageEvents } from "../../utils/types/postMessage.types";
+import visualBuilderPostMessage from "../../utils/visualBuilderPostMessage";
+import { FieldSchemaMap } from "../../utils/fieldSchemaMap";
+import { extractDetailsFromCslp } from "../../../cslp/cslpdata";
+
+vi.mock("../../utils/visualBuilderPostMessage", () => ({
+ default: {
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("../../utils/fieldSchemaMap", () => ({
+ FieldSchemaMap: {
+ getFieldSchema: vi.fn().mockResolvedValue({
+ display_name: "Test Field",
+ data_type: "text",
+ }),
+ },
+}));
+
+vi.mock("../../../cslp/cslpdata", () => ({
+ extractDetailsFromCslp: vi.fn(),
+}));
+
+describe("sendFieldEvent", () => {
+ let previousSelectedEditableDOM: HTMLElement;
+ let visualBuilderContainer: HTMLElement;
+
+ beforeEach(() => {
+ previousSelectedEditableDOM = document.createElement("div");
+ previousSelectedEditableDOM.setAttribute("contenteditable", "true");
+ previousSelectedEditableDOM.innerText = "Test content";
+ document.body.appendChild(previousSelectedEditableDOM);
+
+ visualBuilderContainer = document.createElement("div");
+ document.body.appendChild(visualBuilderContainer);
+
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ previousSelectedEditableDOM;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ });
+
+ it("should return early and not send event when data-cslp attribute is invalid", () => {
+ previousSelectedEditableDOM.setAttribute("data-cslp", "");
+
+ sendFieldEvent({
+ visualBuilderContainer,
+ eventType: VisualBuilderPostMessageEvents.UPDATE_FIELD,
+ });
+
+ expect(extractDetailsFromCslp).not.toHaveBeenCalled();
+ expect(FieldSchemaMap.getFieldSchema).not.toHaveBeenCalled();
+ expect(visualBuilderPostMessage?.send).not.toHaveBeenCalled();
+ });
+});
+
diff --git a/src/visualBuilder/generators/generateOverlay.tsx b/src/visualBuilder/generators/generateOverlay.tsx
index e09e9be9..595cefff 100644
--- a/src/visualBuilder/generators/generateOverlay.tsx
+++ b/src/visualBuilder/generators/generateOverlay.tsx
@@ -177,9 +177,12 @@ export function sendFieldEvent(options: ISendFieldEventParams): void {
? actualEditedElement.innerText
: actualEditedElement.textContent;
- const fieldMetadata = extractDetailsFromCslp(
- previousSelectedEditableDOM.getAttribute("data-cslp") as string
- );
+ const cslpData = previousSelectedEditableDOM.getAttribute("data-cslp");
+ if (!cslpData) {
+ return;
+ }
+
+ const fieldMetadata = extractDetailsFromCslp(cslpData);
FieldSchemaMap.getFieldSchema(
fieldMetadata.content_type_uid,
diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts
index 117f505f..da5992f3 100644
--- a/src/visualBuilder/index.ts
+++ b/src/visualBuilder/index.ts
@@ -371,22 +371,22 @@ export class VisualBuilder {
VisualBuilderPostMessageEvents.SEND_VARIANT_AND_LOCALE
);
- visualBuilderPostMessage?.on<{
- scroll: boolean
- }>(
- VisualBuilderPostMessageEvents.TOGGLE_SCROLL,
- (event) => {
- if (!event.data.scroll) {
- document.body.style.overflow = 'hidden'
- } else {
- document.body.style.overflow = 'auto'
- }
+ visualBuilderPostMessage?.on<{
+ scroll: boolean;
+ }>(
+ VisualBuilderPostMessageEvents.TOGGLE_SCROLL,
+ (event) => {
+ if (!event.data.scroll) {
+ document.body.style.overflow = "hidden";
+ } else {
+ document.body.style.overflow = "auto";
+
}
- );
-
-
-
+ }
+ );
+
+
useHideFocusOverlayPostMessageEvent({
overlayWrapper: this.overlayWrapper,
visualBuilderContainer: this.visualBuilderContainer,
diff --git a/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts b/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts
index 307b52fe..ad387095 100644
--- a/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts
+++ b/src/visualBuilder/utils/__test__/getCsDataOfElement.test.ts
@@ -217,4 +217,46 @@ describe("getDOMEditStack", () => {
const editStack = getDOMEditStack(leafEl);
expect(editStack.length).toBe(2);
});
+
+ test("get dom edit stack should filter out elements with same prefix", () => {
+ const dom = new JSDOM(`
+
+ `).window.document;
+ const leafEl = dom.querySelector(
+ '[data-cslp="page.pageuid.en-us.group.field1.nested.leaf"]'
+ ) as HTMLElement;
+ const editStack = getDOMEditStack(leafEl);
+ // Should only have one entry since all have same prefix (page.pageuid.en-us)
+ expect(editStack.length).toBe(1);
+ expect(editStack[0].content_type_uid).toBe("page");
+ expect(editStack[0].entry_uid).toBe("pageuid");
+ });
+
+ test("get dom edit stack should filter out elements with invalid data-cslp attribute", () => {
+ const dom = new JSDOM(`
+
+ `).window.document;
+ const leafEl = dom.querySelector(
+ '[data-cslp="blog.bloguid.fr-fr.title.name"]'
+ ) as HTMLElement;
+ const editStack = getDOMEditStack(leafEl);
+ // Should have two entries (page, blog) - invalid cslp attributes (empty or no value) should be filtered out
+ expect(editStack.length).toBe(2);
+ expect(editStack[0].content_type_uid).toBe("page");
+ expect(editStack[1].content_type_uid).toBe("blog");
+ });
});
diff --git a/src/visualBuilder/utils/__test__/getEntryIdentifiersInCurrentPage.test.ts b/src/visualBuilder/utils/__test__/getEntryIdentifiersInCurrentPage.test.ts
index 2d057b31..7f50ce74 100644
--- a/src/visualBuilder/utils/__test__/getEntryIdentifiersInCurrentPage.test.ts
+++ b/src/visualBuilder/utils/__test__/getEntryIdentifiersInCurrentPage.test.ts
@@ -47,4 +47,17 @@ describe("getEntryIdentifiersInCurrentPage", () => {
expect(entriesInCurrentPage.length).toBe(0);
expect(entriesInCurrentPage).toEqual([]);
});
+
+ test("should filter out elements with empty data-cslp attribute and not break", () => {
+ document.body.innerHTML = `
+
+
Empty CSLP
+ Empty CSLP
+ Valid CSLP
+
+ `;
+ const { entriesInCurrentPage } = getEntryIdentifiersInCurrentPage();
+ expect(entriesInCurrentPage.length).toBe(1);
+ expect(entriesInCurrentPage[0].entryUid).toBe('bltf5bb5f8fb088a332');
+ });
});
diff --git a/src/visualBuilder/utils/__test__/getVisualBuilderRedirectionUrl.test.ts b/src/visualBuilder/utils/__test__/getVisualBuilderRedirectionUrl.test.ts
index e7232ad6..c09ec900 100644
--- a/src/visualBuilder/utils/__test__/getVisualBuilderRedirectionUrl.test.ts
+++ b/src/visualBuilder/utils/__test__/getVisualBuilderRedirectionUrl.test.ts
@@ -84,4 +84,25 @@ describe('getVisualBuilderRedirectionUrl', () => {
const result = getVisualBuilderRedirectionUrl();
expect(result.toString()).toBe('https://app.example.com/#!/stack/12345/visual-builder?branch=main&environment=production&target-url=https%3A%2F%2Fexample.com%2F');
});
+
+ it('should ignore invalid data-cslp attribute and use locale from config', () => {
+ document.body.innerHTML = '';
+ Config.get.mockReturnValue({
+ stackDetails: {
+ branch: 'main',
+ apiKey: '12345',
+ environment: 'production',
+ locale: 'en-US'
+ },
+ clientUrlParams: {
+ url: 'https://app.example.com'
+ }
+ });
+
+ const result = getVisualBuilderRedirectionUrl();
+ // Should use locale from config when data-cslp attribute is invalid (empty or no value)
+ expect(result.toString()).toBe('https://app.example.com/#!/stack/12345/visual-builder?branch=main&environment=production&target-url=https%3A%2F%2Fexample.com%2F&locale=en-US');
+ // Should not call extractDetailsFromCslp for invalid cslp
+ expect(extractDetailsFromCslp).not.toHaveBeenCalled();
+ });
});
\ No newline at end of file
diff --git a/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts b/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts
index 42f89b06..405d3675 100644
--- a/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts
+++ b/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts
@@ -1,10 +1,11 @@
import { describe, it, expect } from "vitest";
-import { isFieldDisabled } from "../isFieldDisabled";
+import { isFieldDisabled, DisableReason } from "../isFieldDisabled";
import { ISchemaFieldMap } from "../types/index.types";
import { FieldDetails } from "../../components/FieldToolbar";
import Config from "../../../configManager/configManager";
import { VisualBuilder } from "../..";
import { EntryPermissions } from "../getEntryPermissions";
+import { WORKFLOW_STAGES } from "../constants";
import { ResolvedVariantPermissions } from "../getResolvedVariantPermissions";
const resolvedVariantPermissions: ResolvedVariantPermissions = {
@@ -29,7 +30,7 @@ describe("isFieldDisabled", () => {
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(true);
- expect(result.reason).toBe("You have only read access to this field");
+ expect(result.reason).toBe(DisableReason.ReadOnly);
});
it("should return disabled state due to non-localizable fields", () => {
@@ -52,18 +53,39 @@ describe("isFieldDisabled", () => {
},
};
+ const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
+ expect(result.isDisabled).toBe(true);
+ expect(result.reason).toBe(DisableReason.LocalizedEntry);
+ });
+
+ it("should return disabled state due to unlinked variant", () => {
+ // @ts-expect-error mocking only required properties
+ const fieldSchemaMap: ISchemaFieldMap = {
+ field_metadata: {
+ isUnlinkedVariant: true,
+ },
+ };
+ const eventFieldDetails: FieldDetails = {
+ editableElement: document.createElement("div"),
+ // @ts-expect-error mocking only required properties
+ fieldMetadata: {
+ locale: "en-us",
+ },
+ };
+
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(true);
expect(result.reason).toBe(
- "Editing this field is restricted in localized entries"
+ `${DisableReason.UnlinkedVariant} ${DisableReason.CannotLinkVariant}`
);
});
- it("should return disabled state due to unlinked variant", () => {
+ it("should return disabled state due to unlinked variant with link option", () => {
// @ts-expect-error mocking only required properties
const fieldSchemaMap: ISchemaFieldMap = {
field_metadata: {
isUnlinkedVariant: true,
+ canLinkVariant: true,
},
};
const eventFieldDetails: FieldDetails = {
@@ -77,7 +99,7 @@ describe("isFieldDisabled", () => {
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(true);
expect(result.reason).toBe(
- "This field is not editable as it is not linked to the selected variant"
+ `${DisableReason.UnlinkedVariant} ${DisableReason.CanLinkVariant} `
);
});
@@ -102,9 +124,7 @@ describe("isFieldDisabled", () => {
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(true);
- expect(result.reason).toBe(
- "This field is not editable as it is not localized"
- );
+ expect(result.reason).toBe(DisableReason.UnlocalizedVariant);
});
it("should return disabled state due to audience mode", () => {
@@ -127,9 +147,7 @@ describe("isFieldDisabled", () => {
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(true);
- expect(result.reason).toBe(
- "To edit an experience, open the Audience widget and click the Edit icon."
- );
+ expect(result.reason).toBe(DisableReason.AudienceMode);
});
it("should return disabled state due to disabled variant", () => {
@@ -155,9 +173,7 @@ describe("isFieldDisabled", () => {
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(true);
- expect(result.reason).toBe(
- "This field is not editable as it doesn't match the selected variant"
- );
+ expect(result.reason).toBe(DisableReason.DisabledVariant);
VisualBuilder.VisualBuilderGlobalState = {
// @ts-expect-error mocking only required properties
value: {
@@ -179,7 +195,7 @@ describe("isFieldDisabled", () => {
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(false);
- expect(result.reason).toBe("");
+ expect(result.reason).toBe(DisableReason.None);
});
it("should return disabled state due to read-only role", () => {
@@ -218,7 +234,7 @@ describe("isFieldDisabled", () => {
const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails);
expect(result.isDisabled).toBe(true);
- expect(result.reason).toBe("You have only read access to this field");
+ expect(result.reason).toBe(DisableReason.ReadOnly);
});
it("should return disabled state due to entry update restriction", () => {
@@ -260,9 +276,7 @@ describe("isFieldDisabled", () => {
publish: true,
});
expect(result.isDisabled).toBe(true);
- expect(result.reason).toBe(
- "You do not have permission to edit this entry"
- );
+ expect(result.reason).toBe(DisableReason.EntryUpdateRestricted);
});
describe("workflow stage restrictions", () => {
@@ -303,7 +317,9 @@ describe("isFieldDisabled", () => {
);
expect(result.isDisabled).toBe(true);
expect(result.reason).toBe(
- "You do not have Edit access to this entry on the 'Review Stage' workflow stage"
+ DisableReason.WorkflowStagePermission({
+ stageName: WORKFLOW_STAGES.REVIEW,
+ })
);
});
@@ -344,7 +360,9 @@ describe("isFieldDisabled", () => {
);
expect(result.isDisabled).toBe(true);
expect(result.reason).toBe(
- "Editing is restricted for your role or by the rules for the 'Final Review' stage. Contact your admin for edit access."
+ DisableReason.EntryUpdateRestrictedRoleAndWorkflowStage({
+ stageName: WORKFLOW_STAGES.FINAL_REVIEW,
+ })
);
});
@@ -422,7 +440,9 @@ describe("isFieldDisabled", () => {
);
expect(result.isDisabled).toBe(true);
expect(result.reason).toBe(
- "You do not have Edit access to this entry on the 'Unknown' workflow stage"
+ DisableReason.WorkflowStagePermission({
+ stageName: WORKFLOW_STAGES.UNKNOWN,
+ })
);
});
@@ -464,7 +484,9 @@ describe("isFieldDisabled", () => {
);
expect(result.isDisabled).toBe(true);
expect(result.reason).toBe(
- "Editing is restricted for your role or by the rules for the 'Unknown' stage. Contact your admin for edit access."
+ DisableReason.EntryUpdateRestrictedRoleAndWorkflowStage({
+ stageName: WORKFLOW_STAGES.UNKNOWN,
+ })
);
});
@@ -509,9 +531,7 @@ describe("isFieldDisabled", () => {
);
expect(result.isDisabled).toBe(true);
// Should return read-only role message first based on getDisableReason logic
- expect(result.reason).toBe(
- "You have only read access to this field"
- );
+ expect(result.reason).toBe(DisableReason.ReadOnly);
});
it("should return enabled state when no workflow stage details provided", () => {
@@ -540,7 +560,7 @@ describe("isFieldDisabled", () => {
undefined
);
expect(result.isDisabled).toBe(false);
- expect(result.reason).toBe("");
+ expect(result.reason).toBe(DisableReason.None);
});
});
});
diff --git a/src/visualBuilder/utils/__test__/updateFocussedState.test.ts b/src/visualBuilder/utils/__test__/updateFocussedState.test.ts
index 8766f24d..ab8d7e51 100644
--- a/src/visualBuilder/utils/__test__/updateFocussedState.test.ts
+++ b/src/visualBuilder/utils/__test__/updateFocussedState.test.ts
@@ -4,7 +4,7 @@ import {
updateFocussedStateOnMutation,
} from "../updateFocussedState";
import { VisualBuilder } from "../..";
-import { addFocusOverlay, hideFocusOverlay } from "../../generators/generateOverlay";
+import { addFocusOverlay, hideOverlay } from "../../generators/generateOverlay";
import { mockGetBoundingClientRect } from "../../../__test__/utils";
import { act } from "@testing-library/preact";
import { singleLineFieldSchema } from "../../../__test__/data/fields";
@@ -14,7 +14,7 @@ import { getEntryPermissionsCached } from "../getEntryPermissionsCached";
vi.mock("../../generators/generateOverlay", () => ({
addFocusOverlay: vi.fn(),
- hideFocusOverlay: vi.fn(),
+ hideOverlay: vi.fn(),
}));
vi.mock("../fetchEntryPermissionsAndStageDetails", () => ({
@@ -43,8 +43,7 @@ vi.mock("../../utils/fieldSchemaMap", () => {
describe("updateFocussedState", () => {
beforeEach(() => {
- let previousSelectedEditableDOM: HTMLElement;
- previousSelectedEditableDOM = document.createElement("div");
+ const previousSelectedEditableDOM = document.createElement("div");
previousSelectedEditableDOM.setAttribute(
"data-cslp",
"content_type_uid.entry_uid.locale.field_path"
@@ -79,6 +78,7 @@ describe("updateFocussedState", () => {
document.body.innerHTML = "";
VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
null;
+ vi.clearAllMocks();
});
it("should return early if required elements are not provided", async () => {
const result = await updateFocussedState({
@@ -91,7 +91,7 @@ describe("updateFocussedState", () => {
expect(result).toBeUndefined();
});
- it("should hide focus overlay if newPreviousSelectedElement is not found", () => {
+ it("should call hideOverlay if newPreviousSelectedElement is not found", () => {
const resizeObserverMock = {
disconnect: vi.fn(),
} as unknown as ResizeObserver;
@@ -112,7 +112,13 @@ describe("updateFocussedState", () => {
resizeObserver: resizeObserverMock,
});
- expect(hideFocusOverlay).toHaveBeenCalled();
+ expect(hideOverlay).toHaveBeenCalledWith({
+ visualBuilderOverlayWrapper: overlayWrapperMock,
+ focusedToolbar: focusedToolbarMock,
+ visualBuilderContainer: visualBuilderContainerMock,
+ resizeObserver: resizeObserverMock,
+ noTrigger: true,
+ });
spyQuerySelector.mockRestore();
});
@@ -270,12 +276,48 @@ describe("updateFocussedState", () => {
expect.any(Boolean)
);
});
+
+ it("should return early if data-cslp attribute is invalid", async () => {
+ const editableElementMock = document.createElement("div");
+ editableElementMock.setAttribute("data-cslp", "");
+ const visualBuilderContainerMock = document.createElement("div");
+ const overlayWrapperMock = document.createElement("div");
+ const focusedToolbarMock = document.createElement("div");
+ const resizeObserverMock = {
+ disconnect: vi.fn(),
+ } as unknown as ResizeObserver;
+
+ const previousSelectedEditableDOM = document.createElement("div");
+ previousSelectedEditableDOM.setAttribute(
+ "data-cslp",
+ "content_type_uid.entry_uid.locale.field_path"
+ );
+ document.body.appendChild(previousSelectedEditableDOM);
+ VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM =
+ previousSelectedEditableDOM;
+
+ document.querySelector = vi
+ .fn()
+ .mockReturnValue(previousSelectedEditableDOM);
+
+ const result = await updateFocussedState({
+ editableElement: editableElementMock,
+ visualBuilderContainer: visualBuilderContainerMock,
+ overlayWrapper: overlayWrapperMock,
+ focusedToolbar: focusedToolbarMock,
+ resizeObserver: resizeObserverMock,
+ });
+
+ // Should return early without processing
+ expect(result).toBeUndefined();
+ expect(getEntryPermissionsCached).not.toHaveBeenCalled();
+ expect(addFocusOverlay).not.toHaveBeenCalled();
+ });
});
describe("updateFocussedStateOnMutation", () => {
beforeEach(() => {
- let previousSelectedEditableDOM: HTMLElement;
- previousSelectedEditableDOM = document.createElement("div");
+ const previousSelectedEditableDOM = document.createElement("div");
previousSelectedEditableDOM.setAttribute(
"data-cslp",
"content_type_uid.entry_uid.locale.field_path"
@@ -294,7 +336,7 @@ describe("updateFocussedStateOnMutation", () => {
expect(result).toBeUndefined();
});
- it("should hide focus overlay if newSelectedElement is not found", () => {
+ it("should call hideOverlay if newSelectedElement is not found", () => {
const resizeObserverMock = {
disconnect: vi.fn(),
} as unknown as ResizeObserver;
@@ -311,7 +353,13 @@ describe("updateFocussedStateOnMutation", () => {
resizeObserverMock
);
- expect(hideFocusOverlay).toHaveBeenCalled();
+ expect(hideOverlay).toHaveBeenCalledWith({
+ visualBuilderOverlayWrapper: focusOverlayWrapperMock,
+ focusedToolbar: focusedToolbarMock,
+ visualBuilderContainer: visualBuilderContainerMock,
+ resizeObserver: resizeObserverMock,
+ noTrigger: true,
+ });
});
it("should update focus outline dimensions", () => {
diff --git a/src/visualBuilder/utils/constants.ts b/src/visualBuilder/utils/constants.ts
index d445b877..b5475553 100644
--- a/src/visualBuilder/utils/constants.ts
+++ b/src/visualBuilder/utils/constants.ts
@@ -16,6 +16,17 @@ export const TOOLBAR_EDGE_BUFFER = 8;
export const DATA_CSLP_ATTR_SELECTOR = "data-cslp";
+export const RESULT_TYPES = Object.freeze({
+ SUCCESS: "success",
+ ERROR: "error",
+});
+
+export const WORKFLOW_STAGES = Object.freeze({
+ REVIEW: "Review Stage",
+ FINAL_REVIEW: "Final Review",
+ UNKNOWN: "Unknown",
+});
+
/**
* The field that can be directly modified using contenteditable=true.
* This includes all text fields like title and numbers.
diff --git a/src/visualBuilder/utils/fieldSchemaMap.ts b/src/visualBuilder/utils/fieldSchemaMap.ts
index 4dce5d2e..ee7914db 100644
--- a/src/visualBuilder/utils/fieldSchemaMap.ts
+++ b/src/visualBuilder/utils/fieldSchemaMap.ts
@@ -90,6 +90,15 @@ export class FieldSchemaMap {
FieldSchemaMap.fieldSchema[contentTypeUid] = fieldSchemaMap;
}
+ /**
+ * Clears the field schemas for a specific content type.
+ * @param contentTypeUid The unique identifier of the content type.
+ */
+ static clearContentTypeSchema(contentTypeUid: string): void {
+ delete FieldSchemaMap.fieldSchema[contentTypeUid];
+ delete FieldSchemaMap.fieldSchemaPromise[contentTypeUid];
+ }
+
/**
* Clears the field schema cache.
*/
diff --git a/src/visualBuilder/utils/getCsDataOfElement.ts b/src/visualBuilder/utils/getCsDataOfElement.ts
index 2cce0888..7139a50f 100644
--- a/src/visualBuilder/utils/getCsDataOfElement.ts
+++ b/src/visualBuilder/utils/getCsDataOfElement.ts
@@ -54,7 +54,11 @@ export function getDOMEditStack(ele: Element): CslpData[] {
const cslpSet: string[] = [];
let curr: any = ele.closest(`[${DATA_CSLP_ATTR_SELECTOR}]`);
while (curr) {
- const cslp = curr.getAttribute(DATA_CSLP_ATTR_SELECTOR)!;
+ const cslp = curr.getAttribute(DATA_CSLP_ATTR_SELECTOR);
+ if (!cslp) {
+ curr = curr.parentElement?.closest(`[${DATA_CSLP_ATTR_SELECTOR}]`);
+ continue;
+ }
const entryPrefix = getPrefix(cslp);
const hasSamePrevPrefix = getPrefix(cslpSet.at(0) || "").startsWith(
entryPrefix
@@ -64,5 +68,5 @@ export function getDOMEditStack(ele: Element): CslpData[] {
}
curr = curr.parentElement?.closest(`[${DATA_CSLP_ATTR_SELECTOR}]`);
}
- return cslpSet.map((cslp) => extractDetailsFromCslp(cslp));
+ return cslpSet.filter((cslp) => cslp).map((cslp) => extractDetailsFromCslp(cslp));
}
diff --git a/src/visualBuilder/utils/getEntryIdentifiersInCurrentPage.ts b/src/visualBuilder/utils/getEntryIdentifiersInCurrentPage.ts
index 7ea4f741..243bc4a1 100644
--- a/src/visualBuilder/utils/getEntryIdentifiersInCurrentPage.ts
+++ b/src/visualBuilder/utils/getEntryIdentifiersInCurrentPage.ts
@@ -14,9 +14,9 @@ export function getEntryIdentifiersInCurrentPage(): EntryIdentifiers {
);
const uniqueEntriesMap = new Map();
elementsWithCslp.forEach((element) => {
- const cslpData = extractDetailsFromCslp(
- element.getAttribute("data-cslp") as string
- );
+ const cslpValue = element.getAttribute("data-cslp");
+ if (!cslpValue) return;
+ const cslpData = extractDetailsFromCslp(cslpValue);
uniqueEntriesMap.set(cslpData.entry_uid,
{
entryUid: cslpData.entry_uid,
diff --git a/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts b/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts
index 955e466c..02e4659b 100644
--- a/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts
+++ b/src/visualBuilder/utils/getVisualBuilderRedirectionUrl.ts
@@ -22,16 +22,18 @@ export default function getVisualBuilderRedirectionUrl(): URL {
// get the locale from the data cslp attribute
const elementWithDataCslp = document.querySelector(`[data-cslp]`);
+ let localeToUse = locale;
if (elementWithDataCslp) {
- const cslpData = elementWithDataCslp.getAttribute(
- "data-cslp"
- ) as string;
- const { locale } = extractDetailsFromCslp(cslpData);
+ const cslpData = elementWithDataCslp.getAttribute("data-cslp");
+ if (cslpData) {
+ const { locale: cslpLocale } = extractDetailsFromCslp(cslpData);
+ localeToUse = cslpLocale;
+ }
+ }
- searchParams.set("locale", locale);
- } else if (locale) {
- searchParams.set("locale", locale);
+ if (localeToUse) {
+ searchParams.set("locale", localeToUse);
}
const completeURL = new URL(
diff --git a/src/visualBuilder/utils/isFieldDisabled.ts b/src/visualBuilder/utils/isFieldDisabled.ts
index 4ad73466..86c61f27 100644
--- a/src/visualBuilder/utils/isFieldDisabled.ts
+++ b/src/visualBuilder/utils/isFieldDisabled.ts
@@ -6,13 +6,17 @@ import { EntryPermissions } from "./getEntryPermissions";
import { WorkflowStageDetails } from "./getWorkflowStageDetails";
import { ResolvedVariantPermissions } from "./getResolvedVariantPermissions";
-const DisableReason = {
+export const DisableReason = {
ReadOnly: "You have only read access to this field",
LocalizedEntry: "Editing this field is restricted in localized entries",
ResolvedVariantPermissions: "This field does not exist in the selected variant",
UnlinkedVariant:
- "This field is not editable as it is not linked to the selected variant",
- AudienceMode: "To edit an experience, open the Audience widget and click the Edit icon.",
+ "This field is not editable as it is not linked to the selected variant.",
+ CanLinkVariant: "Click here to link a variant",
+ UnderlinedAndClickableWord: "here",
+ CannotLinkVariant: "Contact your stack admin or owner to link it.",
+ AudienceMode:
+ "To edit an experience, open the Audience widget and click the Edit icon.",
DisabledVariant:
"This field is not editable as it doesn't match the selected variant",
UnlocalizedVariant: "This field is not editable as it is not localized",
@@ -42,8 +46,11 @@ const getDisableReason = (
return DisableReason.LocalizedEntry;
if (flags.updateRestrictDueToUnlocalizedVariant)
return DisableReason.UnlocalizedVariant;
- if (flags.updateRestrictDueToUnlinkVariant)
- return DisableReason.UnlinkedVariant;
+ if (flags.updateRestrictDueToUnlinkVariant) {
+ return flags.canLinkVariant
+ ? `${DisableReason.UnlinkedVariant} ${DisableReason.CanLinkVariant} `
+ : `${DisableReason.UnlinkedVariant} ${DisableReason.CannotLinkVariant}`;
+ }
if (flags.updateRestrictDueToAudienceMode)
return DisableReason.AudienceMode;
if (flags.updateRestrictDueToDisabledVariant)
@@ -89,6 +96,7 @@ export const isFieldDisabled = (
updateRestrictDueToUnlinkVariant: Boolean(
fieldSchemaMap?.field_metadata?.isUnlinkedVariant
),
+ canLinkVariant: Boolean(fieldSchemaMap?.field_metadata?.canLinkVariant),
updateRestrictDueToUnlocalizedVariant: Boolean(
variant && fieldMetadata.locale !== cmsLocale
),
diff --git a/src/visualBuilder/utils/types/index.types.ts b/src/visualBuilder/utils/types/index.types.ts
index 21eb7073..20cafb5a 100644
--- a/src/visualBuilder/utils/types/index.types.ts
+++ b/src/visualBuilder/utils/types/index.types.ts
@@ -17,6 +17,7 @@ export type ISchemaFieldMap = (
field_metadata?: {
updateRestrict?: boolean;
isUnlinkedVariant?: boolean;
+ canLinkVariant?: boolean;
};
};
diff --git a/src/visualBuilder/utils/types/postMessage.types.ts b/src/visualBuilder/utils/types/postMessage.types.ts
index ed763222..06ea3f91 100644
--- a/src/visualBuilder/utils/types/postMessage.types.ts
+++ b/src/visualBuilder/utils/types/postMessage.types.ts
@@ -9,6 +9,7 @@ export enum VisualBuilderPostMessageEvents {
TOGGLE_FORM = "toggle-quick-form",
GET_FIELD_SCHEMA = "get-field-schema",
GET_FIELD_DATA = "get-field-data",
+ OPEN_LINK_VARIANT_MODAL = "open-link-variant-modal",
GET_FIELD_PATH_WITH_UID = "get-field-path-with-uid",
GET_FIELD_DISPLAY_NAMES = "get-field-display-names",
MOUSE_CLICK = "mouse-click",
diff --git a/src/visualBuilder/utils/updateFocussedState.ts b/src/visualBuilder/utils/updateFocussedState.ts
index 9ed5445a..989560f2 100644
--- a/src/visualBuilder/utils/updateFocussedState.ts
+++ b/src/visualBuilder/utils/updateFocussedState.ts
@@ -3,7 +3,7 @@ import { extractDetailsFromCslp } from "../../cslp";
import { getAddInstanceButtons } from "../generators/generateAddInstanceButtons";
import {
addFocusOverlay,
- hideFocusOverlay,
+ hideOverlay,
} from "../generators/generateOverlay";
import { hideHoverOutline } from "../listeners/mouseHover";
import {
@@ -120,7 +120,7 @@ export async function updateFocussedState({
) ||
document.querySelector(`[data-cslp="${previousSelectedElementCslp}"]`);
if (!newPreviousSelectedElement && resizeObserver) {
- hideFocusOverlay({
+ hideOverlay({
visualBuilderOverlayWrapper: overlayWrapper,
focusedToolbar,
visualBuilderContainer,
@@ -136,6 +136,9 @@ export async function updateFocussedState({
}
const cslp = editableElement?.getAttribute("data-cslp") || "";
+ if (!cslp) {
+ return;
+ }
const fieldMetadata = extractDetailsFromCslp(cslp);
hideHoverOutline(visualBuilderContainer);
@@ -267,7 +270,7 @@ export function updateFocussedStateOnMutation(
`[data-cslp-unique-id="${selectedElementCslpUniqueId}"]`
) || document.querySelector(`[data-cslp="${selectedElementCslp}"]`);
if (!newSelectedElement && resizeObserver) {
- hideFocusOverlay({
+ hideOverlay({
visualBuilderOverlayWrapper: focusOverlayWrapper,
focusedToolbar,
visualBuilderContainer,
@@ -302,6 +305,9 @@ export function updateFocussedStateOnMutation(
}
}
+ //TODO: This logic for overlay position is already present in generateOverlay as `addFocusOverlay`.
+ // We should refactor this to use the same logic. Refer "VB-593" branch for more details.
+
/**
* Update the focus overlays if they exists.
*/
@@ -388,6 +394,8 @@ export function updateFocussedStateOnMutation(
* Update the focus toolbar if it exists.
*/
+ //TODO: This logic for toolbar position is already present in same file as `positionToolbar`.
+ // We should refactor this to use the same logic. Refer "VB-593" branch for more details.
if (focusedToolbar) {
const targetElementRightEdgeOffset =
window.scrollX + window.innerWidth - selectedElementDimension.left;
diff --git a/src/visualBuilder/visualBuilder.style.ts b/src/visualBuilder/visualBuilder.style.ts
index 178fcdef..87a669d9 100644
--- a/src/visualBuilder/visualBuilder.style.ts
+++ b/src/visualBuilder/visualBuilder.style.ts
@@ -534,7 +534,6 @@ export function visualBuilderStyles() {
.visual-builder__focused-toolbar__field-label-wrapper__current-field {
background: #BD59FA;
}
-
`,
"visual-builder__cursor-disabled": css`
.visual-builder__cursor-icon {
@@ -582,6 +581,37 @@ export function visualBuilderStyles() {
&:after {
display: block;
}
+
+ &:has(.visual-builder__custom-tooltip):before,
+ &:has(.visual-builder__custom-tooltip):after {
+ display: none;
+ }
+ `,
+ "visual-builder__custom-tooltip": css`
+ position: absolute;
+ bottom: 20px;
+ margin-bottom: 24px;
+ padding: 12px;
+ border-radius: 4px;
+ width: max-content;
+ max-width: 200px;
+ color: #fff;
+ font-family: Inter;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 132%; /* 0.99rem */
+ letter-spacing: 0.015rem;
+ background: #767676;
+
+ &:after {
+ content: "";
+ position: absolute;
+ bottom: -10px;
+ left: 10px;
+ border: 10px solid #000;
+ border-color: #767676 transparent transparent transparent;
+ }
`,
"visual-builder__empty-block": css`
width: 100%;