diff --git a/.talismanrc b/.talismanrc index 97637673..c7951c73 100644 --- a/.talismanrc +++ b/.talismanrc @@ -5,9 +5,11 @@ fileignoreconfig: - filename: README.md checksum: 568289bbe7c088967493db246dbf29e465382648ac574c1b1236be57d5662a38 - filename: CHANGELOG.md - checksum: 09ed2613ba45ee13b6dbb4fc178911e93674d4e5c40af026d66266ea172374a4 + checksum: ed794e2f5c5884f74af12e5f5bfbb117c08ba454104f929df3deb7627407317a - filename: src/visualBuilder/components/__test__/fieldToolbar.test.tsx checksum: 3badd6a142456b6a361569e6fc546349a38ac6b366bef7fd5255d1e93220444e - filename: src/visualBuilder/components/Collab/ThreadPopup/__test__/CommentTextArea.test.tsx checksum: d0ef271ee5381d9feab06bda6e7e89bd0a882fee87495627bd811c1f0a5459c7 + - filename: package-lock.json + checksum: fd06363871d0ee16ebfb5d9d0cc479e0922a615bb76584b80bb6933ee6c3e237 version: "1.0" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab06bca..03e0a92b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,27 @@ # Changelog +## [v3.3.0](https://github.com/contentstack/live-preview-sdk/compare/v3.2.5...v3.3.0) + +> 24 July 2025 + +### Fixes + +- Fix: HoverToolbar to not render when focussed (Ayush Dubey - [#461](https://github.com/contentstack/live-preview-sdk/pull/461)) + +### General Changes + +- Release 24 July to stage_v3 (Sairaj - [#473](https://github.com/contentstack/live-preview-sdk/pull/473)) +- HoverToolbar: Requested Changes (Ayush Dubey - [#464](https://github.com/contentstack/live-preview-sdk/pull/464)) +- [Feature] HoverToolbar (Ayush Dubey - [#455](https://github.com/contentstack/live-preview-sdk/pull/455)) + ## [v3.2.5](https://github.com/contentstack/live-preview-sdk/compare/v3.2.4...v3.2.5) -> 9 July 2025 +> 10 July 2025 + +### New Features + +- feat: v3.2.5 lp sdk (Karan Gandhi - [#454](https://github.com/contentstack/live-preview-sdk/pull/454)) +- feat: v3.2.5 (Karan Gandhi - [#452](https://github.com/contentstack/live-preview-sdk/pull/452)) ### Fixes @@ -24,9 +43,11 @@ - fix: psuedo-editable height collapse (Faraaz Biyabani - [f28d629](https://github.com/contentstack/live-preview-sdk/commit/f28d629d362d5820b8583f748b42bd98d464c180)) - fix: changed DOM events (csAyushDubey - [8e433b4](https://github.com/contentstack/live-preview-sdk/commit/8e433b41328acefd969ba157d25cf6f6ad5cc351)) - fix: test fix (csAyushDubey - [af6acf5](https://github.com/contentstack/live-preview-sdk/commit/af6acf5eba9236ba3fb13bb32da8fdade9063d51)) +- fix: talisman update (Karan Gandhi - [cf73f0b](https://github.com/contentstack/live-preview-sdk/commit/cf73f0b267e3c42e2fce13579ca014d5edae1a57)) ### Chores And Housekeeping +- chore: changelog update (Karan Gandhi - [f3a512e](https://github.com/contentstack/live-preview-sdk/commit/f3a512e3b72c9a956b2d1580af34d6c4c7e94ecc)) - chore: update README.md to reference ContentstackLivePreview version 3.2.5 (hiteshshetty-dev - [e063d6e](https://github.com/contentstack/live-preview-sdk/commit/e063d6ef8fd95faaef612981f4586b4db66f9e4d)) ## [v3.2.4](https://github.com/contentstack/live-preview-sdk/compare/v3.2.3...v3.2.4) diff --git a/package-lock.json b/package-lock.json index 11bc3a7e..b592b69a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@contentstack/live-preview-utils", - "version": "3.2.5", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@contentstack/live-preview-utils", - "version": "3.2.5", + "version": "3.3.0", "license": "MIT", "dependencies": { + "@floating-ui/dom": "^1.7.2", "@preact/compat": "17.1.2", "@preact/signals": "1.2.2", "classnames": "^2.5.1", @@ -1065,6 +1066,28 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", diff --git a/package.json b/package.json index 90997057..537db588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/live-preview-utils", - "version": "3.2.5", + "version": "3.3.0", "description": "Contentstack provides the Live Preview SDK to establish a communication channel between the various Contentstack SDKs and your website, transmitting live changes to the preview pane.", "type": "module", "types": "dist/legacy/index.d.ts", @@ -86,6 +86,7 @@ "url": "https://github.com/contentstack/live-preview-sdk.git" }, "dependencies": { + "@floating-ui/dom": "^1.7.2", "@preact/compat": "17.1.2", "@preact/signals": "1.2.2", "classnames": "^2.5.1", diff --git a/src/__test__/utils.ts b/src/__test__/utils.ts index 61deaf31..3c0e3063 100644 --- a/src/__test__/utils.ts +++ b/src/__test__/utils.ts @@ -39,7 +39,7 @@ export async function sleep(waitTimeInMs = 100): Promise { export const waitForHoverOutline = async () => { await waitFor(() => { const hoverOutline = document.querySelector( - "[data-testid='visual-builder__hover-outline']" + "[data-testid='visual-builder__hover-outline'][style]" ); expect(hoverOutline).not.toBeNull(); }); diff --git a/src/visualBuilder/components/Tooltip.tsx b/src/visualBuilder/components/Tooltip.tsx new file mode 100644 index 00000000..3bed558f --- /dev/null +++ b/src/visualBuilder/components/Tooltip.tsx @@ -0,0 +1,184 @@ +import { h, cloneElement } from 'preact'; +import { useState, useEffect, useRef } from 'preact/hooks'; +import { + computePosition, + flip, + shift, + offset, + arrow +} from '@floating-ui/dom'; +import { visualBuilderStyles } from '../visualBuilder.style'; +import classNames from 'classnames'; +import { ContentTypeIcon } from './icons'; +import { FieldTypeIconsMap } from '../generators/generateCustomCursor'; +interface TooltipProps { + children: JSX.Element; + content: JSX.Element; + placement?: 'top-start' | 'bottom-start' | 'left-start' | 'right-start'; +} + +/** + * A lightweight, reusable tooltip component for Preact powered by Floating UI. + * + * @param {object} props - The component props. + * @param {preact.ComponentChildren} props.children - The single child element that triggers the tooltip. + * @param {string | preact.VNode} props.content - The content to display inside the tooltip. + * @param {'top'|'bottom'|'left'|'right'} [props.placement='top'] - The desired placement of the tooltip. + */ +const Tooltip = ({ children, content, placement = 'top-start' }: TooltipProps) => { + const [isVisible, setIsVisible] = useState(false); + // Create refs for the trigger and the floating tooltip elements + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const arrowRef = useRef(null); + + const showTooltip = () => setIsVisible(true); + const hideTooltip = () => setIsVisible(false); + + // This effect calculates the tooltip's position whenever it becomes visible + // or if its content or placement changes. + useEffect(() => { + if (!isVisible || !triggerRef.current || !tooltipRef.current) { + return; + } + + const trigger = triggerRef.current as HTMLElement; + const tooltip = tooltipRef.current as HTMLElement; + + computePosition(trigger, tooltip, { + placement, + // Middleware runs in order to modify the position + middleware: [ + offset(8), // Add 8px of space between the trigger and tooltip + flip(), // Flip to the opposite side if it overflows + shift({ padding: 5 }), // Shift to keep it in view + ...(arrowRef.current ? [arrow({ element: arrowRef.current as HTMLElement })] : []), // Handle arrow positioning + ], + }).then(({ x, y, placement, middlewareData }) => { + // Apply the calculated coordinates to the tooltip element + Object.assign(tooltip.style, { + left: `${x}px`, + top: `${y}px`, + }); + + // Position the arrow element + if (middlewareData.arrow && arrowRef.current) { + const { x: arrowX, y: arrowY } = middlewareData.arrow; + const side = placement.split('-')[0]; + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[side] as string; + + const arrowElement = arrowRef.current as HTMLElement; + + // Reset all positioning properties + Object.assign(arrowElement.style, { + left: '', + top: '', + right: '', + bottom: '', + }); + + // For placements like top-start, bottom-start, etc., we want the arrow + // to be centered on the tooltip rather than pointing at the trigger center + if (placement.includes('-start') || placement.includes('-end')) { + const tooltipRect = tooltip.getBoundingClientRect(); + + if (side === 'top' || side === 'bottom') { + // For top/bottom placements, center the arrow horizontally + arrowElement.style.left = `${14}px`; // 4px = half arrow width + if (arrowY != null) { + arrowElement.style.top = `${arrowY}px`; + } + } else { + // For left/right placements, center the arrow vertically + arrowElement.style.top = `${tooltipRect.height / 2 - 4}px`; // 4px = half arrow height + if (arrowX != null) { + arrowElement.style.left = `${arrowX}px`; + } + } + } else { + // For regular placements (top, bottom, left, right), use floating-ui's positioning + if (arrowX != null) { + arrowElement.style.left = `${arrowX}px`; + } + if (arrowY != null) { + arrowElement.style.top = `${arrowY}px`; + } + } + + // Position arrow to overlap the tooltip's border + (arrowElement.style as any)[staticSide] = '-4px'; + } + }); + + }, [isVisible, placement, content]); + + // We need to clone the child element to attach our ref and event listeners. + // This ensures we don't wrap the child in an extra
. + const triggerWithListeners = cloneElement(children, { + ref: triggerRef, + onMouseEnter: showTooltip, + onMouseLeave: hideTooltip, + onFocus: showTooltip, + onBlur: hideTooltip, + 'aria-describedby': 'lightweight-tooltip' // for accessibility + }); + + return ( + <> + {triggerWithListeners} + {isVisible && ( + + )} + + ); +}; + +function ToolbarTooltipContent({contentTypeName, referenceFieldName}: {contentTypeName: string, referenceFieldName: string}) { + return ( +
+ { + contentTypeName && ( +
+ +

{contentTypeName}

+
+ ) + } + { + referenceFieldName && ( +
+
+

{referenceFieldName}

+
+ ) + } +
+ ) +} + +export function ToolbarTooltip({children, data, disabled = false}: {children: JSX.Element, data: {contentTypeName: string, referenceFieldName: string}, disabled?: boolean}) { + if (disabled) { + return children; + } + const { contentTypeName, referenceFieldName } = data; + return ( + }> + {children} + + ) +} + +export default Tooltip; \ No newline at end of file diff --git a/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx b/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx index adbd2ec4..2f637bd4 100644 --- a/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx +++ b/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx @@ -2,14 +2,118 @@ import { waitFor } from "@testing-library/preact"; import FieldLabelWrapperComponent from "../fieldLabelWrapper"; import { CslpData } from "../../../cslp/types/cslp.types"; import { VisualBuilderCslpEventDetails } from "../../types/visualBuilder.types"; -import { VisualBuilderPostMessageEvents } from "../../utils/types/postMessage.types"; import { singleLineFieldSchema } from "../../../__test__/data/fields"; import { asyncRender } from "../../../__test__/utils"; import { isFieldDisabled } from "../../utils/isFieldDisabled"; import { FieldSchemaMap } from "../../utils/fieldSchemaMap"; import { getEntryPermissionsCached } from "../../utils/getEntryPermissionsCached"; +import visualBuilderPostMessage from "../../utils/visualBuilderPostMessage"; import React from "preact/compat"; +// All mocks +vi.mock("../Tooltip", () => ({ + ToolbarTooltip: ({ children, data, disabled }: any) => ( +
+ {children} +
+ ) +})); + +vi.mock("../../utils/fieldSchemaMap", () => ({ + FieldSchemaMap: { + getFieldSchema: vi.fn().mockResolvedValue({ + display_name: "Field 0", + data_type: "text", + field_metadata: {}, + uid: "test_field" + }), + }, +})); + +vi.mock("../../utils/visualBuilderPostMessage", () => ({ + default: { + send: vi.fn().mockImplementation((eventName: string, fields: any) => { + if (eventName === "GET_FIELD_DISPLAY_NAMES") { + // Always return display names for all requested fields + const result: Record = {}; + fields.forEach((field: any) => { + if (field.cslpValue === "mockFieldCslp") { + result[field.cslpValue] = "Field 0"; + } else if (field.cslpValue === "contentTypeUid.entryUid.locale.parentPath1") { + result[field.cslpValue] = "Field 1"; + } else if (field.cslpValue === "contentTypeUid.entryUid.locale.parentPath2") { + result[field.cslpValue] = "Field 2"; + } else if (field.cslpValue === "contentTypeUid.entryUid.locale.parentPath3") { + result[field.cslpValue] = "Field 3"; + } else { + result[field.cslpValue] = field.cslpValue; // fallback + } + }); + return Promise.resolve(result); + } else if(eventName === "GET_CONTENT_TYPE_NAME") { + return Promise.resolve({ + contentTypeName: "Page CT", + }); + } else if(eventName === "REFERENCE_MAP") { + return Promise.resolve({ + "mockEntryUid": [ + { + contentTypeUid: "mockContentTypeUid", + contentTypeTitle: "Page CT", + referenceFieldName: "Reference Field", + } + ] + }); + } + return Promise.resolve({}); + }), + }, +})); + +vi.mock("../../utils/isFieldDisabled", () => ({ + isFieldDisabled: vi.fn().mockReturnValue({ isDisabled: false }), +})); + +vi.mock("../../../cslp", () => ({ + extractDetailsFromCslp: vi.fn().mockImplementation((path) => { + return { + content_type_uid: "mockContentTypeUid", + fieldPath: path, + cslpValue: path + }; + }), +})); + +vi.mock("../../utils/getEntryPermissionsCached", () => ({ + getEntryPermissionsCached: vi.fn().mockResolvedValue({ + create: true, + read: true, + update: true, + delete: true, + publish: true, + }), +})); + +vi.mock("../generators/generateCustomCursor", () => ({ + getFieldIcon: vi.fn().mockReturnValue("mock-icon"), + FieldTypeIconsMap: { + reference: "reference-icon", + }, +})); + +vi.mock("../visualBuilder.style", () => ({ + visualBuilderStyles: vi.fn().mockReturnValue({}), +})); + +vi.mock("../../utils/errorHandling", () => ({ + hasPostMessageError: vi.fn().mockReturnValue(false), +})); + const DISPLAY_NAMES = { mockFieldCslp: "Field 0", parentPath1: "Field 1", @@ -24,68 +128,6 @@ const PARENT_PATHS = [ `${pathPrefix}.parentPath3`, ]; -vi.mock("../../utils/fieldSchemaMap", () => { - return { - FieldSchemaMap: { - getFieldSchema: vi - .fn() - .mockImplementation((content_type_uid, fieldPath) => { - return singleLineFieldSchema; - }), - }, - }; -}); - -vi.mock("../../utils/visualBuilderPostMessage", async () => { - return { - default: { - send: vi - .fn() - .mockImplementation((eventName: string, fields: CslpData[]) => { - if ( - eventName === - VisualBuilderPostMessageEvents.GET_FIELD_DISPLAY_NAMES - ) { - // TODO there is some issue with mocking extractCslpDetails or - // the way it works with the mock cslp values, needs more investigation - // const names: Record = {}; - // fields.forEach((field) => { - // names[field.cslpValue] = - // /** @ts-expect-error - display name will be there */ - // DISPLAY_NAMES[field.cslpValue]; - // }); - // NOTE UGLY hack for now - if (fields.length === 1) { - return Promise.resolve({ - [fields[0].cslpValue]: - DISPLAY_NAMES.mockFieldCslp, - }); - } - const names = { - mockFieldCslp: "Field 0", - [PARENT_PATHS[0]]: DISPLAY_NAMES.parentPath1, - [PARENT_PATHS[1]]: DISPLAY_NAMES.parentPath2, - [PARENT_PATHS[2]]: DISPLAY_NAMES.parentPath3, - }; - return Promise.resolve(names); - } - return Promise.resolve({}); - }), - on: vi.fn(), - }, - }; -}); - -vi.mock("../../utils/isFieldDisabled", () => ({ - isFieldDisabled: vi.fn().mockReturnValue({ isDisabled: false }), -})); - -vi.mock("../../../cslp", () => ({ - extractDetailsFromCslp: vi.fn().mockImplementation((path) => { - return { content_type_uid: "mockContentTypeUid", fieldPath: path }; - }), -})); - describe("FieldLabelWrapperComponent", () => { beforeEach(() => { vi.mocked(isFieldDisabled).mockReturnValue({ @@ -93,6 +135,43 @@ describe("FieldLabelWrapperComponent", () => { // @ts-expect-error - reason is an unexported literal reason: "", }); + + // Reset the mock implementation to the default one + vi.mocked(visualBuilderPostMessage!.send).mockImplementation((eventName: string, fields: any) => { + if (eventName === "GET_FIELD_DISPLAY_NAMES") { + // Always return display names for all requested fields + const result: Record = {}; + fields.forEach((field: any) => { + if (field.cslpValue === "mockFieldCslp") { + result[field.cslpValue] = "Field 0"; + } else if (field.cslpValue === "contentTypeUid.entryUid.locale.parentPath1") { + result[field.cslpValue] = "Field 1"; + } else if (field.cslpValue === "contentTypeUid.entryUid.locale.parentPath2") { + result[field.cslpValue] = "Field 2"; + } else if (field.cslpValue === "contentTypeUid.entryUid.locale.parentPath3") { + result[field.cslpValue] = "Field 3"; + } else { + result[field.cslpValue] = field.cslpValue; // fallback + } + }); + return Promise.resolve(result); + } else if(eventName === "GET_CONTENT_TYPE_NAME") { + return Promise.resolve({ + contentTypeName: "Page CT", + }); + } else if(eventName === "REFERENCE_MAP") { + return Promise.resolve({ + "mockEntryUid": [ + { + contentTypeUid: "mockContentTypeUid", + contentTypeTitle: "Page CT", + referenceFieldName: "Reference Field", + } + ] + }); + } + return Promise.resolve({}); + }); }); afterEach(() => { @@ -100,7 +179,7 @@ describe("FieldLabelWrapperComponent", () => { }); const mockFieldMetadata: CslpData = { - entry_uid: "", + entry_uid: "mockEntryUid", content_type_uid: "mockContentTypeUid", cslpValue: "mockFieldCslp", locale: "", @@ -137,16 +216,9 @@ describe("FieldLabelWrapperComponent", () => { /> ); - const currentField = await findByText(DISPLAY_NAMES.mockFieldCslp); + const currentField = await findByText(DISPLAY_NAMES.mockFieldCslp, {}, { timeout: 15000 }); expect(currentField).toBeVisible(); - - const parentPath1 = await findByText(DISPLAY_NAMES.parentPath1); - expect(parentPath1).toBeInTheDocument(); - const parentPath2 = await findByText(DISPLAY_NAMES.parentPath2); - expect(parentPath2).toBeInTheDocument(); - const parentPath3 = await findByText(DISPLAY_NAMES.parentPath3); - expect(parentPath3).toBeInTheDocument(); - }); + }, { timeout: 20000 }); test("displays current field icon", async () => { const { findByTestId } = await asyncRender( @@ -158,8 +230,8 @@ describe("FieldLabelWrapperComponent", () => { /> ); - const caretIcon = await findByTestId("visual-builder__field-icon"); - expect(caretIcon).toBeInTheDocument(); + const fieldIcon = await findByTestId("visual-builder__field-icon"); + expect(fieldIcon).toBeInTheDocument(); }); test("renders with correct class when field is disabled", async () => { @@ -229,4 +301,76 @@ describe("FieldLabelWrapperComponent", () => { mockEntryPermissions ); }); + + test("renders ToolbarTooltip component with correct data", async () => { + const { findByTestId } = await asyncRender( + + ); + + // Check that the ToolbarTooltip wrapper is rendered + const tooltipWrapper = await findByTestId("toolbar-tooltip", { timeout: 15000 }); + expect(tooltipWrapper).toBeInTheDocument(); + + // Check that the main field label wrapper is rendered + const fieldLabelWrapper = await findByTestId("visual-builder__focused-toolbar__field-label-wrapper", { timeout: 15000 }); + expect(fieldLabelWrapper).toBeInTheDocument(); + }, { timeout: 20000 }); + + test("does not render reference icon when isReference is false", async () => { + const { container } = await asyncRender( + + ); + + await waitFor(() => { + const referenceIconContainer = container.querySelector(".visual-builder__reference-icon-container"); + expect(referenceIconContainer).not.toBeInTheDocument(); + }); + }); + + test("renders with correct hovered cslp data attribute", async () => { + const { findByTestId } = await asyncRender( + + ); + + const fieldLabelWrapper = await findByTestId("visual-builder__focused-toolbar__field-label-wrapper"); + expect(fieldLabelWrapper).toHaveAttribute("data-hovered-cslp", mockFieldMetadata.cslpValue); + }); + + + test("does not render ContentTypeIcon when loading", async () => { + // Mock the display names to never resolve to simulate loading state + vi.mocked(visualBuilderPostMessage!.send).mockImplementation(() => { + return new Promise(() => {}); // Never resolves + }); + + const { container } = await asyncRender( + + ); + + // Wait a bit to ensure the component has time to render + await new Promise(resolve => setTimeout(resolve, 100)); + + const contentTypeIcon = container.querySelector(".visual-builder__content-type-icon"); + expect(contentTypeIcon).not.toBeInTheDocument(); + }); }); diff --git a/src/visualBuilder/components/fieldLabelWrapper.tsx b/src/visualBuilder/components/fieldLabelWrapper.tsx index 1b7cbca2..99c4003b 100644 --- a/src/visualBuilder/components/fieldLabelWrapper.tsx +++ b/src/visualBuilder/components/fieldLabelWrapper.tsx @@ -6,15 +6,25 @@ import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import { FieldSchemaMap } from "../utils/fieldSchemaMap"; import { isFieldDisabled } from "../utils/isFieldDisabled"; import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; -import { CaretIcon, InfoIcon } from "./icons"; +import { CaretIcon, CaretRightIcon, InfoIcon } from "./icons"; import { LoadingIcon } from "./icons/loading"; -import { getFieldIcon } from "../generators/generateCustomCursor"; +import { FieldTypeIconsMap, getFieldIcon } from "../generators/generateCustomCursor"; import { uniqBy } from "lodash-es"; import { visualBuilderStyles } from "../visualBuilder.style"; import { CslpError } from "./CslpError"; import { hasPostMessageError } from "../utils/errorHandling"; import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; +import { ContentTypeIcon } from "./icons"; +import { ToolbarTooltip } from "./Tooltip"; + +interface ReferenceParentMap { + [entryUid: string]: { + contentTypeUid: string; + contentTypeTitle: string; + referenceFieldName: string; + }[] +} async function getFieldDisplayNames(fieldMetadata: CslpData[]) { const result = await visualBuilderPostMessage?.send<{ @@ -23,6 +33,31 @@ async function getFieldDisplayNames(fieldMetadata: CslpData[]) { return result; } +async function getContentTypeName(contentTypeUid: string) { + try { + const result = await visualBuilderPostMessage?.send<{ + contentTypeName: string; + }>(VisualBuilderPostMessageEvents.GET_CONTENT_TYPE_NAME, { + content_type_uid: contentTypeUid, + }); + return result?.contentTypeName; + } catch(e) { + console.warn("[getFieldLabelWrapper] Error getting content type name", e); + return ""; + } +} + +async function getReferenceParentMap() { + try { + const result = await visualBuilderPostMessage?.send(VisualBuilderPostMessageEvents.REFERENCE_MAP, {}) ?? {}; + return result; + } catch(e) { + console.warn("[getFieldLabelWrapper] Error getting reference parent map", e); + return {}; + } + +} + interface FieldLabelWrapperProps { fieldMetadata: CslpData; eventDetails: VisualBuilderCslpEventDetails; @@ -32,10 +67,14 @@ interface FieldLabelWrapperProps { interface ICurrentField { text: string; + contentTypeName: string; icon: JSX.Element; prefixIcon: any; disabled: boolean; isVariant: boolean; + isReference: boolean; + referenceFieldName: string; + parentContentTypeName: string; } function FieldLabelWrapperComponent( @@ -44,15 +83,19 @@ function FieldLabelWrapperComponent( const { eventDetails } = props; const [currentField, setCurrentField] = useState({ text: "", + contentTypeName: "", icon: , prefixIcon: null, disabled: false, isVariant: false, + isReference: false, + referenceFieldName: "", + parentContentTypeName: "", }); const [displayNames, setDisplayNames] = useState>( {} ); - const [displayNamesLoading, setDisplayNamesLoading] = useState(true); + const [dataLoading, setDataLoading] = useState(true); const [error, setError] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -64,7 +107,7 @@ function FieldLabelWrapperComponent( useEffect(() => { const fetchData = async () => { - setDisplayNamesLoading(true); + setDataLoading(true); const allPaths = uniqBy( [ props.fieldMetadata, @@ -74,14 +117,41 @@ function FieldLabelWrapperComponent( ], "cslpValue" ); - const displayNames = await getFieldDisplayNames(allPaths); - const fieldSchema = await FieldSchemaMap.getFieldSchema( - props.fieldMetadata.content_type_uid, - props.fieldMetadata.fieldPath - ); + const [displayNames, fieldSchema, contentTypeName, referenceParentMap] = await Promise.all([ + getFieldDisplayNames(allPaths), + FieldSchemaMap.getFieldSchema( + props.fieldMetadata.content_type_uid, + props.fieldMetadata.fieldPath + ), + getContentTypeName( + props.fieldMetadata.content_type_uid + ), + getReferenceParentMap() + ]); + const entryUid = props.fieldMetadata.entry_uid; + + const referenceData = referenceParentMap[entryUid]; + const isReference = !!referenceData; + + let referenceFieldName = referenceData ? referenceData[0].referenceFieldName : ""; + let parentContentTypeName = referenceData ? referenceData[0].contentTypeTitle : ""; + + if(isReference) { + 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 (hasPostMessageError(displayNames) || !fieldSchema) { - setDisplayNamesLoading(false); + setDataLoading(false); setError(true); return; @@ -107,6 +177,7 @@ function FieldLabelWrapperComponent( setCurrentField({ text: currentFieldDisplayName, + contentTypeName: contentTypeName ?? "", icon: fieldDisabled ? (
), + isReference, prefixIcon: getFieldIcon(fieldSchema), disabled: fieldDisabled, + referenceFieldName, + parentContentTypeName, isVariant: isVariant, }); @@ -132,11 +206,15 @@ function FieldLabelWrapperComponent( setDisplayNames(displayNames); } if (Object.keys(displayNames || {})?.length === allPaths.length) { - setDisplayNamesLoading(false); + setDataLoading(false); } }; - fetchData(); + try { + fetchData(); + } catch(e) { + console.warn("[getFieldLabelWrapper] Error fetching field label data", e); + } }, [props]); const onParentPathClick = (cslp: string) => { @@ -150,7 +228,7 @@ function FieldLabelWrapperComponent( function getCurrentFieldIcon() { if (error) { return null; - } else if (displayNamesLoading) { + } else if (dataLoading) { return ; } else { return currentField.icon; @@ -166,102 +244,145 @@ function FieldLabelWrapperComponent( ] )} > -
setIsDropdownOpen((prev) => !prev)} - data-testid="visual-builder__focused-toolbar__field-label-wrapper" - > - - {props.parentPaths.map((path, index) => ( - ))} -
+ {props.parentPaths.map((path, index) => ( + + ))} +
+
); } diff --git a/src/visualBuilder/components/icons/index.tsx b/src/visualBuilder/components/icons/index.tsx index 9142f794..d209df86 100644 --- a/src/visualBuilder/components/icons/index.tsx +++ b/src/visualBuilder/components/icons/index.tsx @@ -1,4 +1,6 @@ import React from "preact/compat"; +import { visualBuilderStyles } from "../../visualBuilder.style"; +import classNames from "classnames"; type IconProps = { disabled?: boolean; @@ -307,4 +309,36 @@ export function WarningOctagonIcon(): JSX.Element { /> ); +} + +export function ContentTypeIcon(): JSX.Element { + return ( +
+ + + + + +
+ ); +} + +export function CaretRightIcon(): JSX.Element { + return ( +
+ + + +
+ + ) } \ No newline at end of file diff --git a/src/visualBuilder/generators/generateOverlay.tsx b/src/visualBuilder/generators/generateOverlay.tsx index ce5da684..e09e9be9 100644 --- a/src/visualBuilder/generators/generateOverlay.tsx +++ b/src/visualBuilder/generators/generateOverlay.tsx @@ -216,6 +216,7 @@ interface HideOverlayParams } export function hideOverlay(params: HideOverlayParams): void { + VisualBuilder.VisualBuilderGlobalState.value.isFocussed = false; const focusElementObserver = VisualBuilder.VisualBuilderGlobalState.value.focusElementObserver; if (focusElementObserver) { diff --git a/src/visualBuilder/generators/generateToolbar.tsx b/src/visualBuilder/generators/generateToolbar.tsx index ef521837..725b2c98 100644 --- a/src/visualBuilder/generators/generateToolbar.tsx +++ b/src/visualBuilder/generators/generateToolbar.tsx @@ -7,21 +7,26 @@ import { TOOLBAR_EDGE_BUFFER, TOP_EDGE_BUFFER, } from "../utils/constants"; -import { FieldSchemaMap } from "../utils/fieldSchemaMap"; -import { isFieldDisabled } from "../utils/isFieldDisabled"; - import FieldToolbarComponent from "../components/FieldToolbar"; import { render } from "preact"; import FieldLabelWrapperComponent from "../components/fieldLabelWrapper"; import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; +import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; +import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; export function appendFocusedToolbar( eventDetails: VisualBuilderCslpEventDetails, focusedToolbarElement: HTMLDivElement, hideOverlay: () => void, - isVariant: boolean = false + isVariant: boolean = false, + options?: { + isHover?: boolean; + } ): void { - appendFieldPathDropdown(eventDetails, focusedToolbarElement); + appendFieldPathDropdown(eventDetails, focusedToolbarElement, options); + if(options?.isHover) { + return; + } appendFieldToolbar( eventDetails, focusedToolbarElement, @@ -34,12 +39,16 @@ export async function appendFieldToolbar( eventDetails: VisualBuilderCslpEventDetails, focusedToolbarElement: HTMLDivElement, hideOverlay: () => void, - isVariant: boolean = false + isVariant: boolean = false, + options?: { + isHover?: boolean; + } ): Promise { + const { isHover } = options || {}; if ( focusedToolbarElement.querySelector( ".visual-builder__focused-toolbar__multiple-field-toolbar" - ) + ) && !isHover ) return; const entryPermissions = await getEntryPermissionsCached({ @@ -62,15 +71,30 @@ export async function appendFieldToolbar( export function appendFieldPathDropdown( eventDetails: VisualBuilderCslpEventDetails, - focusedToolbarElement: HTMLDivElement + focusedToolbarElement: HTMLDivElement, + options?: { + isHover?: boolean; + } ): void { - if ( - document.querySelector( - ".visual-builder__focused-toolbar__field-label-wrapper" - ) - ) - return; + const { isHover } = options || {}; + const fieldLabelWrapper = document.querySelector( + ".visual-builder__focused-toolbar__field-label-wrapper" + ) as HTMLDivElement | null; const { editableElement: targetElement, fieldMetadata } = eventDetails; + + if (fieldLabelWrapper) { + if(isHover) { + const fieldCslp = fieldLabelWrapper.getAttribute("data-hovered-cslp"); + if(fieldCslp === fieldMetadata.cslpValue) { + return; + } else { + removeFieldToolbar(focusedToolbarElement); + } + } else { + return; + } + } + const targetElementDimension = targetElement.getBoundingClientRect(); const distanceFromTop = @@ -148,3 +172,18 @@ function collectParentCSLPPaths( return cslpPaths; } + +export function removeFieldToolbar(toolbar: Element) { + toolbar.innerHTML = ""; + const toolbarEvents = [ + VisualBuilderPostMessageEvents.DELETE_INSTANCE, + VisualBuilderPostMessageEvents.UPDATE_DISCUSSION_ID, + ]; + toolbarEvents.forEach((event) => { + //@ts-expect-error - We are accessing private method here, but it is necessary to clean up the event listeners. + if (visualBuilderPostMessage?.requestMessageHandlers?.has(event)) { + //@ts-expect-error - We are accessing private method here, but it is necessary to clean up the event listeners. + visualBuilderPostMessage?.unregisterEvent?.(event); + } + }); +} diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts index 2843bf54..6b6d005a 100644 --- a/src/visualBuilder/index.ts +++ b/src/visualBuilder/index.ts @@ -67,6 +67,8 @@ interface VisualBuilderGlobalStateImpl { locale: string; variant: string | null; focusElementObserver: MutationObserver | null; + referenceParentMap: Record; + isFocussed: boolean; } let threadsPayload: IThreadDTO[] = []; @@ -88,6 +90,8 @@ export class VisualBuilder { locale: Config.get().stackDetails.masterLocale || "en-us", variant: null, focusElementObserver: null, + referenceParentMap: {}, + isFocussed: false, }); private handlePositionChange(editableElement: HTMLElement) { @@ -422,6 +426,8 @@ export class VisualBuilder { locale: "en-us", variant: null, focusElementObserver: null, + referenceParentMap: {}, + isFocussed: false, }; // Remove DOM elements diff --git a/src/visualBuilder/listeners/index.ts b/src/visualBuilder/listeners/index.ts index 57448c0e..2e025677 100644 --- a/src/visualBuilder/listeners/index.ts +++ b/src/visualBuilder/listeners/index.ts @@ -4,6 +4,7 @@ import handleMouseHover, { hideCustomCursor, hideHoverOutline, showCustomCursor, + showHoverToolbar, } from "./mouseHover"; import EventListenerHandlerParams from "./types"; @@ -35,6 +36,8 @@ const eventHandlers = { overlayWrapper: params.overlayWrapper, visualBuilderContainer: params.visualBuilderContainer, customCursor: params.customCursor, + resizeObserver: params.resizeObserver, + focusedToolbar: params.focusedToolbar, }); }, mouseleave: (params: AddEventListenersParams) => () => { @@ -55,7 +58,7 @@ export function addEventListeners(params: AddEventListenersParams): void { eventListenersMap.set("click", clickHandler as EventListener); eventListenersMap.set("mousemove", mousemoveHandler as EventListener); eventListenersMap.set("mouseleave", mouseleaveHandler); - eventListenersMap.set("mouseenter", mouseenterHandler); + eventListenersMap.set("mouseenter", mouseenterHandler as EventListener); window.addEventListener("click", clickHandler, { capture: true }); window.addEventListener("mousemove", mousemoveHandler); diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts index d9b4a595..d81a1ab7 100644 --- a/src/visualBuilder/listeners/mouseClick.ts +++ b/src/visualBuilder/listeners/mouseClick.ts @@ -32,7 +32,7 @@ import { fixSvgXPath } from "../utils/collabUtils"; import { v4 as uuidV4 } from "uuid"; import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; -type HandleBuilderInteractionParams = Omit< +export type HandleBuilderInteractionParams = Omit< EventListenerHandlerParams, "eventDetails" | "customCursor" > & { reEvaluate?: boolean }; @@ -45,7 +45,7 @@ type AddFocusOverlayParams = Pick< type AddFocusedToolbarParams = Pick< EventListenerHandlerParams, "eventDetails" | "focusedToolbar" -> & { hideOverlay: () => void; isVariant: boolean }; +> & { hideOverlay: () => void; isVariant: boolean, options?: { isHover?: boolean } }; function addOverlay(params: AddFocusOverlayParams) { if (!params.overlayWrapper || !params.editableElement) return; @@ -67,11 +67,12 @@ export function addFocusedToolbar(params: AddFocusedToolbarParams): void { params.eventDetails, params.focusedToolbar, params.hideOverlay, - params.isVariant + params.isVariant, + params.options ); } -async function handleBuilderInteraction( +export async function handleBuilderInteraction( params: HandleBuilderInteractionParams ): Promise { const eventTarget = params.event.target as HTMLElement | null; @@ -270,6 +271,7 @@ function addOverlayAndToolbar( editableElement: Element, isVariant: boolean ) { + VisualBuilder.VisualBuilderGlobalState.value.isFocussed = true; addOverlay({ overlayWrapper: params.overlayWrapper, resizeObserver: params.resizeObserver, diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts index 2495e827..e1cdc0e9 100644 --- a/src/visualBuilder/listeners/mouseHover.ts +++ b/src/visualBuilder/listeners/mouseHover.ts @@ -1,4 +1,4 @@ -import { throttle } from "lodash-es"; +import { debounce, throttle } from "lodash-es"; import { getCsDataOfElement } from "../utils/getCsDataOfElement"; import { removeAddInstanceButtons } from "../utils/multipleElementAddButton"; import { generateCustomCursor } from "../generators/generateCustomCursor"; @@ -15,16 +15,31 @@ import Config from "../../configManager/configManager"; import { isCollabThread } from "../generators/generateThread"; import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; import { EntryPermissions } from "../utils/getEntryPermissions"; +import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; +import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; +import { HandleBuilderInteractionParams } from "./mouseClick"; +import { appendFieldPathDropdown, removeFieldToolbar } from "../generators/generateToolbar"; +import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; +import { CslpData } from "../../cslp/types/cslp.types"; const config = Config.get(); export interface HandleMouseHoverParams extends Pick< EventListenerHandlerParams, - "event" | "overlayWrapper" | "visualBuilderContainer" + "event" | "overlayWrapper" | "visualBuilderContainer" | "focusedToolbar" | "resizeObserver" > { customCursor: HTMLDivElement | null; } +interface AddOutlineParams { + editableElement: Element; + eventDetails: VisualBuilderCslpEventDetails; + content_type_uid: string; + fieldPath: string; + fieldDisabled?: boolean; + fieldMetadata: CslpData; +} + function resetCustomCursor(customCursor: HTMLDivElement | null): void { if (customCursor) { generateCustomCursor({ @@ -56,12 +71,47 @@ function handleCursorPosition( } } -function addOutline(editableElement: Element, isFieldDisabled?: boolean): void { +function addOutline(params?: AddOutlineParams): void { + if(!params) { + return; + } + const { editableElement, eventDetails, content_type_uid, fieldPath, fieldMetadata, fieldDisabled } = params; if (!editableElement) return; - - addHoverOutline(editableElement as HTMLElement, isFieldDisabled); + addHoverOutline(editableElement as HTMLElement, fieldDisabled); + FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath).then( + (fieldSchema) => { + let entryAcl: EntryPermissions | undefined; + if (!fieldSchema) return; + getEntryPermissionsCached({ + entryUid: fieldMetadata.entry_uid, + contentTypeUid: fieldMetadata.content_type_uid, + locale: fieldMetadata.locale, + }) + .then((data) => { + entryAcl = data; + }) + .catch((error) => { + console.error( + "[Visual Builder] Error retrieving entry permissions:", + error + ); + }) + .finally(() => { + const { isDisabled: fieldDisabled } = + isFieldDisabled( + fieldSchema, + eventDetails, + entryAcl + ); + addHoverOutline(editableElement, fieldDisabled); + }); + } + ); } +const debouncedAddOutline = debounce(addOutline, 50, { trailing: true }); +const showOutline = (params?: AddOutlineParams): void => debouncedAddOutline(params); + function hideDefaultCursor(): void { if ( document?.body && @@ -118,6 +168,24 @@ export function showCustomCursor(customCursor: HTMLDivElement | null): void { customCursor?.classList.add("visible"); } +const debouncedRenderHoverToolbar = debounce(async (params: HandleBuilderInteractionParams) => { + const eventDetails = getCsDataOfElement(params.event); + if ( + !eventDetails || + !params.overlayWrapper || + !params.visualBuilderContainer || + !params.focusedToolbar + ) { + return; + } + + appendFieldPathDropdown(eventDetails, params.focusedToolbar, { + isHover: true + }); +}, 50, { trailing: true }); + +export const showHoverToolbar = async (params: HandleBuilderInteractionParams) => await debouncedRenderHoverToolbar(params); + function isOverlay(target: HTMLElement): boolean { return target.classList.contains("visual-builder__overlay"); } @@ -128,197 +196,208 @@ function isContentEditable(target: HTMLElement): boolean { return false; } -async function handleMouseHover(params: HandleMouseHoverParams): Promise { - throttle(async (params: HandleMouseHoverParams) => { - const eventDetails = getCsDataOfElement(params.event); - const eventTarget = params.event.target as HTMLElement | null; +function isFieldPathDropdown(target: HTMLElement): boolean { + return target.classList.contains("visual-builder__focused-toolbar__field-label-wrapper") || target.classList.contains("visual-builder__focused-toolbar__field-label-wrapper__current-field"); +} + +const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { + const eventDetails = getCsDataOfElement(params.event); + const eventTarget = params.event.target as HTMLElement | null; - if (config?.collab.enable && config?.collab.pauseFeedback) { + if (config?.collab.enable && config?.collab.pauseFeedback) { + hideCustomCursor(params.customCursor); + return; + } + if (!eventDetails) { + if ( + eventTarget && + (isOverlay(eventTarget) || + isContentEditable(eventTarget) || + isCollabThread(eventTarget)) + ) { + handleCursorPosition(params.event, params.customCursor); hideCustomCursor(params.customCursor); return; } - if (!eventDetails) { - if ( - eventTarget && - (isOverlay(eventTarget) || - isContentEditable(eventTarget) || - isCollabThread(eventTarget)) - ) { - handleCursorPosition(params.event, params.customCursor); - hideCustomCursor(params.customCursor); - return; - } - if (!config?.collab.enable) { - resetCustomCursor(params.customCursor); - } - removeAddInstanceButtons({ - eventTarget: params.event.target, - visualBuilderContainer: params.visualBuilderContainer, + if( + eventTarget && + isFieldPathDropdown(eventTarget) + ) { + params.customCursor && hideCustomCursor(params.customCursor); + showOutline(); + showHoverToolbar({ + event: params.event, overlayWrapper: params.overlayWrapper, + visualBuilderContainer: params.visualBuilderContainer, + previousSelectedEditableDOM: + VisualBuilder.VisualBuilderGlobalState.value + .previousSelectedEditableDOM, + focusedToolbar: params.focusedToolbar, + resizeObserver: params.resizeObserver, }); - handleCursorPosition(params.event, params.customCursor); - if (config?.collab.enable && config?.collab.isFeedbackMode) { - showCustomCursor(params.customCursor); - collabCustomCursor(params.customCursor); - } - return; } - - const { editableElement, fieldMetadata } = eventDetails; - const { content_type_uid, fieldPath } = fieldMetadata; - - if ( - VisualBuilder.VisualBuilderGlobalState.value - .previousSelectedEditableDOM && - VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM.isSameNode( - editableElement - ) - ) { - hideCustomCursor(params.customCursor); - return; + if (!config?.collab.enable) { + resetCustomCursor(params.customCursor); + } + removeAddInstanceButtons({ + eventTarget: params.event.target, + visualBuilderContainer: params.visualBuilderContainer, + overlayWrapper: params.overlayWrapper, + }); + handleCursorPosition(params.event, params.customCursor); + if (config?.collab.enable && config?.collab.isFeedbackMode) { + showCustomCursor(params.customCursor); + collabCustomCursor(params.customCursor); } + return; + } - if (params.customCursor) { - const elementUnderCursor = document.elementFromPoint( - params.event.clientX, - params.event.clientY - ); - if (elementUnderCursor) { - if ( - elementUnderCursor.nodeName === "A" || - elementUnderCursor.nodeName === "BUTTON" - ) { - elementUnderCursor.classList.add( - visualBuilderStyles()["visual-builder__no-cursor-style"] - ); - } - } + const { editableElement, fieldMetadata } = eventDetails; + const { content_type_uid, fieldPath } = fieldMetadata; - if (config?.collab.enable && config?.collab.isFeedbackMode) { - collabCustomCursor(params.customCursor); - handleCursorPosition(params.event, params.customCursor); - showCustomCursor(params.customCursor); - return; - } else if ( - config?.collab.enable && - !config?.collab.isFeedbackMode - ) { - hideCustomCursor(params.customCursor); - return; - } + if ( + VisualBuilder.VisualBuilderGlobalState.value + .previousSelectedEditableDOM && + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM.isSameNode( + editableElement + ) + ) { + hideCustomCursor(params.customCursor); + return; + } + if (params.customCursor) { + const elementUnderCursor = document.elementFromPoint( + params.event.clientX, + params.event.clientY + ); + if (elementUnderCursor) { if ( - VisualBuilder.VisualBuilderGlobalState.value - .previousHoveredTargetDOM !== editableElement + elementUnderCursor.nodeName === "A" || + elementUnderCursor.nodeName === "BUTTON" ) { - resetCustomCursor(params.customCursor); - removeAddInstanceButtons({ - eventTarget: params.event.target, - visualBuilderContainer: params.visualBuilderContainer, - overlayWrapper: params.overlayWrapper, - }); + elementUnderCursor.classList.add( + visualBuilderStyles()["visual-builder__no-cursor-style"] + ); } + } - if (!FieldSchemaMap.hasFieldSchema(content_type_uid, fieldPath)) { - generateCustomCursor({ - fieldType: "loading", - customCursor: params.customCursor, - }); - } - - /** - * We called it seperately inside the code block to ensure that - * the code will not wait for the promise to resolve. - * If we get a cache miss, we will send a message to the iframe - * without blocking the code. - */ - FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath).then( - (fieldSchema) => { - if (!fieldSchema) return; - - let entryAcl: EntryPermissions | undefined; - getEntryPermissionsCached({ - entryUid: fieldMetadata.entry_uid, - contentTypeUid: fieldMetadata.content_type_uid, - locale: fieldMetadata.locale, - }) - .then((data) => { - entryAcl = data; - }) - .catch((error) => { - console.error( - "[Visual Builder] Error retrieving entry permissions:", - error - ); - }) - .finally(() => { - if (!params.customCursor) return; - const { isDisabled: fieldDisabled } = - isFieldDisabled( - fieldSchema, - eventDetails, - entryAcl - ); - const fieldType = getFieldType(fieldSchema); - generateCustomCursor({ - fieldType, - customCursor: params.customCursor, - fieldDisabled, - }); - }); - } - ); - + if (config?.collab.enable && config?.collab.isFeedbackMode) { + collabCustomCursor(params.customCursor); handleCursorPosition(params.event, params.customCursor); showCustomCursor(params.customCursor); + return; + } else if ( + config?.collab.enable && + !config?.collab.isFeedbackMode + ) { + hideCustomCursor(params.customCursor); + return; } if ( - !editableElement.classList.contains(VB_EmptyBlockParentClass) && - !editableElement.classList.contains("visual-builder__empty-block") + VisualBuilder.VisualBuilderGlobalState.value + .previousHoveredTargetDOM !== editableElement ) { - addOutline(editableElement); - FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath).then( - (fieldSchema) => { - let entryAcl: EntryPermissions | undefined; - if (!fieldSchema) return; - getEntryPermissionsCached({ - entryUid: fieldMetadata.entry_uid, - contentTypeUid: fieldMetadata.content_type_uid, - locale: fieldMetadata.locale, + resetCustomCursor(params.customCursor); + removeAddInstanceButtons({ + eventTarget: params.event.target, + visualBuilderContainer: params.visualBuilderContainer, + overlayWrapper: params.overlayWrapper, + }); + } + + if (!FieldSchemaMap.hasFieldSchema(content_type_uid, fieldPath)) { + generateCustomCursor({ + fieldType: "loading", + customCursor: params.customCursor, + }); + } + + /** + * We called it seperately inside the code block to ensure that + * the code will not wait for the promise to resolve. + * If we get a cache miss, we will send a message to the iframe + * without blocking the code. + */ + FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath).then( + (fieldSchema) => { + if (!fieldSchema) return; + + let entryAcl: EntryPermissions | undefined; + getEntryPermissionsCached({ + entryUid: fieldMetadata.entry_uid, + contentTypeUid: fieldMetadata.content_type_uid, + locale: fieldMetadata.locale, + }) + .then((data) => { + entryAcl = data; }) - .then((data) => { - entryAcl = data; - }) - .catch((error) => { - console.error( - "[Visual Builder] Error retrieving entry permissions:", - error + .catch((error) => { + console.error( + "[Visual Builder] Error retrieving entry permissions:", + error + ); + }) + .finally(() => { + if (!params.customCursor) return; + const { isDisabled: fieldDisabled } = + isFieldDisabled( + fieldSchema, + eventDetails, + entryAcl ); - }) - .finally(() => { - const { isDisabled: fieldDisabled } = - isFieldDisabled( - fieldSchema, - eventDetails, - entryAcl - ); - addOutline(editableElement, fieldDisabled); + const fieldType = getFieldType(fieldSchema); + generateCustomCursor({ + fieldType, + customCursor: params.customCursor, + fieldDisabled, }); - } - ); - } + }); + } + ); - if ( - VisualBuilder.VisualBuilderGlobalState.value - .previousHoveredTargetDOM === editableElement - ) { - return; + handleCursorPosition(params.event, params.customCursor); + showCustomCursor(params.customCursor); + } + + if ( + !editableElement.classList.contains(VB_EmptyBlockParentClass) && + !editableElement.classList.contains("visual-builder__empty-block") + ) { + showOutline({ + editableElement, + eventDetails, + content_type_uid, + fieldPath, + fieldMetadata, + }); + const isFocussed= VisualBuilder.VisualBuilderGlobalState.value.isFocussed; + if(!isFocussed) { + showHoverToolbar({ + event: params.event, + overlayWrapper: params.overlayWrapper, + visualBuilderContainer: params.visualBuilderContainer, + previousSelectedEditableDOM: + VisualBuilder.VisualBuilderGlobalState.value + .previousSelectedEditableDOM, + focusedToolbar: params.focusedToolbar, + resizeObserver: params.resizeObserver, + }); } + } - VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM = - editableElement; - }, 10)(params); -} + if ( + VisualBuilder.VisualBuilderGlobalState.value + .previousHoveredTargetDOM === editableElement + ) { + return; + } + + VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM = + editableElement; +}, 10); + +const handleMouseHover = async (params: HandleMouseHoverParams): Promise => await throttledMouseHover(params); export default handleMouseHover; diff --git a/src/visualBuilder/utils/__test__/enableInlineEditing.test.ts b/src/visualBuilder/utils/__test__/enableInlineEditing.test.ts index 15995637..886bd5ff 100644 --- a/src/visualBuilder/utils/__test__/enableInlineEditing.test.ts +++ b/src/visualBuilder/utils/__test__/enableInlineEditing.test.ts @@ -51,6 +51,7 @@ vi.mock("../updateFocussedState", () => ({ vi.mock("lodash-es", () => ({ throttle: vi.fn((fn) => fn), + debounce: vi.fn((fn) => fn), })); vi.mock("../handleFieldInput", () => ({ diff --git a/src/visualBuilder/utils/handleIndividualFields.ts b/src/visualBuilder/utils/handleIndividualFields.ts index 9703a607..63be0e51 100644 --- a/src/visualBuilder/utils/handleIndividualFields.ts +++ b/src/visualBuilder/utils/handleIndividualFields.ts @@ -10,13 +10,12 @@ import { handleAddButtonsForMultiple, removeAddInstanceButtons, } from "./multipleElementAddButton"; -import { VisualBuilderPostMessageEvents } from "./types/postMessage.types"; -import visualBuilderPostMessage from "./visualBuilderPostMessage"; import { isFieldMultiple } from "./isFieldMultiple"; import { handleInlineEditableField } from "./handleInlineEditableField"; import { VisualBuilderEditContext } from "./types/index.types"; import { pasteAsPlainText } from "./pasteAsPlainText"; import { getEntryPermissionsCached } from "./getEntryPermissionsCached"; +import { removeFieldToolbar } from "../generators/generateToolbar"; /** * It handles all the fields based on their data type and its "multiple" property. @@ -160,17 +159,6 @@ export function cleanIndividualFieldResidual(elements: { } if (focusedToolbar) { - focusedToolbar.innerHTML = ""; - const toolbarEvents = [ - VisualBuilderPostMessageEvents.DELETE_INSTANCE, - VisualBuilderPostMessageEvents.UPDATE_DISCUSSION_ID, - ]; - toolbarEvents.forEach((event) => { - //@ts-expect-error - We are accessing private method here, but it is necessary to clean up the event listeners. - if (visualBuilderPostMessage?.requestMessageHandlers?.has(event)) { - //@ts-expect-error - We are accessing private method here, but it is necessary to clean up the event listeners. - visualBuilderPostMessage?.unregisterEvent?.(event); - } - }); + removeFieldToolbar(focusedToolbar); } } diff --git a/src/visualBuilder/utils/types/postMessage.types.ts b/src/visualBuilder/utils/types/postMessage.types.ts index 09631d05..e24bd4a5 100644 --- a/src/visualBuilder/utils/types/postMessage.types.ts +++ b/src/visualBuilder/utils/types/postMessage.types.ts @@ -42,10 +42,12 @@ export enum VisualBuilderPostMessageEvents { GET_VARIANT_ID = "get-variant-id", GET_LOCALE = "get-locale", SEND_VARIANT_AND_LOCALE = "send-variant-and-locale", + GET_CONTENT_TYPE_NAME = "get-content-type-name", + REFERENCE_MAP = "get-reference-map", COLLAB_ENABLE = "collab-enable", COLLAB_DATA_UPDATE = "collab-data-update", COLLAB_DISABLE = "collab-disable", COLLAB_THREADS_REMOVE = "collab-threads-remove", COLLAB_THREAD_REOPEN = "collab-thread-reopen", COLLAB_THREAD_HIGHLIGHT = "collab-thread-highlight", -} +} \ No newline at end of file diff --git a/src/visualBuilder/visualBuilder.style.ts b/src/visualBuilder/visualBuilder.style.ts index 4273dd8b..dc4ea46a 100644 --- a/src/visualBuilder/visualBuilder.style.ts +++ b/src/visualBuilder/visualBuilder.style.ts @@ -64,6 +64,48 @@ export function visualBuilderStyles() { cursor: none; } `, + "tooltip-container": css` + position: absolute; + background-color: #767676; + color: white; + padding: 12px; + border-radius: 4px; + font-size: 12px; + line-height: 1.4; + z-index: 1000; + pointer-events: none; + max-width: 250px; + text-align: center; + `, + "tooltip-arrow": css` + position: absolute; + background: #767676; + width: 8px; + height: 8px; + transform: rotate(45deg); + `, + "toolbar-tooltip-content": css` + display: flex; + flex-direction: column; + gap: 4px; + `, + "toolbar-tooltip-content-item": css` + display: flex; + align-items: center; + justify-content: start; + gap: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + p { + margin: 0; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + `, "visual-builder__overlay__wrapper": css` position: absolute; top: 0; @@ -378,7 +420,30 @@ export function visualBuilderStyles() { svg { height: 16px; width: 16px; - margin-right: 5px; + margin-right: 3px; + } + `, + "visual-builder__content-type-icon": css` + svg { + height: 16px; + width: 16px; + margin-right: 3px; + } + `, + "visual-builder__caret-right-icon": css` + svg { + height: 16px; + width: 16px; + } + `, + "visual-builder__reference-icon-container": css` + display: flex; + align-items: center; + + .visual-builder__field-icon { + svg { + margin-right: 0px; + } } `, "visual-builder__focused-toolbar__button-group": css` diff --git a/vitest.config.ts b/vitest.config.ts index a422c34e..d016ee6f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,5 +14,8 @@ export default defineConfig({ }, globals: true, setupFiles: "./vitest.setup.ts", + retry: 2, + testTimeout: 30000, + hookTimeout: 30000, }, });