diff --git a/src/visualBuilder/components/VisualBuilder.tsx b/src/visualBuilder/components/VisualBuilder.tsx index 8cfa4fee..abdefe42 100644 --- a/src/visualBuilder/components/VisualBuilder.tsx +++ b/src/visualBuilder/components/VisualBuilder.tsx @@ -106,6 +106,14 @@ function VisualBuilderComponent(props: VisualBuilderProps): JSX.Element | null { )} data-testid="visual-builder__hover-outline" > +
> = {}; @@ -207,6 +211,7 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => { beforeEach(() => { // Reset all mocks to their default state before each test vi.clearAllMocks(); + clearAllEntryFieldLockInfo(); // Reset isFieldDisabled to default (isFieldDisabled as any).mockReturnValue({ @@ -279,6 +284,7 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => { afterEach(() => { // Clean up field schema cache after each test FieldSchemaMap.clear(); + clearAllEntryFieldLockInfo(); // Clean up DOM after each test to prevent state pollution document.body.innerHTML = ""; }); @@ -329,4 +335,52 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => { "visual-builder__focused-toolbar--field-disabled" ); }); + + test("renders the disabled (locked) class when the field is peer-locked, even without a permission disable", async () => { + // isFieldDisabled stays false (no permission/workflow disable); the peer + // lock alone must drive the same disabled label affordance. + (isFieldDisabled as any).mockReturnValue({ + isDisabled: false, + reason: "", + }); + setEntryFieldLockInfo( + { + entryUid: mockFieldMetadata.entry_uid, + locale: mockFieldMetadata.locale, + }, + { + [mockFieldMetadata.fieldPath]: { + user: { uid: "u1", name: "Ada Lovelace" }, + ttl: "2030-01-01", + isLocked: true, + isOwn: false, + }, + } + ); + + const { container } = render( + + ); + + await act(async () => { + await new Promise((resolve) => + queueMicrotask(() => resolve()) + ); + }); + + const fieldLabel = (await findByTestId( + container as HTMLElement, + "visual-builder__focused-toolbar__field-label-wrapper", + {}, + { timeout: 1000 } + )) as HTMLElement; + expect(fieldLabel).toHaveClass( + "visual-builder__focused-toolbar--field-disabled" + ); + }); }); diff --git a/src/visualBuilder/components/fieldLabelWrapper.tsx b/src/visualBuilder/components/fieldLabelWrapper.tsx index b92a2834..c815f3be 100644 --- a/src/visualBuilder/components/fieldLabelWrapper.tsx +++ b/src/visualBuilder/components/fieldLabelWrapper.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import React, { useEffect, useRef, useState } from "preact/compat"; +import React, { useCallback, useEffect, useRef, useState } from "preact/compat"; import { extractDetailsFromCslp, isValidCslp } from "../../cslp"; import { CslpData } from "../../cslp/types/cslp.types"; import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; @@ -20,6 +20,8 @@ import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermiss import { VariantIndicator } from "./VariantIndicator"; import { handleRevalidateFieldData } from "../eventManager/useRevalidateFieldDataPostMessageEvent"; import { RESULT_TYPES } from "../utils/constants"; +import { getPeerLockForField, lockAvatarInfo } from "../utils/fieldLockIndicator"; +import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore"; interface ReferenceParentMap { [entryUid: string]: { @@ -211,6 +213,73 @@ function FieldLabelWrapperComponent( const [error, setError] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + // Base (non-lock) label state captured by the data fetch below. The peer + // lock is layered on top separately so the affordance re-renders when the + // lock mirror changes, without re-running the (network) fetch. + const labelBaseRef = useRef<{ + text: string; + contentTypeName: string; + fieldDisabled: boolean; + reason: string; + workflowRequestUi?: "request" | "pending"; + handleLinkVariant: () => void; + handleRequestEditAccess: () => void | Promise; + hasParentPaths: boolean; + isReference: boolean; + referenceFieldName: string; + parentContentTypeName: string; + isVariant: boolean; + prefixIcon: any; + } | null>(null); + + const applyLockAffordance = useCallback(() => { + const base = labelBaseRef.current; + if (!base) return; + const peerLock = getPeerLockForField(props.fieldMetadata); + const effectiveDisabled = base.fieldDisabled || Boolean(peerLock); + const effectiveReason = + !base.fieldDisabled && peerLock + ? `This field is locked by ${ + lockAvatarInfo(peerLock).name || "another user" + }` + : base.reason; + const usePlainDataTooltip = + effectiveReason && + !effectiveReason.includes(DisableReason.CanLinkVariant) && + base.workflowRequestUi == null; + setCurrentField({ + text: base.text, + contentTypeName: base.contentTypeName, + icon: effectiveDisabled ? ( + + ) : base.hasParentPaths ? ( + + ) : ( + <> + ), + isReference: base.isReference, + prefixIcon: base.prefixIcon, + disabled: effectiveDisabled, + referenceFieldName: base.referenceFieldName, + parentContentTypeName: base.parentContentTypeName, + isVariant: base.isVariant, + }); + }, [props.fieldMetadata]); + + // Re-layer the lock affordance whenever the SDK lock mirror changes. + useEffect( + () => subscribeEntryFieldLockInfo(applyLockAffordance), + [applyLockAffordance] + ); + function calculateTopOffset(index: number) { const height = -30; // from bottom const offset = (index + 1) * height; @@ -351,36 +420,26 @@ function FieldLabelWrapperComponent( const hasParentPaths = !!props?.parentPaths?.length; const isVariant = props.fieldMetadata.variant ? true : false; - const usePlainDataTooltip = - reason && - !reason.includes(DisableReason.CanLinkVariant) && - workflowRequestUi == null; - - setCurrentField({ + // A peer-held lock reads as disabled in the field label too (reusing + // the disabled info-icon + tooltip). Capture the base (non-lock) + // state and layer the lock on via applyLockAffordance, so a lock + // acquire/release re-renders the label without re-fetching. + labelBaseRef.current = { text: currentFieldDisplayName, contentTypeName: contentTypeName ?? "", - icon: fieldDisabled ? ( - - ) : hasParentPaths ? ( - - ) : ( - <> - ), + fieldDisabled, + reason, + ...(workflowRequestUi != null ? { workflowRequestUi } : {}), + handleLinkVariant, + handleRequestEditAccess, + hasParentPaths, isReference, - prefixIcon: getFieldIcon(fieldSchema), - disabled: fieldDisabled, referenceFieldName, parentContentTypeName, - isVariant: isVariant, - }); + isVariant, + prefixIcon: getFieldIcon(fieldSchema), + }; + applyLockAffordance(); if (displayNames) { setDisplayNames(displayNames); diff --git a/src/visualBuilder/eventManager/useEntryLockInfoUpdateEvent.ts b/src/visualBuilder/eventManager/useEntryLockInfoUpdateEvent.ts new file mode 100644 index 00000000..d105f27f --- /dev/null +++ b/src/visualBuilder/eventManager/useEntryLockInfoUpdateEvent.ts @@ -0,0 +1,38 @@ +import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; +import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; +import { + EntryFieldLockInfo, + setEntryFieldLockInfo, +} from "../utils/fieldLockStore"; + +interface EntryLockInfoUpdateEvent { + data: { + entryUid: string; + locale: string; + variantUid?: string; + fieldLockInfo: EntryFieldLockInfo; + }; +} + +/** + * Keeps the SDK's lock mirror live. The parent pushes an entry's current + * field-lock map whenever a peer focuses or blurs a field; we overwrite that + * entry's scope in the mirror so indicators and gating reflect it immediately. + * The parent stays the single source of truth — the SDK only mirrors. + */ +export function useEntryLockInfoUpdateEvent(): void { + visualBuilderPostMessage?.on( + VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE, + (event: EntryLockInfoUpdateEvent) => { + const { entryUid, locale, variantUid, fieldLockInfo } = event.data; + setEntryFieldLockInfo( + { + entryUid, + locale, + ...(variantUid ? { variantUid } : {}), + }, + fieldLockInfo ?? {} + ); + } + ); +} diff --git a/src/visualBuilder/generators/__test__/generateLockAvatar.test.ts b/src/visualBuilder/generators/__test__/generateLockAvatar.test.ts new file mode 100644 index 00000000..85816f7a --- /dev/null +++ b/src/visualBuilder/generators/__test__/generateLockAvatar.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { showLockAvatar, hideLockAvatar } from "../generateLockAvatar"; +import { visualBuilderStyles } from "../../visualBuilder.style"; + +vi.mock("../../utils/visualBuilderPostMessage", () => ({ + default: { send: vi.fn(), on: vi.fn() }, +})); + +const hiddenClass = () => + visualBuilderStyles()["visual-builder__lock-avatar--hidden"]; + +const lock = { + user: { uid: "u1", name: "Ada Lovelace", initials: "AL", avatarColor: "#c2410c" }, + ttl: "2030-01-01", + isLocked: true, + isOwn: false, +}; + +beforeEach(() => { + document.body.innerHTML = ""; + const avatar = document.createElement("div"); + avatar.className = `visual-builder__lock-avatar ${hiddenClass()}`; + document.body.appendChild(avatar); +}); + +describe("lock avatar overlay", () => { + it("shows the avatar with initials, colour, title, and unhides it", () => { + const target = document.createElement("div"); + document.body.appendChild(target); + + showLockAvatar(target, lock as never); + + const avatar = document.querySelector( + ".visual-builder__lock-avatar" + )!; + expect(avatar.textContent).toBe("AL"); + expect(avatar.style.backgroundColor).not.toBe(""); + expect(avatar.getAttribute("title")).toBe("Locked by Ada Lovelace"); + expect(avatar.classList.contains(hiddenClass())).toBe(false); + }); + + it("hides the avatar", () => { + const avatar = document.querySelector( + ".visual-builder__lock-avatar" + )!; + avatar.classList.remove(hiddenClass()); + + hideLockAvatar(); + + expect(avatar.classList.contains(hiddenClass())).toBe(true); + }); +}); diff --git a/src/visualBuilder/generators/generateLockAvatar.tsx b/src/visualBuilder/generators/generateLockAvatar.tsx new file mode 100644 index 00000000..2bdc4798 --- /dev/null +++ b/src/visualBuilder/generators/generateLockAvatar.tsx @@ -0,0 +1,50 @@ +import { visualBuilderStyles } from "../visualBuilder.style"; +import { lockAvatarInfo } from "../utils/fieldLockIndicator"; +import type { EntryFieldLock } from "../utils/fieldLockStore"; + +const AVATAR_SIZE = 24; + +/** + * Shows the locking author's avatar at the hovered field's top-left corner, + * sitting just outside the field border. Reuses the single overlay avatar node + * (rendered by VisualBuilder), so it follows the hover the same way the hover + * outline does. Only used on hover of a peer-locked field. + */ +export function showLockAvatar( + targetElement: Element, + lock: EntryFieldLock +): void { + const avatar = document.querySelector( + ".visual-builder__lock-avatar" + ); + if (!avatar) { + return; + } + const rect = targetElement.getBoundingClientRect(); + const { initials, color, name } = lockAvatarInfo(lock); + + avatar.textContent = initials; + avatar.style.backgroundColor = color; + avatar.setAttribute( + "title", + name ? `Locked by ${name}` : "Locked by another user" + ); + // Centre the badge on the field's top-left corner so it sits outside the border. + avatar.style.top = `${rect.top + window.scrollY - AVATAR_SIZE / 2}px`; + avatar.style.left = `${rect.left + window.scrollX - AVATAR_SIZE / 2}px`; + avatar.classList.remove( + visualBuilderStyles()["visual-builder__lock-avatar--hidden"] + ); +} + +export function hideLockAvatar(): void { + const avatar = document.querySelector( + ".visual-builder__lock-avatar" + ); + if (!avatar) { + return; + } + avatar.classList.add( + visualBuilderStyles()["visual-builder__lock-avatar--hidden"] + ); +} diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts index f484f1a2..37a082eb 100644 --- a/src/visualBuilder/index.ts +++ b/src/visualBuilder/index.ts @@ -25,6 +25,7 @@ import { h } from "preact"; import { extractDetailsFromCslp, isValidCslp } from "../cslp"; import initUI from "./components"; import { useDraftFieldsPostMessageEvent } from "./eventManager/useDraftFieldsPostMessageEvent"; +import { useEntryLockInfoUpdateEvent } from "./eventManager/useEntryLockInfoUpdateEvent"; import { useHideFocusOverlayPostMessageEvent } from "./eventManager/useHideFocusOverlayPostMessageEvent"; import { useScrollToField } from "./eventManager/useScrollToField"; import { debounceAddVariantFieldClass, getHighlightVariantFieldsStatus, setHighlightVariantFields, useVariantFieldsPostMessageEvent } from "./eventManager/useVariantsPostMessageEvent"; @@ -414,6 +415,7 @@ export class VisualBuilder { useOnEntryUpdatePostMessageEvent(); useRecalculateVariantDataCSLPValues(); useDraftFieldsPostMessageEvent(); + useEntryLockInfoUpdateEvent(); useVariantFieldsPostMessageEvent({ isSSR: config.ssr ?? false }); } }) diff --git a/src/visualBuilder/listeners/__test__/mouseClick.test.ts b/src/visualBuilder/listeners/__test__/mouseClick.test.ts index cb41cff9..4ee536f3 100644 --- a/src/visualBuilder/listeners/__test__/mouseClick.test.ts +++ b/src/visualBuilder/listeners/__test__/mouseClick.test.ts @@ -13,8 +13,22 @@ vi.mock("../../utils/getCsDataOfElement", () => ({ getDOMEditStack: vi.fn().mockReturnValue([]), })); +const { mockIsValidCslp } = vi.hoisted(() => ({ + mockIsValidCslp: vi.fn().mockReturnValue(false), +})); + vi.mock("../../cslp", () => ({ - isValidCslp: vi.fn().mockReturnValue(false), + isValidCslp: mockIsValidCslp, +})); + +const { mockExtractDetailsFromCslp } = vi.hoisted(() => ({ + mockExtractDetailsFromCslp: vi + .fn() + .mockReturnValue({ entry_uid: "entry1", fieldPathWithIndex: "field.0" }), +})); + +vi.mock("../../cslp/cslpdata", () => ({ + extractDetailsFromCslp: mockExtractDetailsFromCslp, })); vi.mock("../../generators/generateToolbar", () => ({ @@ -27,8 +41,12 @@ vi.mock("../../generators/generateOverlay", () => ({ hideOverlay: vi.fn(), })); +const { mockPostMessageSend } = vi.hoisted(() => ({ + mockPostMessageSend: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("../../utils/visualBuilderPostMessage", () => ({ - default: { send: vi.fn().mockResolvedValue(undefined) }, + default: { send: mockPostMessageSend }, })); vi.mock("../../utils/types/postMessage.types", () => ({ @@ -369,3 +387,92 @@ describe("handleBuilderInteraction — alt+click on anchor", () => { expect(window.location.href).toBe(anchor.href); }); }); + +describe("handleBuilderInteraction — inline editor click keeps the field lock", () => { + let focusedField: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + hoistedConfigMocks.configGet.mockReturnValue({ + collab: { enable: false, isFeedbackMode: false, pauseFeedback: false }, + }); + focusedField = document.createElement("div"); + focusedField.setAttribute("data-cslp", "ct.entry1.en-us.field.0"); + document.body.appendChild(focusedField); + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + focusedField; + VisualBuilder.VisualBuilderGlobalState.value.isFocussed = true; + // getCsDataOfElement finds nothing (pseudo-editable and empty space both + // resolve to no data-cslp) + vi.mocked(getCsDataOfElement).mockReturnValue(undefined as any); + mockIsValidCslp.mockReturnValue(true); + }); + + it("carries the focused field's metadata on MOUSE_CLICK when the click is inside the pseudo-editable overlay", async () => { + const pseudo = document.createElement("div"); + pseudo.className = "visual-builder__pseudo-editable-element"; + document.body.appendChild(pseudo); + + await handleBuilderInteraction(makeParams(pseudo)); + + expect(mockPostMessageSend).toHaveBeenCalledWith( + "MOUSE_CLICK", + expect.objectContaining({ + cslpData: "ct.entry1.en-us.field.0", + fieldMetadata: expect.anything(), + }), + ); + }); + + it("carries the focused field's metadata when the click is inside the focused field itself (in-place editor reposition)", async () => { + // A no-cslp click on a child of the focused field (e.g. repositioning the + // cursor inside an RTE / in-place editor) is not a deselect. + const innerNode = document.createElement("span"); + focusedField.appendChild(innerNode); + + await handleBuilderInteraction(makeParams(innerNode)); + + expect(mockPostMessageSend).toHaveBeenCalledWith( + "MOUSE_CLICK", + expect.objectContaining({ + cslpData: "ct.entry1.en-us.field.0", + fieldMetadata: expect.anything(), + }), + ); + }); + + it("carries the focused field's metadata when clicking the field toolbar chrome (e.g. Replace)", async () => { + // The file/asset Replace button lives in the SDK field toolbar, not the + // content DOM, so it has no data-cslp — but clicking it acts on the focused + // field and must not release the lock. + const toolbar = document.createElement("div"); + toolbar.className = "visual-builder__field-toolbar-container"; + const replaceBtn = document.createElement("button"); + replaceBtn.setAttribute("data-testid", "visual-builder-replace-file"); + toolbar.appendChild(replaceBtn); + document.body.appendChild(toolbar); + + await handleBuilderInteraction(makeParams(replaceBtn)); + + expect(mockPostMessageSend).toHaveBeenCalledWith( + "MOUSE_CLICK", + expect.objectContaining({ + cslpData: "ct.entry1.en-us.field.0", + fieldMetadata: expect.anything(), + }), + ); + }); + + it("sends no fieldMetadata on a genuine empty-space click (a real deselect)", async () => { + const emptyTarget = document.createElement("div"); + document.body.appendChild(emptyTarget); + + await handleBuilderInteraction(makeParams(emptyTarget)); + + expect(mockPostMessageSend).toHaveBeenCalledWith("MOUSE_CLICK", { + cslpData: undefined, + fieldMetadata: undefined, + }); + }); +}); diff --git a/src/visualBuilder/listeners/__test__/mouseHover.test.ts b/src/visualBuilder/listeners/__test__/mouseHover.test.ts index 9f2ac67d..17253789 100644 --- a/src/visualBuilder/listeners/__test__/mouseHover.test.ts +++ b/src/visualBuilder/listeners/__test__/mouseHover.test.ts @@ -3,6 +3,10 @@ import handleMouseHover from "../mouseHover"; import { VisualBuilder } from "../../index"; import * as fetchEntryPermissionsModule from "../../utils/fetchEntryPermissionsAndStageDetails"; import { FieldSchemaMap } from "../../utils/fieldSchemaMap"; +import { + setEntryFieldLockInfo, + clearAllEntryFieldLockInfo, +} from "../../utils/fieldLockStore"; vi.mock("lodash-es", async () => ({ ...(await import("lodash-es")), @@ -141,6 +145,7 @@ describe("mouseHover — custom field multiple instance suppression", () => { VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = null; VisualBuilder.VisualBuilderGlobalState.value.isFocussed = false; vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(true); + clearAllEntryFieldLockInfo(); }); it("dispatches mousemove on whole-field element for custom field multiple instance", async () => { @@ -184,6 +189,30 @@ describe("mouseHover — custom field multiple instance suppression", () => { expect(addHoverOutline).toHaveBeenCalled(); }); + it("grays the hover cursor for a peer-locked field (same disabled affordance as a disabled field)", async () => { + setEntryFieldLockInfo( + { entryUid: "entry1", locale: "en-us" }, + { + title: { + user: { uid: "u1", name: "Ada", initials: "AD" }, + ttl: "2030-01-01", + isLocked: true, + isOwn: false, + }, + }, + ); + vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(false); + mockedGetCsDataOfElement.mockReturnValue( + makeEventDetails(editableElement) as any, + ); + + await handleMouseHover(makeParams(editableElement, customCursor)); + + expect(vi.mocked(generateCustomCursor)).toHaveBeenCalledWith( + expect.objectContaining({ fieldDisabled: true }), + ); + }); + it("does not suppress when schema is not yet cached (hasFieldSchema returns false)", async () => { vi.mocked(FieldSchemaMap.hasFieldSchema).mockReturnValue(false); vi.mocked(isCustomFieldMultipleInstance).mockReturnValue(true); diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts index b874a198..0e9a0ef0 100644 --- a/src/visualBuilder/listeners/mouseClick.ts +++ b/src/visualBuilder/listeners/mouseClick.ts @@ -8,6 +8,7 @@ import { getDOMEditStack, } from "../utils/getCsDataOfElement"; import { isValidCslp } from "../../cslp"; +import { extractDetailsFromCslp } from "../../cslp/cslpdata"; import { appendFocusedToolbar } from "../generators/generateToolbar"; @@ -20,6 +21,7 @@ import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types import { VisualBuilder } from ".."; import { FieldSchemaMap } from "../utils/fieldSchemaMap"; import { isFieldDisabled } from "../utils/isFieldDisabled"; +import { getPeerLockForField } from "../utils/fieldLockIndicator"; import EventListenerHandlerParams from "./types"; import { toggleHighlightedCommentIconDisplay } from "../generators/generateHighlightedComment"; import { VB_EmptyBlockParentClass } from "../.."; @@ -180,8 +182,25 @@ export async function handleBuilderInteraction( const eventDetails = getCsDataOfElement(params.event); - // Send mouse click post message - sendMouseClickPostMessage(eventDetails); + // A field locked by another user is not editable — block entering edit mode + // (the hover state already shows it disabled with the author avatar). This + // gate runs before the post message so a click on a peer-locked field is a + // true no-op: posting MOUSE_CLICK with its fieldMetadata makes the host read + // it as a fresh selection and cancel the current user's own pending lock + // release. + if (eventDetails && getPeerLockForField(eventDetails.fieldMetadata)) { + return; + } + + // Send mouse click post message. A click inside the active inline editor (the + // pseudo-editable overlay) resolves to no data-cslp — the overlay lives in the + // SDK container, not the content DOM — but it is NOT a deselect: the user is + // just repositioning the cursor within the focused field. Carry the focused + // field's metadata so the host keeps the field lock instead of reading an + // empty-space click as a deselect and releasing it. + sendMouseClickPostMessage( + eventDetails ?? getInlineEditFieldDetails(eventTarget) + ); if ( !eventDetails || @@ -278,6 +297,45 @@ function sendMouseClickPostMessage(eventDetails: any) { console.warn("Error while sending post message", err); }); } + +/** SDK-owned chrome for the focused field (its toolbar + action buttons like + * Replace / Edit / move / revert). These live in the SDK container, not the + * content DOM, so they carry no data-cslp — but clicking them acts ON the focused + * field, so it must not be read as a deselect. */ +const FOCUSED_FIELD_CHROME_SELECTOR = + ".visual-builder__pseudo-editable-element," + + ".visual-builder__focused-toolbar," + + ".visual-builder__field-toolbar-container"; + +/** + * When a no-cslp click is still an interaction with the currently focused field — + * inside its inline-edit overlay, inside the field element itself (cursor + * reposition / text selection), or on its SDK toolbar chrome (Replace, Edit, + * move, revert, field-path dropdown) — resolve the focused field's cslp so + * MOUSE_CLICK still carries fieldMetadata and the host keeps the lock. Returns + * undefined for any other empty click (a genuine deselect). + */ +function getInlineEditFieldDetails( + eventTarget: HTMLElement | null +): { cslpData: string; fieldMetadata: CslpData } | undefined { + const focusedElement = + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM; + if (!focusedElement) { + return undefined; + } + const insideFieldChrome = !!eventTarget?.closest?.( + FOCUSED_FIELD_CHROME_SELECTOR + ); + const insideFocusedField = !!eventTarget && focusedElement.contains(eventTarget); + if (!insideFieldChrome && !insideFocusedField) { + return undefined; + } + const cslpData = focusedElement.getAttribute?.("data-cslp") ?? null; + if (!isValidCslp(cslpData)) { + return undefined; + } + return { cslpData, fieldMetadata: extractDetailsFromCslp(cslpData) }; +} function cleanResidualsIfNeeded( params: HandleBuilderInteractionParams, editableElement: Element diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts index b0d20363..54916265 100644 --- a/src/visualBuilder/listeners/mouseHover.ts +++ b/src/visualBuilder/listeners/mouseHover.ts @@ -20,6 +20,12 @@ import { CslpData } from "../../cslp/types/cslp.types"; import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; import { getParentCslp, getWholeFieldElement } from "../utils/getWholeFieldElement"; +import { getPeerLockForField } from "../utils/fieldLockIndicator"; +import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore"; +import { + showLockAvatar, + hideLockAvatar, +} from "../generators/generateLockAvatar"; const config = Config.get(); export interface HandleMouseHoverParams @@ -84,7 +90,22 @@ async function addOutline(params?: AddOutlineParams): Promise { } = params; if (!editableElement) return; const isVariant = !!fieldMetadata.variant; - addHoverOutline(editableElement as HTMLElement, fieldDisabled, isVariant); + // Peer-lock is a hover-only affordance: if this field is locked by another + // user, show the disabled hover state + the author avatar (top-left, outside + // the border). Read the mirror synchronously for an instant paint on cached + // entries; it is re-read after the async fetch below in case the snapshot + // only just arrived for a first-time-hovered entry. + let peerLock = getPeerLockForField(fieldMetadata); + if (peerLock) { + showLockAvatar(editableElement, peerLock); + } else { + hideLockAvatar(); + } + addHoverOutline( + editableElement as HTMLElement, + fieldDisabled || Boolean(peerLock), + isVariant + ); const fieldSchema = await FieldSchemaMap.getFieldSchema( content_type_uid, fieldPath @@ -105,12 +126,55 @@ async function addOutline(params?: AddOutlineParams): Promise { entryAcl, entryWorkflowStageDetails ); - addHoverOutline(editableElement, fieldDisabled || isDisabled, isVariant); + peerLock = getPeerLockForField(fieldMetadata); + if (peerLock) { + showLockAvatar(editableElement, peerLock); + } else { + hideLockAvatar(); + } + addHoverOutline( + editableElement, + fieldDisabled || isDisabled || Boolean(peerLock), + isVariant + ); } const debouncedAddOutline = debounce(addOutline, 50, { trailing: true }); export const cancelPendingAddOutline = () => debouncedAddOutline.cancel(); -const showOutline = (params?: AddOutlineParams): Promise | undefined => debouncedAddOutline(params); +// Remember the last painted hover so a lock change can repaint the same element +// (see the subscription below) without waiting for the next mouse move. +let lastAddOutlineParams: AddOutlineParams | undefined; +const showOutline = (params?: AddOutlineParams): Promise | undefined => { + if (params) { + lastAddOutlineParams = params; + } + return debouncedAddOutline(params); +}; + +// Repaint the hover affordance when the lock mirror changes, so a lock acquired +// or released while the pointer sits on a field updates immediately. Guarded to +// the element still under the pointer with a visible outline, so a stale paint +// never resurrects a hidden outline. +subscribeEntryFieldLockInfo(() => { + const params = lastAddOutlineParams; + if (!params) return; + if ( + VisualBuilder.VisualBuilderGlobalState.value.previousHoveredTargetDOM !== + params.editableElement + ) { + return; + } + const outline = document.querySelector(".visual-builder__hover-outline"); + if ( + !outline || + outline.classList.contains( + visualBuilderStyles()["visual-builder__hover-outline--hidden"] + ) + ) { + return; + } + void addOutline(params); +}); function hideDefaultCursor(): void { if ( @@ -151,6 +215,7 @@ export function hideHoverOutline( hoverOutline.classList.add( visualBuilderStyles()["visual-builder__hover-outline--hidden"] ); + hideLockAvatar(); } export function hideCustomCursor(customCursor: HTMLDivElement | null): void { @@ -246,6 +311,9 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { } if (!config?.collab.enable) { resetCustomCursor(params.customCursor); + // Empty space: drop any peer-lock avatar left over from a prior + // field hover so it does not ghost over the blank canvas. + hideLockAvatar(); } removeAddInstanceButtons({ eventTarget: params.event.target, @@ -418,10 +486,12 @@ async function generateCursor({ entryWorkflowStageDetails ); const fieldType = getFieldType(fieldSchema); + // A peer-locked field reads as disabled on hover (same grey outline), so gray + // the cursor info too for a consistent disabled affordance. generateCustomCursor({ fieldType, customCursor, - fieldDisabled, + fieldDisabled: fieldDisabled || Boolean(getPeerLockForField(fieldMetadata)), }); } diff --git a/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts b/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts new file mode 100644 index 00000000..a8337e92 --- /dev/null +++ b/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { getPeerLockForField, lockAvatarInfo } from "../fieldLockIndicator"; +import { + setEntryFieldLockInfo, + clearAllEntryFieldLockInfo, +} from "../fieldLockStore"; + +vi.mock("../visualBuilderPostMessage", () => ({ + default: { send: vi.fn(), on: vi.fn() }, +})); + +const scope = { entryUid: "entry1", contentTypeUid: "page", locale: "en-us" }; + +const meta = (over: Record = {}) => ({ + entry_uid: "entry1", + locale: "en-us", + variant: undefined, + fieldPath: "title", + fieldPathWithIndex: "title", + ...over, +}); + +const peer = (over: Record = {}) => ({ + user: { uid: "u1", name: "Ada Lovelace", initials: "AL", avatarColor: "#c2410c" }, + ttl: "2030-01-01", + isLocked: true, + isOwn: false, + ...over, +}); + +beforeEach(() => { + clearAllEntryFieldLockInfo(); +}); + +describe("getPeerLockForField", () => { + it("returns the peer lock for a field locked by another user", () => { + setEntryFieldLockInfo(scope, { title: peer() }); + expect(getPeerLockForField(meta() as never)?.user.uid).toBe("u1"); + }); + + it("returns null for the current user's own lock", () => { + setEntryFieldLockInfo(scope, { title: peer({ isOwn: true }) }); + expect(getPeerLockForField(meta() as never)).toBeNull(); + }); + + it("returns null for an unlocked field", () => { + expect(getPeerLockForField(meta() as never)).toBeNull(); + }); + + it("treats a lock with no isOwn flag as a peer lock", () => { + setEntryFieldLockInfo(scope, { + title: { user: { uid: "u1" }, ttl: "2030-01-01", isLocked: true }, + }); + expect(getPeerLockForField(meta() as never)).not.toBeNull(); + }); + + it("matches by fieldPathWithIndex, falling back to fieldPath", () => { + setEntryFieldLockInfo(scope, { + "page_components.0.section.title_h3": peer(), + }); + const found = getPeerLockForField( + meta({ + fieldPath: "page_components.section.title_h3", + fieldPathWithIndex: "page_components.0.section.title_h3", + }) as never + ); + expect(found).not.toBeNull(); + }); + + it("scopes the lookup by variant", () => { + setEntryFieldLockInfo({ ...scope, variantUid: "var1" }, { title: peer() }); + expect(getPeerLockForField(meta({ variant: "var1" }) as never)).not.toBeNull(); + // base scope has no lock + expect(getPeerLockForField(meta() as never)).toBeNull(); + }); + + it("locks a child when an ancestor container is peer-locked", () => { + setEntryFieldLockInfo(scope, { "section.0": peer() }); + const found = getPeerLockForField( + meta({ + fieldPath: "section.title", + fieldPathWithIndex: "section.0.title", + }) as never + ); + expect(found?.user.uid).toBe("u1"); + }); + + it("locks a container when a descendant field is peer-locked", () => { + setEntryFieldLockInfo(scope, { "section.0.title": peer() }); + const found = getPeerLockForField( + meta({ + fieldPath: "section", + fieldPathWithIndex: "section.0", + }) as never + ); + expect(found?.user.uid).toBe("u1"); + }); + + it("does not lock a sibling that only shares a path prefix", () => { + setEntryFieldLockInfo(scope, { "section.0.title": peer() }); + expect( + getPeerLockForField( + meta({ + fieldPath: "section.subtitle", + fieldPathWithIndex: "section.0.subtitle", + }) as never + ) + ).toBeNull(); + }); + + it("does not propagate the current user's own container lock", () => { + setEntryFieldLockInfo(scope, { "section.0": peer({ isOwn: true }) }); + expect( + getPeerLockForField( + meta({ + fieldPath: "section.title", + fieldPathWithIndex: "section.0.title", + }) as never + ) + ).toBeNull(); + }); +}); + +describe("lockAvatarInfo", () => { + it("uses initials, color, and name from the lock", () => { + expect(lockAvatarInfo(peer() as never)).toEqual({ + initials: "AL", + color: "#c2410c", + name: "Ada Lovelace", + }); + }); + + it("derives initials from the name when initials are absent", () => { + expect( + lockAvatarInfo({ + user: { uid: "u", name: "grace hopper" }, + ttl: "x", + isLocked: true, + } as never).initials + ).toBe("GR"); + }); + + it("falls back to '?' and the default color when nothing resolves", () => { + const info = lockAvatarInfo({ + user: { uid: "u" }, + ttl: "x", + isLocked: true, + } as never); + expect(info.initials).toBe("?"); + expect(info.color).toBe("#6c5ce7"); + expect(info.name).toBe(""); + }); +}); diff --git a/src/visualBuilder/utils/__test__/getEntryLockInfo.test.ts b/src/visualBuilder/utils/__test__/getEntryLockInfo.test.ts new file mode 100644 index 00000000..1bd10028 --- /dev/null +++ b/src/visualBuilder/utils/__test__/getEntryLockInfo.test.ts @@ -0,0 +1,240 @@ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + MockedObject, +} from "vitest"; +import { EventManager } from "@contentstack/advanced-post-message"; +import { getEntryLockInfo } from "../getEntryLockInfo"; +import { useEntryLockInfoUpdateEvent } from "../../eventManager/useEntryLockInfoUpdateEvent"; +import { VisualBuilderPostMessageEvents } from "../types/postMessage.types"; +import visualBuilderPostMessage from "../visualBuilderPostMessage"; +import { + getEntryFieldLockInfo, + setEntryFieldLockInfo, + clearAllEntryFieldLockInfo, + subscribeEntryFieldLockInfo, + getEntryFieldLockVersion, + entryLockScopeKey, + EntryLockScope, + EntryFieldLockInfo, +} from "../fieldLockStore"; + +vi.mock("../visualBuilderPostMessage", () => ({ + default: { send: vi.fn(), on: vi.fn() }, +})); + +const mockPostMessage = visualBuilderPostMessage as MockedObject; + +const scope: EntryLockScope = { + entryUid: "entry_1", + contentTypeUid: "ct_1", + locale: "en-us", +}; + +const locks: EntryFieldLockInfo = { + title: { + user: { uid: "user_a" }, + ttl: "2030-01-01T00:00:00.000Z", + isLocked: true, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + clearAllEntryFieldLockInfo(); + vi.spyOn(console, "debug").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getEntryLockInfo", () => { + it("requests the lock snapshot for the entry and returns it", async () => { + mockPostMessage.send.mockResolvedValue({ fieldLockInfo: locks }); + + const result = await getEntryLockInfo(scope); + + expect(mockPostMessage.send).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO, + { entryUid: "entry_1", contentTypeUid: "ct_1", locale: "en-us" } + ); + expect(result).toEqual(locks); + }); + + it("stores the snapshot in the SDK mirror", async () => { + mockPostMessage.send.mockResolvedValue({ fieldLockInfo: locks }); + + await getEntryLockInfo(scope); + + expect(getEntryFieldLockInfo(scope)).toEqual(locks); + }); + + it("returns null (not an empty map) and does not throw when the request fails", async () => { + mockPostMessage.send.mockRejectedValue(new Error("bridge down")); + + // null lets the caller distinguish a transient failure from a genuine + // "no locks" snapshot and retry, instead of caching the failure. + await expect(getEntryLockInfo(scope)).resolves.toBeNull(); + }); + + it("returns an empty map for a genuine no-locks snapshot", async () => { + mockPostMessage.send.mockResolvedValue({ fieldLockInfo: {} }); + + await expect(getEntryLockInfo(scope)).resolves.toEqual({}); + expect(getEntryFieldLockInfo(scope)).toEqual({}); + }); + + it("returns null and does not cache when the parent reports a failure", async () => { + mockPostMessage.send.mockResolvedValue({ error: true }); + + // The parent flags a fetch failure with `error: true` so the SDK retries + // rather than caching an empty snapshot for the session. + await expect(getEntryLockInfo(scope)).resolves.toBeNull(); + expect(getEntryFieldLockInfo(scope)).toEqual({}); + }); + + it("returns null for a malformed/empty response", async () => { + mockPostMessage.send.mockResolvedValue(undefined); + + await expect(getEntryLockInfo(scope)).resolves.toBeNull(); + }); + + it("does not overwrite a newer delta that lands during the request", async () => { + let resolveSnapshot: (v: { fieldLockInfo: EntryFieldLockInfo }) => void = + () => {}; + mockPostMessage.send.mockImplementation( + () => + new Promise((res) => { + resolveSnapshot = res as (v: { + fieldLockInfo: EntryFieldLockInfo; + }) => void; + }) + ); + + const pending = getEntryLockInfo(scope); + // A delta clears the scope while the snapshot is still in flight. + setEntryFieldLockInfo(scope, {}); + // The now-stale snapshot (title locked) resolves and must not re-lock it. + resolveSnapshot({ fieldLockInfo: locks }); + await pending; + + expect(getEntryFieldLockInfo(scope)).toEqual({}); + }); + + it("passes the variant through for variant entries", async () => { + mockPostMessage.send.mockResolvedValue({ fieldLockInfo: {} }); + + await getEntryLockInfo({ ...scope, variantUid: "variant_1" }); + + expect(mockPostMessage.send).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO, + { + entryUid: "entry_1", + contentTypeUid: "ct_1", + locale: "en-us", + variantUid: "variant_1", + } + ); + }); +}); + +describe("fieldLockStore", () => { + beforeEach(() => clearAllEntryFieldLockInfo()); + + it("keys by entry, locale, and variant (content type excluded, matching the parent + Redis channel)", () => { + expect(entryLockScopeKey(scope)).toBe("entry_1.en-us"); + expect(entryLockScopeKey({ ...scope, variantUid: "variant_1" })).toBe( + "entry_1.en-us.variant_1" + ); + }); + + it("round-trips and keeps base and variant scopes separate", () => { + setEntryFieldLockInfo(scope, locks); + setEntryFieldLockInfo({ ...scope, variantUid: "variant_1" }, {}); + + expect(getEntryFieldLockInfo(scope)).toEqual(locks); + expect( + getEntryFieldLockInfo({ ...scope, variantUid: "variant_1" }) + ).toEqual({}); + }); + + it("returns an empty map for an unknown scope", () => { + expect( + getEntryFieldLockInfo({ + entryUid: "missing", + contentTypeUid: "ct_1", + locale: "en-us", + }) + ).toEqual({}); + }); + + it("notifies subscribers on write and clear; unsubscribe stops them", () => { + const listener = vi.fn(); + const unsubscribe = subscribeEntryFieldLockInfo(listener); + + setEntryFieldLockInfo(scope, locks); + expect(listener).toHaveBeenCalledTimes(1); + + clearAllEntryFieldLockInfo(); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + setEntryFieldLockInfo(scope, {}); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("bumps the scope version on each write", () => { + const before = getEntryFieldLockVersion(scope); + setEntryFieldLockInfo(scope, locks); + expect(getEntryFieldLockVersion(scope)).toBeGreaterThan(before); + }); +}); + +describe("useEntryLockInfoUpdateEvent", () => { + const handlers: Record void> = {}; + + beforeEach(() => { + clearAllEntryFieldLockInfo(); + for (const key of Object.keys(handlers)) delete handlers[key]; + (mockPostMessage.on as unknown as ReturnType).mockImplementation( + (event: string, cb: (e: { data: unknown }) => void) => { + handlers[event] = cb; + } + ); + }); + + it("registers a handler for the ENTRY_LOCK_INFO_UPDATE push", () => { + useEntryLockInfoUpdateEvent(); + + expect(mockPostMessage.on).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE, + expect.any(Function) + ); + }); + + it("writes a pushed lock map into the mirror for that entry scope", () => { + useEntryLockInfoUpdateEvent(); + + handlers[VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE]({ + data: { entryUid: "entry_1", locale: "en-us", fieldLockInfo: locks }, + }); + + expect(getEntryFieldLockInfo(scope)).toEqual(locks); + }); + + it("replaces the scope on each push so released locks drop out", () => { + setEntryFieldLockInfo(scope, locks); + useEntryLockInfoUpdateEvent(); + + handlers[VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE]({ + data: { entryUid: "entry_1", locale: "en-us", fieldLockInfo: {} }, + }); + + expect(getEntryFieldLockInfo(scope)).toEqual({}); + }); +}); diff --git a/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts b/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts index 6fe2b305..a1ec0259 100644 --- a/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts +++ b/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts @@ -1,6 +1,7 @@ import { getEntryPermissionsCached } from "./getEntryPermissionsCached"; import { getResolvedVariantPermissions } from "./getResolvedVariantPermissions"; import { getWorkflowStageDetails } from "./getWorkflowStageDetails"; +import { requestEntryLockInfoOnce } from "./fieldLockIndicator"; export async function fetchEntryPermissionsAndStageDetails({ entryUid, @@ -15,6 +16,16 @@ export async function fetchEntryPermissionsAndStageDetails({ fieldPathWithIndex: string; variantUid?: string | undefined; }) { + // Fire-and-forget: pull this entry's lock snapshot once and paint indicators. + // De-duped per entry (the parent pushes deltas afterwards), so hovering does + // not spam requests, and it must not block the permission/stage fetch below. + void requestEntryLockInfoOnce({ + entryUid, + contentTypeUid, + locale, + ...(variantUid ? { variantUid } : {}), + }); + const entryAclPromise = getEntryPermissionsCached({ entryUid, contentTypeUid, diff --git a/src/visualBuilder/utils/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts new file mode 100644 index 00000000..b648e88f --- /dev/null +++ b/src/visualBuilder/utils/fieldLockIndicator.ts @@ -0,0 +1,105 @@ +import { getEntryFieldLockInfo } from "./fieldLockStore"; +import { getEntryLockInfo } from "./getEntryLockInfo"; +import type { EntryFieldLock, EntryLockScope } from "./fieldLockStore"; +import type { CslpData } from "../../cslp/types/cslp.types"; + +type FieldMetadataForLock = Pick< + CslpData, + "entry_uid" | "locale" | "variant" | "fieldPath" | "fieldPathWithIndex" +>; + +/** A lock counts as a peer lock when it is locked and not the current user's. */ +function asPeerLock(lock: EntryFieldLock | undefined): EntryFieldLock | null { + return lock?.isLocked && lock.isOwn !== true ? lock : null; +} + +/** + * Returns the peer lock (a lock held by another user) for a field, or null when + * the field is unlocked or locked by the current user. Reads the SDK mirror the + * parent keeps in sync; the parent stamps `isOwn`. The lock display is now + * hover-driven (see mouseHover), so this is the single source consumers call on + * hover/click rather than a persistent page-wide paint. + * + * Container fields propagate the lock, mirroring the entry editor's + * useContainerFieldLock: a peer lock on an ancestor disables its descendants + * (parent locked => child locked) and a peer lock on a descendant disables the + * ancestor's structural actions (child locked => container locked). Mirror keys + * are cslp index-form, so the ancestor/descendant scan compares against + * fieldPathWithIndex. + */ +export function getPeerLockForField( + fieldMetadata: FieldMetadataForLock +): EntryFieldLock | null { + const scopeLocks = getEntryFieldLockInfo({ + entryUid: fieldMetadata.entry_uid, + locale: fieldMetadata.locale, + ...(fieldMetadata.variant ? { variantUid: fieldMetadata.variant } : {}), + }); + + const exact = asPeerLock( + scopeLocks[fieldMetadata.fieldPathWithIndex] ?? + scopeLocks[fieldMetadata.fieldPath] + ); + if (exact) return exact; + + const target = fieldMetadata.fieldPathWithIndex || fieldMetadata.fieldPath; + if (!target) return null; + for (const lockedPath of Object.keys(scopeLocks)) { + if ( + target.startsWith(lockedPath + ".") || + lockedPath.startsWith(target + ".") + ) { + const peer = asPeerLock(scopeLocks[lockedPath]); + if (peer) return peer; + } + } + return null; +} + +/** Avatar display (initials + colour + full name) for a lock's holder. */ +export function lockAvatarInfo(lock: EntryFieldLock): { + initials: string; + color: string; + name: string; +} { + const user = lock.user ?? { uid: "" }; + let initials = "?"; + if (typeof user.initials === "string" && user.initials.trim()) { + initials = user.initials.trim(); + } else if (typeof user.name === "string" && user.name.trim()) { + initials = user.name.trim().slice(0, 2).toUpperCase(); + } + const color = + typeof user.avatarColor === "string" && user.avatarColor + ? user.avatarColor + : "#6c5ce7"; + const name = typeof user.name === "string" ? user.name : ""; + return { initials, color, name }; +} + +/** Entries whose snapshot has already been requested this session. */ +const requestedScopes = new Set(); + +/** + * Requests an entry's lock snapshot at most once (the parent pushes deltas after, + * so re-asking is unnecessary) to seed the SDK mirror. Safe to call on every + * hover — it de-dupes per entry scope. A transient failure is not cached: the + * scope is released so a later hover retries, otherwise one blip would silence + * lock info for that entry for the whole session. + */ +export async function requestEntryLockInfoOnce( + scope: EntryLockScope +): Promise { + const key = `${scope.entryUid}.${scope.locale}${ + scope.variantUid ? `.${scope.variantUid}` : "" + }`; + if (requestedScopes.has(key)) { + return; + } + requestedScopes.add(key); + + const result = await getEntryLockInfo(scope); + if (result === null) { + requestedScopes.delete(key); + } +} diff --git a/src/visualBuilder/utils/fieldLockStore.ts b/src/visualBuilder/utils/fieldLockStore.ts new file mode 100644 index 00000000..b2b55ee0 --- /dev/null +++ b/src/visualBuilder/utils/fieldLockStore.ts @@ -0,0 +1,105 @@ +/** + * SDK-side read-only mirror of entry field-lock state. + * + * The parent window is the single source of truth for locks. The SDK keeps this + * mirror in sync via the initial snapshot (getEntryLockInfo) and subsequent + * pushes from the parent (ENTRY_LOCK_INFO_UPDATE). Consumers read from here to + * render indicators and gate actions; they never mutate lock state directly. + */ + +export interface EntryFieldLock { + user: { + uid: string; + name?: string; + initials?: string; + avatarColor?: string; + [key: string]: unknown; + }; + ttl: string; + isLocked: boolean; + /** + * True when the lock belongs to the current session. The parent computes + * this (the SDK knows neither the current user nor its socket id); a peer + * lock is `isLocked && !isOwn`. Absent means treat as a peer lock. + */ + isOwn?: boolean; +} + +export type EntryFieldLockInfo = Record; + +/** The parts that identify a lock scope: entry + locale + variant. */ +export interface EntryLockScopeParts { + entryUid: string; + locale: string; + variantUid?: string; +} + +/** Scope parts plus the content type needed to build the lock-status request. */ +export interface EntryLockScope extends EntryLockScopeParts { + contentTypeUid: string; +} + +const store = new Map(); +// Per-scope monotonic write counter, so a late snapshot can detect that a newer +// delta already updated the scope and skip its stale overwrite. +const scopeVersions = new Map(); +let writeSeq = 0; +// Consumers (hover paint, field label) subscribe so the affordance re-renders +// the moment the mirror changes, not only on the next mouse move. +const lockListeners = new Set<() => void>(); + +function notifyLockListeners(): void { + lockListeners.forEach((listener) => { + try { + listener(); + } catch (error) { + console.debug("[Visual Builder] lock listener failed", error); + } + }); +} + +export function subscribeEntryFieldLockInfo(listener: () => void): () => void { + lockListeners.add(listener); + return () => { + lockListeners.delete(listener); + }; +} + +/** + * Lock scope key: entry + locale + variant, mirroring the parent's entry key and + * the Redis presence channel (both of which exclude content type). Content type + * is only needed to build the lock-status request, not to identify the scope. + */ +export function entryLockScopeKey({ + entryUid, + locale, + variantUid, +}: EntryLockScopeParts): string { + return `${entryUid}.${locale}${variantUid ? `.${variantUid}` : ""}`; +} + +export function getEntryFieldLockVersion(scope: EntryLockScopeParts): number { + return scopeVersions.get(entryLockScopeKey(scope)) ?? 0; +} + +export function setEntryFieldLockInfo( + scope: EntryLockScopeParts, + fieldLockInfo: EntryFieldLockInfo +): void { + const key = entryLockScopeKey(scope); + store.set(key, fieldLockInfo ?? {}); + scopeVersions.set(key, ++writeSeq); + notifyLockListeners(); +} + +export function getEntryFieldLockInfo( + scope: EntryLockScopeParts +): EntryFieldLockInfo { + return store.get(entryLockScopeKey(scope)) ?? {}; +} + +export function clearAllEntryFieldLockInfo(): void { + store.clear(); + scopeVersions.clear(); + notifyLockListeners(); +} diff --git a/src/visualBuilder/utils/getEntryLockInfo.ts b/src/visualBuilder/utils/getEntryLockInfo.ts new file mode 100644 index 00000000..6fe8480a --- /dev/null +++ b/src/visualBuilder/utils/getEntryLockInfo.ts @@ -0,0 +1,53 @@ +import { VisualBuilderPostMessageEvents } from "./types/postMessage.types"; +import visualBuilderPostMessage from "./visualBuilderPostMessage"; +import { + EntryFieldLockInfo, + EntryLockScope, + getEntryFieldLockVersion, + setEntryFieldLockInfo, +} from "./fieldLockStore"; + +/** + * Asks the parent for the current field-lock snapshot of an entry and stores it + * in the SDK mirror. The parent fetches it from the lock-status route and + * subscribes to the entry's presence channel so it can push later updates. + * Returns `null` (rather than throwing) when the read fails, so the caller can + * tell a genuine "no locks" snapshot ({}) apart from a transient failure and + * retry the latter. + */ +export async function getEntryLockInfo( + scope: EntryLockScope +): Promise { + // Snapshot the scope version before the round-trip so we can tell if a newer + // delta (ENTRY_LOCK_INFO_UPDATE) landed while we waited and avoid clobbering + // it with this now-stale snapshot. + const versionBeforeRequest = getEntryFieldLockVersion(scope); + try { + const response = await visualBuilderPostMessage?.send<{ + fieldLockInfo?: EntryFieldLockInfo; + error?: boolean; + }>(VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO, { ...scope }); + + // The parent flags a genuine fetch failure with `error: true` (not an + // empty map), so a transient blip is retried instead of being cached as + // "no locks" for the rest of the session. + if (!response || response.error || response.fieldLockInfo == null) { + return null; + } + + const fieldLockInfo = response.fieldLockInfo; + // Only seed the mirror if no delta updated this scope during the + // round-trip; a delta that arrived meanwhile is fresher than this + // snapshot, so keep it. + if (getEntryFieldLockVersion(scope) === versionBeforeRequest) { + setEntryFieldLockInfo(scope, fieldLockInfo); + } + return fieldLockInfo; + } catch (error) { + console.debug( + "[Visual Builder] Error fetching entry lock info", + error + ); + return null; + } +} diff --git a/src/visualBuilder/utils/types/postMessage.types.ts b/src/visualBuilder/utils/types/postMessage.types.ts index 54ce0f87..2a85459b 100644 --- a/src/visualBuilder/utils/types/postMessage.types.ts +++ b/src/visualBuilder/utils/types/postMessage.types.ts @@ -33,6 +33,7 @@ export enum VisualBuilderPostMessageEvents { GET_WORKFLOW_STAGE_DETAILS = "get-workflow-stage-details", GET_RESOLVED_VARIANT_PERMISSIONS = "get-resolved-variant-permissions", OPEN_REQUEST_EDIT_ACCESS = "open-request-edit-access", + GET_ENTRY_LOCK_INFO = "get-entry-lock-info", // FROM visual builder GET_ALL_ENTRIES_IN_CURRENT_PAGE = "get-entries-in-current-page", @@ -61,6 +62,7 @@ export enum VisualBuilderPostMessageEvents { TOGGLE_SCROLL = "toggle-scroll", PAGE_CONTEXT = "page-context", REQUEST_DISCUSSION_HIGHLIGHTS = "request-discussion-highlights", + ENTRY_LOCK_INFO_UPDATE = "entry-lock-info-update", } export interface IPageContextPostMessageEvent { diff --git a/src/visualBuilder/visualBuilder.style.ts b/src/visualBuilder/visualBuilder.style.ts index 2f394cca..d5d6a7ce 100644 --- a/src/visualBuilder/visualBuilder.style.ts +++ b/src/visualBuilder/visualBuilder.style.ts @@ -710,6 +710,27 @@ export function visualBuilderStyles() { "visual-builder__draft-field": css` outline: 2px dashed #eb5646; `, + "visual-builder__lock-avatar": css` + position: absolute; + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + line-height: 1; + color: #ffffff; + background: #6c5ce7; + box-shadow: 0 0 0 2px #ffffff; + pointer-events: none; + transition: var(--outline-transition); + z-index: 2147483647 !important; + `, + "visual-builder__lock-avatar--hidden": css` + display: none; + `, "visual-builder__variant-field": css``, "visual-builder__variant-field-outline": css` outline: 2px solid #bd59fa;