diff --git a/src/visualBuilder/components/FieldToolbar.tsx b/src/visualBuilder/components/FieldToolbar.tsx
index 49cd2a7f..766641a1 100644
--- a/src/visualBuilder/components/FieldToolbar.tsx
+++ b/src/visualBuilder/components/FieldToolbar.tsx
@@ -43,6 +43,8 @@ import { EntryPermissions } from "../utils/getEntryPermissions";
import { FieldLocationAppList } from "./FieldLocationAppList";
import { FieldLocationIcon } from "./FieldLocationIcon";
import { WorkflowStageDetails } from "../utils/getWorkflowStageDetails";
+import { getEntryEditRestrictionForField } from "../utils/fieldLockIndicator";
+import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore";
import { ResolvedVariantPermissions } from "../utils/getResolvedVariantPermissions";
import { isCustomFieldMultipleInstance as checkIsCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance";
@@ -153,7 +155,17 @@ function FieldToolbarComponent(
let isWholeMultipleField = false;
const APP_LIST_MIN_WIDTH = 230;
- let disableFieldActions = false;
+ const [restrictionReason, setRestrictionReason] = useState(() =>
+ getEntryEditRestrictionForField(fieldMetadata)
+ );
+ useEffect(() => {
+ const update = () =>
+ setRestrictionReason(getEntryEditRestrictionForField(fieldMetadata));
+ update();
+ return subscribeEntryFieldLockInfo(update);
+ }, [fieldMetadata]);
+
+ let disableFieldActions = Boolean(restrictionReason);
let isCustomFieldMultipleInstance = false;
let isCustomFieldWholeMultiple = false;
if (fieldSchema) {
@@ -167,7 +179,7 @@ function FieldToolbarComponent(
entryPermissions,
entryWorkflowStageDetails,
);
- disableFieldActions = isDisabled;
+ disableFieldActions = isDisabled || Boolean(restrictionReason);
fieldType = getFieldType(fieldSchema);
diff --git a/src/visualBuilder/components/__test__/emptyBlock.test.tsx b/src/visualBuilder/components/__test__/emptyBlock.test.tsx
index fb53a33d..6201afb5 100644
--- a/src/visualBuilder/components/__test__/emptyBlock.test.tsx
+++ b/src/visualBuilder/components/__test__/emptyBlock.test.tsx
@@ -7,7 +7,7 @@ import { CslpData } from "../../../cslp/types/cslp.types";
import { ISchemaFieldMap } from "../../utils/types/index.types";
import { VisualBuilderPostMessageEvents } from "../../utils/types/postMessage.types";
import { getDOMEditStack } from "../../utils/getCsDataOfElement";
-import { getPeerLockForField } from "../../utils/fieldLockIndicator";
+import { isFieldBlockedByAutoDraft } from "../../utils/fieldLockIndicator";
vi.mock("../../utils/visualBuilderPostMessage", () => ({
default: {
@@ -20,7 +20,7 @@ vi.mock("../../utils/multipleElementAddButton", () => ({
}));
vi.mock("../../utils/fieldLockIndicator", () => ({
- getPeerLockForField: vi.fn(() => null),
+ isFieldBlockedByAutoDraft: vi.fn(() => false),
}));
describe("EmptyBlock", () => {
@@ -129,15 +129,13 @@ describe("EmptyBlock", () => {
);
});
- test("adds nothing when a peer holds the field", async () => {
- (getPeerLockForField as any).mockReturnValueOnce({
- user: { uid: "peer" },
- });
+ test("adds nothing when a peer holds the field or the entry is restricted", async () => {
+ (isFieldBlockedByAutoDraft as any).mockReturnValueOnce(true);
const { getByTestId } = render();
fireEvent.click(getByTestId("visual-builder__empty-block-add-button"));
- await waitFor(() => expect(getPeerLockForField).toHaveBeenCalled());
+ await waitFor(() => expect(isFieldBlockedByAutoDraft).toHaveBeenCalled());
expect((visualBuilderPostMessage as any).send).not.toHaveBeenCalled();
expect(observeParentAndFocusNewInstance).not.toHaveBeenCalled();
});
diff --git a/src/visualBuilder/components/__test__/fieldToolbar.test.tsx b/src/visualBuilder/components/__test__/fieldToolbar.test.tsx
index 17feac39..7f2c4ae3 100644
--- a/src/visualBuilder/components/__test__/fieldToolbar.test.tsx
+++ b/src/visualBuilder/components/__test__/fieldToolbar.test.tsx
@@ -27,6 +27,10 @@ import React from "preact/compat";
import visualBuilderPostMessage from "../../utils/visualBuilderPostMessage";
import { FieldLocationIcon } from "../FieldLocationIcon";
import { VisualBuilderPostMessageEvents } from "../../utils/types/postMessage.types";
+import {
+ clearAllEntryFieldLockInfo,
+ setEntryEditRestriction,
+} from "../../utils/fieldLockStore";
vi.mock("../../utils/instanceHandlers", () => ({
handleMoveInstance: vi.fn(),
@@ -629,5 +633,53 @@ describe("FieldToolbarComponent", () => {
expect(replaceButton).toBeDisabled();
}
});
+
+ describe("on an entry the parent has restricted", () => {
+ const scope = { entryUid: "e1", locale: "en-us" };
+ const renderToolbar = async () => {
+ const { container } = render(
+
+ );
+ await findByTestId(
+ container as HTMLElement,
+ "visual-builder__focused-toolbar__multiple-field-toolbar",
+ {},
+ { timeout: 1000 }
+ );
+ return () =>
+ container.querySelector(
+ '[data-testid="visual-builder__focused-toolbar__multiple-field-toolbar__delete-button"]'
+ );
+ };
+
+ afterEach(() => clearAllEntryFieldLockInfo());
+
+ test("disables the field actions", async () => {
+ setEntryEditRestriction(scope, "olderVersion");
+
+ const deleteButton = await renderToolbar();
+
+ await waitFor(() => expect(deleteButton()).toBeDisabled());
+ });
+
+ test("disables the field actions when the restriction arrives after the toolbar opened", async () => {
+ const deleteButton = await renderToolbar();
+ await waitFor(() => expect(deleteButton()).not.toBeDisabled());
+
+ act(() => setEntryEditRestriction(scope, "olderVersion"));
+
+ await waitFor(() => expect(deleteButton()).toBeDisabled());
+ });
+ });
});
});
diff --git a/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.disabledClass.test.tsx b/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.disabledClass.test.tsx
index 74809751..ba72892f 100644
--- a/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.disabledClass.test.tsx
+++ b/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.disabledClass.test.tsx
@@ -17,6 +17,7 @@ import {
} from "./fieldLabelWrapper.mocks";
import {
setEntryFieldLockInfo,
+ setEntryEditRestriction,
clearAllEntryFieldLockInfo,
} from "../../../utils/fieldLockStore";
@@ -383,4 +384,83 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => {
"visual-builder__focused-toolbar--field-disabled"
);
});
+
+ const OLDER_VERSION_MESSAGE =
+ "You're viewing an older version of this entry. Switch to the latest version to edit.";
+
+ const renderLabel = async () => {
+ const { container } = render(
+
+ );
+ await act(async () => {
+ await new Promise((resolve) =>
+ queueMicrotask(() => resolve())
+ );
+ });
+ return (await findByTestId(
+ container as HTMLElement,
+ "visual-builder__focused-toolbar__field-label-wrapper",
+ {},
+ { timeout: 1000 }
+ )) as HTMLElement;
+ };
+
+ test("shows a restricted entry as disabled with the restriction's message", async () => {
+ (isFieldDisabled as any).mockReturnValue({
+ isDisabled: false,
+ reason: "",
+ });
+ setEntryEditRestriction(
+ {
+ entryUid: mockFieldMetadata.entry_uid,
+ locale: mockFieldMetadata.locale,
+ },
+ "olderVersion"
+ );
+
+ const fieldLabel = await renderLabel();
+
+ expect(fieldLabel).toHaveClass(
+ "visual-builder__focused-toolbar--field-disabled"
+ );
+ expect(
+ fieldLabel.querySelector(
+ `[data-tooltip="${OLDER_VERSION_MESSAGE}"]`
+ )
+ ).not.toBeNull();
+ });
+
+ test("prefers the entry restriction's message over a peer lock", async () => {
+ (isFieldDisabled as any).mockReturnValue({
+ isDisabled: false,
+ reason: "",
+ });
+ const scope = {
+ entryUid: mockFieldMetadata.entry_uid,
+ locale: mockFieldMetadata.locale,
+ };
+ setEntryFieldLockInfo(scope, {
+ [mockFieldMetadata.fieldPath]: {
+ user: { uid: "u1", name: "Ada Lovelace" },
+ ttl: "2030-01-01",
+ isLocked: true,
+ isOwn: false,
+ },
+ });
+ setEntryEditRestriction(scope, "olderVersion");
+
+ const fieldLabel = await renderLabel();
+
+ expect(
+ fieldLabel.querySelector(
+ `[data-tooltip="${OLDER_VERSION_MESSAGE}"]`
+ )
+ ).not.toBeNull();
+ expect(fieldLabel.textContent).not.toContain("Ada Lovelace");
+ });
});
diff --git a/src/visualBuilder/components/emptyBlock.tsx b/src/visualBuilder/components/emptyBlock.tsx
index 4ca4a818..617b7e2f 100644
--- a/src/visualBuilder/components/emptyBlock.tsx
+++ b/src/visualBuilder/components/emptyBlock.tsx
@@ -8,7 +8,7 @@ import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types
import React from "preact/compat";
import { startCase, toLower } from "lodash-es";
import { getDOMEditStack } from "../utils/getCsDataOfElement";
-import { getPeerLockForField } from "../utils/fieldLockIndicator";
+import { isFieldBlockedByAutoDraft } from "../utils/fieldLockIndicator";
import { DATA_CSLP_ATTR_SELECTOR } from "../utils/constants";
interface EmptyBlockProps {
@@ -26,9 +26,9 @@ export function EmptyBlock(props: EmptyBlockProps): JSX.Element {
async function sendAddInstanceEvent(
event: JSX.TargetedMouseEvent
) {
- // A peer holds this field: adding would edit through their lock, the same
- // no-op a click on a peer-locked field gets in the click listener.
- if (getPeerLockForField(details.fieldMetadata)) return;
+ // A peer holds this field or its entry is restricted: the same no-op a click on a
+ // blocked field gets in the click listener.
+ if (isFieldBlockedByAutoDraft(details.fieldMetadata)) return;
// Resolve the field by its cslp, not the button's DOM position: a portal
// render would yield an empty stack and silently skip the lock claim.
diff --git a/src/visualBuilder/components/fieldLabelWrapper.tsx b/src/visualBuilder/components/fieldLabelWrapper.tsx
index c815f3be..b27fce53 100644
--- a/src/visualBuilder/components/fieldLabelWrapper.tsx
+++ b/src/visualBuilder/components/fieldLabelWrapper.tsx
@@ -20,7 +20,11 @@ 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 {
+ getEntryEditRestrictionForField,
+ getPeerLockForField,
+ lockAvatarInfo,
+} from "../utils/fieldLockIndicator";
import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore";
interface ReferenceParentMap {
@@ -236,13 +240,20 @@ function FieldLabelWrapperComponent(
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 restrictionReason = getEntryEditRestrictionForField(
+ props.fieldMetadata
+ );
+ const effectiveDisabled =
+ base.fieldDisabled || Boolean(restrictionReason) || Boolean(peerLock);
+ // Permission and workflow reasons win, then the entry restriction, then a peer lock.
+ let effectiveReason = base.reason;
+ if (!base.fieldDisabled && restrictionReason) {
+ effectiveReason = restrictionReason;
+ } else if (!base.fieldDisabled && peerLock) {
+ effectiveReason = `This field is locked by ${
+ lockAvatarInfo(peerLock).name || "another user"
+ }`;
+ }
const usePlainDataTooltip =
effectiveReason &&
!effectiveReason.includes(DisableReason.CanLinkVariant) &&
diff --git a/src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts b/src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts
new file mode 100644
index 00000000..e2c548ed
--- /dev/null
+++ b/src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts
@@ -0,0 +1,32 @@
+import visualBuilderPostMessage from "../utils/visualBuilderPostMessage";
+import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types";
+import {
+ setEntryEditRestriction,
+ toEntryEditRestriction,
+} from "../utils/fieldLockStore";
+
+interface EntryEditRestrictionUpdateEvent {
+ data: {
+ entryUid: string;
+ locale: string;
+ variantUid?: string;
+ restriction: unknown;
+ };
+}
+
+/**
+ * Mirrors the parent's per-entry edit restriction (older version open, unlocalized, ...) so the
+ * entry's fields render disabled with the matching reason. Null clears the scope.
+ */
+export function useEntryEditRestrictionUpdateEvent(): void {
+ visualBuilderPostMessage?.on(
+ VisualBuilderPostMessageEvents.ENTRY_EDIT_RESTRICTION_UPDATE,
+ (event: EntryEditRestrictionUpdateEvent) => {
+ const { entryUid, locale, variantUid, restriction } = event.data;
+ setEntryEditRestriction(
+ { entryUid, locale, variantUid },
+ toEntryEditRestriction(restriction)
+ );
+ }
+ );
+}
diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts
index 37a082eb..49378844 100644
--- a/src/visualBuilder/index.ts
+++ b/src/visualBuilder/index.ts
@@ -26,6 +26,8 @@ import { extractDetailsFromCslp, isValidCslp } from "../cslp";
import initUI from "./components";
import { useDraftFieldsPostMessageEvent } from "./eventManager/useDraftFieldsPostMessageEvent";
import { useEntryLockInfoUpdateEvent } from "./eventManager/useEntryLockInfoUpdateEvent";
+import { useEntryEditRestrictionUpdateEvent } from "./eventManager/useEntryEditRestrictionUpdateEvent";
+import { getEntryEditRestrictionForField } from "./utils/fieldLockIndicator";
import { useHideFocusOverlayPostMessageEvent } from "./eventManager/useHideFocusOverlayPostMessageEvent";
import { useScrollToField } from "./eventManager/useScrollToField";
import { debounceAddVariantFieldClass, getHighlightVariantFieldsStatus, setHighlightVariantFields, useVariantFieldsPostMessageEvent } from "./eventManager/useVariantsPostMessageEvent";
@@ -195,10 +197,12 @@ export class VisualBuilder {
if (!fieldSchema) {
return;
}
- const { isDisabled } = isFieldDisabled(fieldSchema, {
- editableElement,
- fieldMetadata,
- });
+ const isDisabled =
+ isFieldDisabled(fieldSchema, {
+ editableElement,
+ fieldMetadata,
+ }).isDisabled ||
+ Boolean(getEntryEditRestrictionForField(fieldMetadata));
if (isDisabled) {
addFocusOverlay(
editableElement,
@@ -416,6 +420,7 @@ export class VisualBuilder {
useRecalculateVariantDataCSLPValues();
useDraftFieldsPostMessageEvent();
useEntryLockInfoUpdateEvent();
+ useEntryEditRestrictionUpdateEvent();
useVariantFieldsPostMessageEvent({ isSSR: config.ssr ?? false });
}
})
diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts
index 0e9a0ef0..f2e6c560 100644
--- a/src/visualBuilder/listeners/mouseClick.ts
+++ b/src/visualBuilder/listeners/mouseClick.ts
@@ -21,7 +21,10 @@ 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 {
+ getEntryEditRestrictionForField,
+ getPeerLockForField,
+} from "../utils/fieldLockIndicator";
import EventListenerHandlerParams from "./types";
import { toggleHighlightedCommentIconDisplay } from "../generators/generateHighlightedComment";
import { VB_EmptyBlockParentClass } from "../..";
@@ -187,7 +190,8 @@ export async function handleBuilderInteraction(
// 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.
+ // release. An entry-wide restriction does not stop here: the click still opens
+ // the form (the way back to the latest version), and the field shows disabled.
if (eventDetails && getPeerLockForField(eventDetails.fieldMetadata)) {
return;
}
@@ -438,7 +442,7 @@ async function handleFieldSchemaAndIndividualFields(
entryAcl,
entryWorkflowStageDetails
);
- if (isDisabled) {
+ if (isDisabled || getEntryEditRestrictionForField(fieldMetadata)) {
addOverlay({
overlayWrapper: params.overlayWrapper,
resizeObserver: params.resizeObserver,
diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts
index 7eacae8d..3a9e8eca 100644
--- a/src/visualBuilder/listeners/mouseHover.ts
+++ b/src/visualBuilder/listeners/mouseHover.ts
@@ -20,7 +20,10 @@ 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 {
+ getPeerLockForField,
+ isFieldBlockedByAutoDraft,
+} from "../utils/fieldLockIndicator";
import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore";
import {
showLockAvatar,
@@ -103,7 +106,7 @@ async function addOutline(params?: AddOutlineParams): Promise {
}
addHoverOutline(
editableElement as HTMLElement,
- fieldDisabled || Boolean(peerLock),
+ fieldDisabled || isFieldBlockedByAutoDraft(fieldMetadata),
isVariant
);
const fieldSchema = await FieldSchemaMap.getFieldSchema(
@@ -134,7 +137,7 @@ async function addOutline(params?: AddOutlineParams): Promise {
}
addHoverOutline(
editableElement,
- fieldDisabled || isDisabled || Boolean(peerLock),
+ fieldDisabled || isDisabled || isFieldBlockedByAutoDraft(fieldMetadata),
isVariant
);
}
@@ -503,7 +506,7 @@ async function generateCursor({
generateCustomCursor({
fieldType,
customCursor,
- fieldDisabled: fieldDisabled || Boolean(getPeerLockForField(fieldMetadata)),
+ fieldDisabled: fieldDisabled || isFieldBlockedByAutoDraft(fieldMetadata),
});
}
diff --git a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts
new file mode 100644
index 00000000..45426191
--- /dev/null
+++ b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts
@@ -0,0 +1,244 @@
+import { describe, it, expect, beforeEach, vi, MockedObject } from "vitest";
+import { EventManager } from "@contentstack/advanced-post-message";
+import {
+ ENTRY_RESTRICTION_MESSAGES,
+ getEntryEditRestrictionForField,
+ isFieldBlockedByAutoDraft,
+ requestEntryLockInfoOnce,
+ waitForEntryLockInfo,
+} from "../fieldLockIndicator";
+import {
+ clearAllEntryFieldLockInfo,
+ getEntryEditRestriction,
+ setEntryEditRestriction,
+ setEntryFieldLockInfo,
+ subscribeEntryFieldLockInfo,
+} from "../fieldLockStore";
+import { getEntryLockInfo } from "../getEntryLockInfo";
+import { useEntryEditRestrictionUpdateEvent } from "../../eventManager/useEntryEditRestrictionUpdateEvent";
+import { VisualBuilderPostMessageEvents } from "../types/postMessage.types";
+import visualBuilderPostMessage from "../visualBuilderPostMessage";
+
+vi.mock("../visualBuilderPostMessage", () => ({
+ default: { send: vi.fn(), on: vi.fn() },
+}));
+
+const mockPostMessage = visualBuilderPostMessage as MockedObject;
+
+const scope = { entryUid: "entry1", locale: "en-us" };
+
+const meta = (over: Record = {}) =>
+ ({
+ entry_uid: "entry1",
+ locale: "en-us",
+ variant: undefined,
+ fieldPath: "title",
+ fieldPathWithIndex: "title",
+ ...over,
+ }) as never;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ clearAllEntryFieldLockInfo();
+});
+
+describe("getEntryEditRestrictionForField", () => {
+ it("returns the older-version message for a restricted entry", () => {
+ setEntryEditRestriction(scope, "olderVersion");
+ expect(getEntryEditRestrictionForField(meta())).toBe(
+ ENTRY_RESTRICTION_MESSAGES.olderVersion
+ );
+ });
+
+ it("returns a distinct message per restriction", () => {
+ setEntryEditRestriction(scope, "unlocalized");
+ expect(getEntryEditRestrictionForField(meta())).toBe(
+ ENTRY_RESTRICTION_MESSAGES.unlocalized
+ );
+ setEntryEditRestriction(scope, "unsavedVariant");
+ expect(getEntryEditRestrictionForField(meta())).toBe(
+ ENTRY_RESTRICTION_MESSAGES.unsavedVariant
+ );
+ });
+
+ it("returns null once the restriction clears", () => {
+ setEntryEditRestriction(scope, "olderVersion");
+ setEntryEditRestriction(scope, null);
+ expect(getEntryEditRestrictionForField(meta())).toBeNull();
+ });
+
+ it("applies a base-entry restriction to that entry's variant fields", () => {
+ setEntryEditRestriction(scope, "olderVersion");
+ expect(getEntryEditRestrictionForField(meta({ variant: "v1" }))).toBe(
+ ENTRY_RESTRICTION_MESSAGES.olderVersion
+ );
+ });
+
+ it("keeps a variant restriction off the base entry's fields", () => {
+ setEntryEditRestriction({ ...scope, variantUid: "v1" }, "olderVersion");
+ expect(getEntryEditRestrictionForField(meta())).toBeNull();
+ expect(
+ getEntryEditRestrictionForField(meta({ variant: "v1" }))
+ ).not.toBeNull();
+ });
+
+ it("does not affect other entries or locales", () => {
+ setEntryEditRestriction(scope, "olderVersion");
+ expect(
+ getEntryEditRestrictionForField(meta({ entry_uid: "entry2" }))
+ ).toBeNull();
+ expect(getEntryEditRestrictionForField(meta({ locale: "fr" }))).toBeNull();
+ });
+});
+
+describe("isFieldBlockedByAutoDraft", () => {
+ it("is true for a restricted entry and for a peer lock, false otherwise", () => {
+ expect(isFieldBlockedByAutoDraft(meta())).toBe(false);
+ setEntryFieldLockInfo(scope, {
+ title: { user: { uid: "u1" }, ttl: "2030", isLocked: true, isOwn: false },
+ });
+ expect(isFieldBlockedByAutoDraft(meta())).toBe(true);
+ setEntryFieldLockInfo(scope, {});
+ setEntryEditRestriction(scope, "olderVersion");
+ expect(isFieldBlockedByAutoDraft(meta())).toBe(true);
+ });
+});
+
+describe("restriction updates", () => {
+ it("notifies lock listeners so the hover and label repaint", () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeEntryFieldLockInfo(listener);
+ setEntryEditRestriction(scope, "olderVersion");
+ unsubscribe();
+ expect(listener).toHaveBeenCalled();
+ });
+
+ it("mirrors ENTRY_EDIT_RESTRICTION_UPDATE from the parent", () => {
+ useEntryEditRestrictionUpdateEvent();
+ const [event, handler] = mockPostMessage.on.mock.calls[0] as any;
+ expect(event).toBe(
+ VisualBuilderPostMessageEvents.ENTRY_EDIT_RESTRICTION_UPDATE
+ );
+
+ handler({ data: { ...scope, restriction: "olderVersion" } });
+ expect(getEntryEditRestriction(scope)).toBe("olderVersion");
+ handler({ data: { ...scope, restriction: null } });
+ expect(getEntryEditRestriction(scope)).toBeNull();
+ });
+
+ it("ignores a restriction value it does not know", () => {
+ useEntryEditRestrictionUpdateEvent();
+ const handler = mockPostMessage.on.mock.calls[0][1] as any;
+ handler({ data: { ...scope, restriction: "
" } });
+ expect(getEntryEditRestriction(scope)).toBeNull();
+ });
+
+ it("does not notify listeners when the value is unchanged", () => {
+ setEntryEditRestriction(scope, "olderVersion");
+ const listener = vi.fn();
+ const unsubscribe = subscribeEntryFieldLockInfo(listener);
+ setEntryEditRestriction(scope, "olderVersion");
+ setEntryEditRestriction({ ...scope, entryUid: "other" }, null);
+ unsubscribe();
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
+
+describe("snapshot seeding", () => {
+ const request = { ...scope, contentTypeUid: "page" };
+
+ it("seeds every restricted scope returned with the lock snapshot", async () => {
+ mockPostMessage.send.mockResolvedValueOnce({
+ fieldLockInfo: {},
+ editRestrictions: {
+ "entry1:en-us": "olderVersion",
+ "entry1:en-us:v1": "unlocalized",
+ },
+ });
+
+ await getEntryLockInfo(request);
+
+ expect(getEntryEditRestriction(scope)).toBe("olderVersion");
+ expect(getEntryEditRestriction({ ...scope, variantUid: "v1" })).toBe(
+ "unlocalized"
+ );
+ });
+
+ it("does not let a late snapshot undo an update that arrived meanwhile", async () => {
+ let resolve!: (value: unknown) => void;
+ mockPostMessage.send.mockReturnValueOnce(
+ new Promise((r) => {
+ resolve = r;
+ }) as never
+ );
+
+ const pending = getEntryLockInfo(request);
+ setEntryEditRestriction(scope, null);
+ resolve({
+ fieldLockInfo: {},
+ editRestrictions: { "entry1:en-us": "olderVersion" },
+ });
+ await pending;
+
+ expect(getEntryEditRestriction(scope)).toBeNull();
+ });
+
+ it("clears this entry's scopes that the snapshot no longer lists, and only this entry's", async () => {
+ setEntryEditRestriction(scope, "olderVersion");
+ setEntryEditRestriction({ ...scope, entryUid: "entry2" }, "olderVersion");
+ mockPostMessage.send.mockResolvedValueOnce({
+ fieldLockInfo: {},
+ editRestrictions: {},
+ });
+
+ await getEntryLockInfo(request);
+
+ expect(getEntryEditRestriction(scope)).toBeNull();
+ expect(
+ getEntryEditRestriction({ ...scope, entryUid: "entry2" })
+ ).toBe("olderVersion");
+ });
+
+ it("leaves restrictions alone when an older parent sends no editRestrictions", async () => {
+ setEntryEditRestriction(scope, "olderVersion");
+ mockPostMessage.send.mockResolvedValueOnce({ fieldLockInfo: {} });
+
+ await getEntryLockInfo(request);
+
+ expect(getEntryEditRestriction(scope)).toBe("olderVersion");
+ });
+
+ it("lets inline editing wait for a first snapshot still in flight", async () => {
+ let resolve!: (value: unknown) => void;
+ mockPostMessage.send.mockReturnValueOnce(
+ new Promise((r) => {
+ resolve = r;
+ }) as never
+ );
+ const wideScope = { ...request, entryUid: "entry-wait" };
+ void requestEntryLockInfoOnce(wideScope);
+
+ let done = false;
+ const waiting = waitForEntryLockInfo(wideScope, 5000).then(() => {
+ done = true;
+ });
+ await new Promise((r) => setTimeout(r, 0));
+ expect(done).toBe(false);
+
+ resolve({
+ fieldLockInfo: {},
+ editRestrictions: { "entry-wait:en-us": "olderVersion" },
+ });
+ await waiting;
+
+ expect(
+ getEntryEditRestriction({ entryUid: "entry-wait", locale: "en-us" })
+ ).toBe("olderVersion");
+ });
+
+ it("does not wait when nothing is in flight", async () => {
+ await expect(
+ waitForEntryLockInfo({ entryUid: "idle", locale: "en-us" }, 5000)
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts b/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts
index cf09f5e0..f162ea15 100644
--- a/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts
+++ b/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts
@@ -18,6 +18,10 @@ import { VisualBuilder } from "../..";
import { act } from "@testing-library/preact";
import { VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY } from "../constants";
import { FieldDataType } from "../types/index.types";
+import {
+ clearAllEntryFieldLockInfo,
+ setEntryEditRestriction,
+} from "../fieldLockStore";
vi.mock("../fieldSchemaMap");
vi.mock("../getFieldData");
@@ -188,6 +192,50 @@ describe("handleIndividualFields", () => {
});
});
+describe("handleIndividualFields on a restricted entry", () => {
+ it("does not make the field editable while its entry is restricted", async () => {
+ clearAllEntryFieldLockInfo();
+ setEntryEditRestriction(
+ { entryUid: "entryUid", locale: "en-us" },
+ "olderVersion"
+ );
+ const editableElement = document.createElement("div");
+ editableElement.textContent = "value";
+ (FieldSchemaMap.getFieldSchema as Mock).mockResolvedValue({
+ data_type: FieldDataType.SINGLELINE,
+ multiple: false,
+ });
+ (getFieldData as Mock).mockResolvedValue("value");
+ (getFieldType as Mock).mockReturnValue(FieldDataType.SINGLELINE);
+ (isFieldDisabled as Mock).mockReturnValue({ isDisabled: false });
+
+ await act(async () => {
+ await handleIndividualFields(
+ {
+ // @ts-expect-error mocking only required properties
+ fieldMetadata: {
+ content_type_uid: "contentTypeUid",
+ entry_uid: "entryUid",
+ locale: "en-us",
+ fieldPath: "fieldPath",
+ fieldPathWithIndex: "fieldPathWithIndex",
+ instance: { fieldPathWithIndex: "fieldPathWithIndex.0" },
+ },
+ editableElement,
+ },
+ {
+ visualBuilderContainer: document.createElement("div"),
+ resizeObserver: new ResizeObserver(() => {}),
+ lastEditedField: null,
+ }
+ );
+ });
+ clearAllEntryFieldLockInfo();
+
+ expect(editableElement.getAttribute("contenteditable")).not.toBe("true");
+ });
+});
+
describe("cleanIndividualFieldResidual", () => {
let elements: {
overlayWrapper: HTMLDivElement;
diff --git a/src/visualBuilder/utils/__test__/updateFocussedState.test.ts b/src/visualBuilder/utils/__test__/updateFocussedState.test.ts
index 073db9e1..c5fd3662 100644
--- a/src/visualBuilder/utils/__test__/updateFocussedState.test.ts
+++ b/src/visualBuilder/utils/__test__/updateFocussedState.test.ts
@@ -13,6 +13,10 @@ import { act } from "@testing-library/preact";
import { singleLineFieldSchema } from "../../../__test__/data/fields";
import { fetchEntryPermissionsAndStageDetails } from "../fetchEntryPermissionsAndStageDetails";
import { isFieldDisabled } from "../isFieldDisabled";
+import {
+ clearAllEntryFieldLockInfo,
+ setEntryEditRestriction,
+} from "../fieldLockStore";
import { getEntryPermissionsCached } from "../getEntryPermissionsCached";
vi.mock("../../generators/generateOverlay", () => ({
@@ -394,4 +398,35 @@ describe("updateFocussedStateOnMutation", () => {
expect(focusOutlineMock.style.width).toBe("100px");
expect(focusOutlineMock.style.height).toBe("100px");
});
+
+ it("redraws the focus overlay as disabled while the entry is restricted", async () => {
+ setEntryEditRestriction(
+ { entryUid: "entry_uid", locale: "locale" },
+ "olderVersion"
+ );
+ const editableElementMock = document.createElement("div");
+ editableElementMock.setAttribute(
+ "data-cslp",
+ "content_type_uid.entry_uid.locale.field_path"
+ );
+
+ await act(async () => {
+ await updateFocussedState({
+ editableElement: editableElementMock,
+ visualBuilderContainer: document.createElement("div"),
+ overlayWrapper: document.createElement("div"),
+ focusedToolbar: document.createElement("div"),
+ resizeObserver: {
+ disconnect: vi.fn(),
+ } as unknown as ResizeObserver,
+ });
+ });
+ clearAllEntryFieldLockInfo();
+
+ expect(addFocusOverlay).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ expect.any(HTMLElement),
+ true
+ );
+ });
});
diff --git a/src/visualBuilder/utils/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts
index b648e88f..bb435051 100644
--- a/src/visualBuilder/utils/fieldLockIndicator.ts
+++ b/src/visualBuilder/utils/fieldLockIndicator.ts
@@ -1,6 +1,13 @@
-import { getEntryFieldLockInfo } from "./fieldLockStore";
+import {
+ getEntryEditRestriction,
+ getEntryFieldLockInfo,
+} from "./fieldLockStore";
import { getEntryLockInfo } from "./getEntryLockInfo";
-import type { EntryFieldLock, EntryLockScope } from "./fieldLockStore";
+import type {
+ EntryEditRestriction,
+ EntryFieldLock,
+ EntryLockScope,
+} from "./fieldLockStore";
import type { CslpData } from "../../cslp/types/cslp.types";
type FieldMetadataForLock = Pick<
@@ -56,6 +63,46 @@ export function getPeerLockForField(
return null;
}
+export const ENTRY_RESTRICTION_MESSAGES: Record = {
+ olderVersion:
+ "You're viewing an older version of this entry. Switch to the latest version to edit.",
+ unlocalized:
+ "This entry isn't localized in this language yet. Save it from the form to localize it, then edit here.",
+ unsavedVariant:
+ "This variant hasn't been saved yet. Save it from the form to edit this field here.",
+};
+
+/**
+ * The message for an entry-wide edit restriction on this field's entry, or null. A variant
+ * field also inherits a restriction recorded on its base entry.
+ */
+export function getEntryEditRestrictionForField(
+ fieldMetadata: FieldMetadataForLock
+): string | null {
+ const scope = {
+ entryUid: fieldMetadata.entry_uid,
+ locale: fieldMetadata.locale,
+ };
+ const restriction =
+ (fieldMetadata.variant
+ ? getEntryEditRestriction({
+ ...scope,
+ variantUid: fieldMetadata.variant,
+ })
+ : null) ?? getEntryEditRestriction(scope);
+ return restriction ? ENTRY_RESTRICTION_MESSAGES[restriction] : null;
+}
+
+/** True when auto-draft blocks editing this field: a peer lock or an entry restriction. */
+export function isFieldBlockedByAutoDraft(
+ fieldMetadata: FieldMetadataForLock
+): boolean {
+ return (
+ Boolean(getEntryEditRestrictionForField(fieldMetadata)) ||
+ Boolean(getPeerLockForField(fieldMetadata))
+ );
+}
+
/** Avatar display (initials + colour + full name) for a lock's holder. */
export function lockAvatarInfo(lock: EntryFieldLock): {
initials: string;
@@ -79,6 +126,13 @@ export function lockAvatarInfo(lock: EntryFieldLock): {
/** Entries whose snapshot has already been requested this session. */
const requestedScopes = new Set();
+/** Snapshot requests still in flight, so a caller can wait for the first one. */
+const inFlightRequests = new Map>();
+
+const requestKey = (scope: Omit): string =>
+ `${scope.entryUid}.${scope.locale}${
+ scope.variantUid ? `.${scope.variantUid}` : ""
+ }`;
/**
* Requests an entry's lock snapshot at most once (the parent pushes deltas after,
@@ -90,16 +144,41 @@ const requestedScopes = new Set();
export async function requestEntryLockInfoOnce(
scope: EntryLockScope
): Promise {
- const key = `${scope.entryUid}.${scope.locale}${
- scope.variantUid ? `.${scope.variantUid}` : ""
- }`;
+ const key = requestKey(scope);
if (requestedScopes.has(key)) {
return;
}
requestedScopes.add(key);
- const result = await getEntryLockInfo(scope);
- if (result === null) {
- requestedScopes.delete(key);
+ const request = getEntryLockInfo(scope).then((result) => {
+ if (result === null) {
+ requestedScopes.delete(key);
+ }
+ });
+ inFlightRequests.set(key, request);
+ try {
+ await request;
+ } finally {
+ inFlightRequests.delete(key);
}
}
+
+/**
+ * Waits (bounded) for this scope's first snapshot if it is still in flight. Inline editing calls
+ * this so a fresh canvas does not open a field for editing before an entry restriction arrives.
+ */
+export async function waitForEntryLockInfo(
+ scope: Omit,
+ timeoutMs = 1500
+): Promise {
+ const request = inFlightRequests.get(requestKey(scope));
+ if (!request) return;
+ let timer: ReturnType | undefined;
+ await Promise.race([
+ request,
+ new Promise((resolve) => {
+ timer = setTimeout(resolve, timeoutMs);
+ }),
+ ]);
+ clearTimeout(timer);
+}
diff --git a/src/visualBuilder/utils/fieldLockStore.ts b/src/visualBuilder/utils/fieldLockStore.ts
index b2b55ee0..b5b97db4 100644
--- a/src/visualBuilder/utils/fieldLockStore.ts
+++ b/src/visualBuilder/utils/fieldLockStore.ts
@@ -27,6 +27,12 @@ export interface EntryFieldLock {
export type EntryFieldLockInfo = Record;
+/** Why the parent has turned editing off for a whole entry scope. */
+export type EntryEditRestriction =
+ | "olderVersion"
+ | "unlocalized"
+ | "unsavedVariant";
+
/** The parts that identify a lock scope: entry + locale + variant. */
export interface EntryLockScopeParts {
entryUid: string;
@@ -40,6 +46,9 @@ export interface EntryLockScope extends EntryLockScopeParts {
}
const store = new Map();
+const restrictions = new Map();
+// Write sequence per restricted scope, so a late snapshot does not undo a newer update.
+const restrictionWrites = 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();
@@ -101,5 +110,85 @@ export function getEntryFieldLockInfo(
export function clearAllEntryFieldLockInfo(): void {
store.clear();
scopeVersions.clear();
+ restrictions.clear();
+ restrictionWrites.clear();
notifyLockListeners();
}
+
+/** Current write sequence; pass it to `seedEntryEditRestrictions` after a round trip. */
+export function getEntryEditRestrictionWriteSeq(): number {
+ return writeSeq;
+}
+
+const RESTRICTIONS: ReadonlySet = new Set([
+ "olderVersion",
+ "unlocalized",
+ "unsavedVariant",
+]);
+
+/** Narrows a value from the parent; anything unknown is treated as "no restriction". */
+export function toEntryEditRestriction(
+ value: unknown
+): EntryEditRestriction | null {
+ return typeof value === "string" && RESTRICTIONS.has(value)
+ ? (value as EntryEditRestriction)
+ : null;
+}
+
+function writeRestriction(
+ key: string,
+ restriction: EntryEditRestriction | null
+): boolean {
+ restrictionWrites.set(key, ++writeSeq);
+ if ((restrictions.get(key) ?? null) === restriction) return false;
+ if (restriction) {
+ restrictions.set(key, restriction);
+ } else {
+ restrictions.delete(key);
+ }
+ return true;
+}
+
+export function setEntryEditRestriction(
+ scope: EntryLockScopeParts,
+ restriction: EntryEditRestriction | null
+): void {
+ if (writeRestriction(entryLockScopeKey(scope), restriction)) {
+ notifyLockListeners();
+ }
+}
+
+/**
+ * Replaces one entry's restrictions with a snapshot keyed `uid:locale[:variant]` (the parent's
+ * entry key). Scopes absent from it are cleared; any scope updated after `seqBeforeRequest` is kept.
+ */
+export function seedEntryEditRestrictions(
+ entryUid: string,
+ snapshot: Record,
+ seqBeforeRequest: number
+): void {
+ const next = new Map();
+ for (const key of restrictions.keys()) {
+ if (key.startsWith(`${entryUid}.`)) next.set(key, null);
+ }
+ for (const [parentKey, value] of Object.entries(snapshot)) {
+ const [uid, locale, variantUid] = parentKey.split(":");
+ if (uid !== entryUid) continue;
+ next.set(
+ entryLockScopeKey({ entryUid: uid, locale, variantUid }),
+ toEntryEditRestriction(value)
+ );
+ }
+ let changed = false;
+ for (const [key, restriction] of next) {
+ if ((restrictionWrites.get(key) ?? 0) > seqBeforeRequest) continue;
+ changed = writeRestriction(key, restriction) || changed;
+ }
+ if (changed) notifyLockListeners();
+}
+
+export function getEntryEditRestriction(
+ scope: EntryLockScopeParts
+): EntryEditRestriction | null {
+ return restrictions.get(entryLockScopeKey(scope)) ?? null;
+}
diff --git a/src/visualBuilder/utils/getEntryLockInfo.ts b/src/visualBuilder/utils/getEntryLockInfo.ts
index 6fe8480a..7e475428 100644
--- a/src/visualBuilder/utils/getEntryLockInfo.ts
+++ b/src/visualBuilder/utils/getEntryLockInfo.ts
@@ -3,7 +3,9 @@ import visualBuilderPostMessage from "./visualBuilderPostMessage";
import {
EntryFieldLockInfo,
EntryLockScope,
+ getEntryEditRestrictionWriteSeq,
getEntryFieldLockVersion,
+ seedEntryEditRestrictions,
setEntryFieldLockInfo,
} from "./fieldLockStore";
@@ -22,9 +24,11 @@ export async function getEntryLockInfo(
// delta (ENTRY_LOCK_INFO_UPDATE) landed while we waited and avoid clobbering
// it with this now-stale snapshot.
const versionBeforeRequest = getEntryFieldLockVersion(scope);
+ const restrictionSeqBeforeRequest = getEntryEditRestrictionWriteSeq();
try {
const response = await visualBuilderPostMessage?.send<{
fieldLockInfo?: EntryFieldLockInfo;
+ editRestrictions?: Record;
error?: boolean;
}>(VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO, { ...scope });
@@ -36,6 +40,14 @@ export async function getEntryLockInfo(
}
const fieldLockInfo = response.fieldLockInfo;
+ // An older parent omits the field; it cannot have restrictions, so skip the seed.
+ if (response.editRestrictions) {
+ seedEntryEditRestrictions(
+ scope.entryUid,
+ response.editRestrictions,
+ restrictionSeqBeforeRequest
+ );
+ }
// 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.
diff --git a/src/visualBuilder/utils/handleIndividualFields.ts b/src/visualBuilder/utils/handleIndividualFields.ts
index 3ddb6763..3eba70c2 100644
--- a/src/visualBuilder/utils/handleIndividualFields.ts
+++ b/src/visualBuilder/utils/handleIndividualFields.ts
@@ -6,6 +6,10 @@ import { getFieldData } from "./getFieldData";
import { getFieldType } from "./getFieldType";
import { handleFieldInput, handleFieldKeyDown } from "./handleFieldMouseDown";
import { isFieldDisabled } from "./isFieldDisabled";
+import {
+ getEntryEditRestrictionForField,
+ waitForEntryLockInfo,
+} from "./fieldLockIndicator";
import {
handleAddButtonsForMultiple,
removeAddInstanceButtons,
@@ -57,13 +61,19 @@ export async function handleIndividualFields(
variantUid: variant,
fieldPathWithIndex,
});
- const { isDisabled: disabled } = isFieldDisabled(
- fieldSchema,
- eventDetails,
- resolvedVariantPermissions,
- entryAcl,
- entryWorkflowStageDetails
- );
+ await waitForEntryLockInfo({
+ entryUid: entry_uid,
+ locale,
+ ...(variant ? { variantUid: variant } : {}),
+ });
+ const disabled =
+ isFieldDisabled(
+ fieldSchema,
+ eventDetails,
+ resolvedVariantPermissions,
+ entryAcl,
+ entryWorkflowStageDetails
+ ).isDisabled || Boolean(getEntryEditRestrictionForField(fieldMetadata));
editableElement.setAttribute(
VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY,
diff --git a/src/visualBuilder/utils/types/postMessage.types.ts b/src/visualBuilder/utils/types/postMessage.types.ts
index 2a85459b..eb68adec 100644
--- a/src/visualBuilder/utils/types/postMessage.types.ts
+++ b/src/visualBuilder/utils/types/postMessage.types.ts
@@ -63,6 +63,7 @@ export enum VisualBuilderPostMessageEvents {
PAGE_CONTEXT = "page-context",
REQUEST_DISCUSSION_HIGHLIGHTS = "request-discussion-highlights",
ENTRY_LOCK_INFO_UPDATE = "entry-lock-info-update",
+ ENTRY_EDIT_RESTRICTION_UPDATE = "entry-edit-restriction-update",
}
export interface IPageContextPostMessageEvent {
diff --git a/src/visualBuilder/utils/updateFocussedState.ts b/src/visualBuilder/utils/updateFocussedState.ts
index 295a5683..bb2cd778 100644
--- a/src/visualBuilder/utils/updateFocussedState.ts
+++ b/src/visualBuilder/utils/updateFocussedState.ts
@@ -16,6 +16,7 @@ import { FieldSchemaMap } from "./fieldSchemaMap";
import getChildrenDirection from "./getChildrenDirection";
import { getPsuedoEditableElementStyles } from "./getPsuedoEditableStylesElement";
import { isFieldDisabled } from "./isFieldDisabled";
+import { getEntryEditRestrictionForField } from "./fieldLockIndicator";
import { fetchEntryPermissionsAndStageDetails } from "./fetchEntryPermissionsAndStageDetails";
interface ToolbarPositionParams {
@@ -157,13 +158,14 @@ export async function updateFocussedState({
variantUid: fieldMetadata.variant,
fieldPathWithIndex: fieldMetadata.fieldPathWithIndex,
});
- const { isDisabled } = isFieldDisabled(
- fieldSchema,
- { editableElement, fieldMetadata },
- resolvedVariantPermissions,
- entryAcl,
- entryWorkflowStageDetails
- );
+ const isDisabled =
+ isFieldDisabled(
+ fieldSchema,
+ { editableElement, fieldMetadata },
+ resolvedVariantPermissions,
+ entryAcl,
+ entryWorkflowStageDetails
+ ).isDisabled || Boolean(getEntryEditRestrictionForField(fieldMetadata));
addFocusOverlay(previousSelectedEditableDOM, overlayWrapper, isDisabled);
// update psuedo editable element if present