diff --git a/src/visualBuilder/components/fieldLabelWrapper.tsx b/src/visualBuilder/components/fieldLabelWrapper.tsx index f59cea06..2e3d0d6e 100644 --- a/src/visualBuilder/components/fieldLabelWrapper.tsx +++ b/src/visualBuilder/components/fieldLabelWrapper.tsx @@ -4,7 +4,7 @@ import { extractDetailsFromCslp } from "../../cslp"; import { CslpData } from "../../cslp/types/cslp.types"; import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import { FieldSchemaMap } from "../utils/fieldSchemaMap"; -import { isFieldDisabled } from "../utils/isFieldDisabled"; +import { DisableReason, isFieldDisabled } from "../utils/isFieldDisabled"; import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; import { CaretIcon, CaretRightIcon, InfoIcon } from "./icons"; import { LoadingIcon } from "./icons/loading"; @@ -18,6 +18,8 @@ import { ContentTypeIcon } from "./icons"; import { ToolbarTooltip } from "./Tooltip"; import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; import { VariantIndicator } from "./VariantIndicator"; +import { handleRevalidateFieldData } from "../eventManager/useRevalidateFieldDataPostMessageEvent"; +import { RESULT_TYPES } from "../utils/constants"; interface ReferenceParentMap { [entryUid: string]: { @@ -111,9 +113,11 @@ function FieldLabelWrapperComponent( const allPaths = uniqBy( [ props.fieldMetadata, - ...props.parentPaths.map((path) => { - return extractDetailsFromCslp(path); - }), + ...props.parentPaths + .filter((path) => path) + .map((path) => { + return extractDetailsFromCslp(path); + }), ], "cslpValue" ); @@ -129,7 +133,7 @@ function FieldLabelWrapperComponent( getReferenceParentMap() ]); const entryUid = props.fieldMetadata.entry_uid; - + const referenceData = referenceParentMap[entryUid]; const isReference = !!referenceData; @@ -140,12 +144,14 @@ function FieldLabelWrapperComponent( const domAncestor = eventDetails.editableElement.closest(`[data-cslp]:not([data-cslp^="${props.fieldMetadata.content_type_uid}"])`); if(domAncestor) { const domAncestorCslp = domAncestor.getAttribute("data-cslp"); - const domAncestorDetails = extractDetailsFromCslp(domAncestorCslp!); - const domAncestorContentTypeUid = domAncestorDetails.content_type_uid; - const domAncestorContentParent = referenceData?.find(data => data.contentTypeUid === domAncestorContentTypeUid); - if(domAncestorContentParent) { - referenceFieldName = domAncestorContentParent.referenceFieldName; - parentContentTypeName = domAncestorContentParent.contentTypeTitle; + if (domAncestorCslp) { + const domAncestorDetails = extractDetailsFromCslp(domAncestorCslp); + const domAncestorContentTypeUid = domAncestorDetails.content_type_uid; + const domAncestorContentParent = referenceData?.find(data => data.contentTypeUid === domAncestorContentTypeUid); + if(domAncestorContentParent) { + referenceFieldName = domAncestorContentParent.referenceFieldName; + parentContentTypeName = domAncestorContentParent.contentTypeTitle; + } } } } @@ -173,6 +179,38 @@ function FieldLabelWrapperComponent( entryWorkflowStageDetails, ); + const handleLinkVariant = async () => { + try { + if (fieldSchema.field_metadata?.canLinkVariant) { + const result = await visualBuilderPostMessage?.send<{ + type: typeof RESULT_TYPES.SUCCESS | typeof RESULT_TYPES.ERROR; + message: string; + }>( + VisualBuilderPostMessageEvents.OPEN_LINK_VARIANT_MODAL, + { + contentTypeUid: + props.fieldMetadata.content_type_uid, + } + ); + + // If the modal was closed or linking failed, do nothing + if (!result || result.type === RESULT_TYPES.ERROR) { + return; + } + + // If linking was successful and requires revalidation, revalidate + if (result.type === RESULT_TYPES.SUCCESS) { + await handleRevalidateFieldData(); + } + } + } catch (error) { + console.error( + "Error in link variant modal flow:", + error + ); + } + }; + const currentFieldDisplayName = displayNames?.[props.fieldMetadata.cslpValue] ?? fieldSchema.display_name; @@ -190,8 +228,30 @@ function FieldLabelWrapperComponent( "visual-builder__tooltip--persistent" ] )} - data-tooltip={reason} + data-tooltip={!reason?.includes(DisableReason.CanLinkVariant) + ? reason + : undefined} > + {reason + .includes(DisableReason.CanLinkVariant) && ( +
+ {(() => { + const [before, after] = reason.split( + DisableReason.UnderlinedAndClickableWord + ); + return ( + <> + {before} + {DisableReason.UnderlinedAndClickableWord} + {after} + + ); + })()} +
+ )} ) : hasParentPaths ? ( @@ -305,11 +365,11 @@ function FieldLabelWrapperComponent( > { currentField.isReference && !dataLoading && !error ? -
({ + 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(` +
+
+
+ leaf +
+
+
+ `).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(` +
+
+
+
+ name +
+
+
+
+ `).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%;