From 65a722e9e8205fe1ec50b4c44d92c773e346701c Mon Sep 17 00:00:00 2001 From: SahilCs15 Date: Tue, 6 Oct 2026 16:14:45 +0530 Subject: [PATCH 1/4] feat(visual-builder): disable fields of entries the parent has restricted (DRFT-913) Mirror per-entry edit restrictions (older version, unlocalized, unsaved variant) from the parent and show those fields disabled on hover, click, empty-block add and the field label, with a reason-specific message. --- .../components/__test__/emptyBlock.test.tsx | 12 +- .../fieldLabelWrapper.disabledClass.test.tsx | 80 +++++++++ src/visualBuilder/components/emptyBlock.tsx | 8 +- .../components/fieldLabelWrapper.tsx | 27 ++- .../useEntryEditRestrictionUpdateEvent.ts | 32 ++++ src/visualBuilder/index.ts | 2 + src/visualBuilder/listeners/mouseClick.ts | 8 +- src/visualBuilder/listeners/mouseHover.ts | 17 +- .../__test__/entryEditRestriction.test.ts | 166 ++++++++++++++++++ src/visualBuilder/utils/fieldLockIndicator.ts | 51 +++++- src/visualBuilder/utils/fieldLockStore.ts | 56 ++++++ src/visualBuilder/utils/getEntryLockInfo.ts | 9 + src/visualBuilder/utils/isFieldDisabled.ts | 6 + .../utils/types/postMessage.types.ts | 1 + 14 files changed, 446 insertions(+), 29 deletions(-) create mode 100644 src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts create mode 100644 src/visualBuilder/utils/__test__/entryEditRestriction.test.ts 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__/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..b5cc8c9e --- /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 { + EntryEditRestriction, + setEntryEditRestriction, +} from "../utils/fieldLockStore"; + +interface EntryEditRestrictionUpdateEvent { + data: { + entryUid: string; + locale: string; + variantUid?: string; + restriction: EntryEditRestriction | null; + }; +} + +/** + * 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 ? { variantUid } : {}) }, + restriction ?? null + ); + } + ); +} diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts index 37a082eb..18947b39 100644 --- a/src/visualBuilder/index.ts +++ b/src/visualBuilder/index.ts @@ -26,6 +26,7 @@ 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 { useHideFocusOverlayPostMessageEvent } from "./eventManager/useHideFocusOverlayPostMessageEvent"; import { useScrollToField } from "./eventManager/useScrollToField"; import { debounceAddVariantFieldClass, getHighlightVariantFieldsStatus, setHighlightVariantFields, useVariantFieldsPostMessageEvent } from "./eventManager/useVariantsPostMessageEvent"; @@ -416,6 +417,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..7867e3af 100644 --- a/src/visualBuilder/listeners/mouseClick.ts +++ b/src/visualBuilder/listeners/mouseClick.ts @@ -21,7 +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 { isFieldBlockedByAutoDraft } from "../utils/fieldLockIndicator"; import EventListenerHandlerParams from "./types"; import { toggleHighlightedCommentIconDisplay } from "../generators/generateHighlightedComment"; import { VB_EmptyBlockParentClass } from "../.."; @@ -182,13 +182,13 @@ export async function handleBuilderInteraction( const eventDetails = getCsDataOfElement(params.event); - // 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 + // A field locked by another user, or on an entry the parent has restricted (e.g. an older + // version is open), is not editable — block entering edit mode. 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)) { + if (eventDetails && isFieldBlockedByAutoDraft(eventDetails.fieldMetadata)) { return; } diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts index 7eacae8d..1b2967d7 100644 --- a/src/visualBuilder/listeners/mouseHover.ts +++ b/src/visualBuilder/listeners/mouseHover.ts @@ -20,7 +20,11 @@ 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 { + getEntryEditRestrictionForField, + getPeerLockForField, + isFieldBlockedByAutoDraft, +} from "../utils/fieldLockIndicator"; import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore"; import { showLockAvatar, @@ -103,7 +107,9 @@ async function addOutline(params?: AddOutlineParams): Promise { } addHoverOutline( editableElement as HTMLElement, - fieldDisabled || Boolean(peerLock), + fieldDisabled || + Boolean(peerLock) || + Boolean(getEntryEditRestrictionForField(fieldMetadata)), isVariant ); const fieldSchema = await FieldSchemaMap.getFieldSchema( @@ -134,7 +140,10 @@ async function addOutline(params?: AddOutlineParams): Promise { } addHoverOutline( editableElement, - fieldDisabled || isDisabled || Boolean(peerLock), + fieldDisabled || + isDisabled || + Boolean(peerLock) || + Boolean(getEntryEditRestrictionForField(fieldMetadata)), isVariant ); } @@ -503,7 +512,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..e3f035c9 --- /dev/null +++ b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, vi, MockedObject } from "vitest"; +import { EventManager } from "@contentstack/advanced-post-message"; +import { + getEntryEditRestrictionForField, + isFieldBlockedByAutoDraft, +} 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 { DisableReason } from "../isFieldDisabled"; +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( + DisableReason.OlderEntryVersion + ); + }); + + it("returns a distinct message per restriction", () => { + setEntryEditRestriction(scope, "unlocalized"); + expect(getEntryEditRestrictionForField(meta())).toBe( + DisableReason.UnlocalizedEntry + ); + setEntryEditRestriction(scope, "unsavedVariant"); + expect(getEntryEditRestrictionForField(meta())).toBe( + DisableReason.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( + DisableReason.OlderEntryVersion + ); + }); + + 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(); + }); +}); + +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(); + }); +}); diff --git a/src/visualBuilder/utils/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts index b648e88f..27cf783c 100644 --- a/src/visualBuilder/utils/fieldLockIndicator.ts +++ b/src/visualBuilder/utils/fieldLockIndicator.ts @@ -1,6 +1,14 @@ -import { getEntryFieldLockInfo } from "./fieldLockStore"; +import { + getEntryEditRestriction, + getEntryFieldLockInfo, +} from "./fieldLockStore"; import { getEntryLockInfo } from "./getEntryLockInfo"; -import type { EntryFieldLock, EntryLockScope } from "./fieldLockStore"; +import { DisableReason } from "./isFieldDisabled"; +import type { + EntryEditRestriction, + EntryFieldLock, + EntryLockScope, +} from "./fieldLockStore"; import type { CslpData } from "../../cslp/types/cslp.types"; type FieldMetadataForLock = Pick< @@ -56,6 +64,45 @@ export function getPeerLockForField( return null; } +// Read at call time: isFieldDisabled imports the VisualBuilder module, which loads this one. +const restrictionMessage = (restriction: EntryEditRestriction): string => + ({ + olderVersion: DisableReason.OlderEntryVersion, + unlocalized: DisableReason.UnlocalizedEntry, + unsavedVariant: DisableReason.UnsavedVariant, + })[restriction]; + +/** + * 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 ? restrictionMessage(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; diff --git a/src/visualBuilder/utils/fieldLockStore.ts b/src/visualBuilder/utils/fieldLockStore.ts index b2b55ee0..11289275 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,52 @@ 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; +} + +export function setEntryEditRestriction( + scope: EntryLockScopeParts, + restriction: EntryEditRestriction | null +): void { + const key = entryLockScopeKey(scope); + if (restriction) { + restrictions.set(key, restriction); + } else { + restrictions.delete(key); + } + restrictionWrites.set(key, ++writeSeq); notifyLockListeners(); } + +/** + * Seeds restrictions from a snapshot keyed `uid:locale[:variant]` (the parent's entry key), + * skipping any scope updated after `seqBeforeRequest`. + */ +export function seedEntryEditRestrictions( + snapshot: Record, + seqBeforeRequest: number +): void { + let changed = false; + for (const [parentKey, restriction] of Object.entries(snapshot)) { + const [entryUid, locale, variantUid] = parentKey.split(":"); + const key = entryLockScopeKey({ entryUid, locale, variantUid }); + if ((restrictionWrites.get(key) ?? 0) > seqBeforeRequest) continue; + restrictions.set(key, restriction); + restrictionWrites.set(key, ++writeSeq); + changed = true; + } + 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..bfd7e8e1 100644 --- a/src/visualBuilder/utils/getEntryLockInfo.ts +++ b/src/visualBuilder/utils/getEntryLockInfo.ts @@ -1,9 +1,12 @@ import { VisualBuilderPostMessageEvents } from "./types/postMessage.types"; import visualBuilderPostMessage from "./visualBuilderPostMessage"; import { + EntryEditRestriction, EntryFieldLockInfo, EntryLockScope, + getEntryEditRestrictionWriteSeq, getEntryFieldLockVersion, + seedEntryEditRestrictions, setEntryFieldLockInfo, } from "./fieldLockStore"; @@ -22,9 +25,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 +41,10 @@ export async function getEntryLockInfo( } const fieldLockInfo = response.fieldLockInfo; + seedEntryEditRestrictions( + 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/isFieldDisabled.ts b/src/visualBuilder/utils/isFieldDisabled.ts index dbdf2c52..3631927c 100644 --- a/src/visualBuilder/utils/isFieldDisabled.ts +++ b/src/visualBuilder/utils/isFieldDisabled.ts @@ -20,6 +20,12 @@ export const DisableReason = { DisabledVariant: "This field is not editable as it doesn't match the selected variant", UnlocalizedVariant: "This field is not editable as it is not localized", + OlderEntryVersion: + "You're viewing an older version of this entry. Switch to the latest version to edit.", + UnlocalizedEntry: + "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.", None: "", EntryUpdateRestricted: "You do not have permission to edit this entry", WorkflowStagePermission: ({ stageName }: { stageName: string }) => 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 { From 354956d17d4734d24816c96ff15d92845fa0b3be Mon Sep 17 00:00:00 2001 From: SahilCs15 Date: Tue, 6 Oct 2026 17:04:29 +0530 Subject: [PATCH 2/4] fix(visual-builder): let restricted-entry clicks open the form; keep the snapshot authoritative (DRFT-913) Entry restrictions no longer swallow the click, so the form can still open the entry (the way back to its latest version or to localize it); the field shows disabled and inline editing and add buttons stay off. The lock-info snapshot now also clears scopes it no longer lists, unknown restriction values are ignored, listeners fire only on real changes, and the restriction messages live with the indicator to avoid an import cycle. --- .../useEntryEditRestrictionUpdateEvent.ts | 8 +-- src/visualBuilder/listeners/mouseClick.ts | 16 +++-- src/visualBuilder/listeners/mouseHover.ts | 10 +-- .../__test__/entryEditRestriction.test.ts | 52 +++++++++++++-- .../__test__/handleIndividualFields.test.ts | 48 ++++++++++++++ src/visualBuilder/utils/fieldLockIndicator.ts | 18 +++--- src/visualBuilder/utils/fieldLockStore.ts | 63 ++++++++++++++----- src/visualBuilder/utils/getEntryLockInfo.ts | 15 +++-- .../utils/handleIndividualFields.ts | 16 ++--- src/visualBuilder/utils/isFieldDisabled.ts | 6 -- 10 files changed, 186 insertions(+), 66 deletions(-) diff --git a/src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts b/src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts index b5cc8c9e..e2c548ed 100644 --- a/src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts +++ b/src/visualBuilder/eventManager/useEntryEditRestrictionUpdateEvent.ts @@ -1,8 +1,8 @@ import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; import { - EntryEditRestriction, setEntryEditRestriction, + toEntryEditRestriction, } from "../utils/fieldLockStore"; interface EntryEditRestrictionUpdateEvent { @@ -10,7 +10,7 @@ interface EntryEditRestrictionUpdateEvent { entryUid: string; locale: string; variantUid?: string; - restriction: EntryEditRestriction | null; + restriction: unknown; }; } @@ -24,8 +24,8 @@ export function useEntryEditRestrictionUpdateEvent(): void { (event: EntryEditRestrictionUpdateEvent) => { const { entryUid, locale, variantUid, restriction } = event.data; setEntryEditRestriction( - { entryUid, locale, ...(variantUid ? { variantUid } : {}) }, - restriction ?? null + { entryUid, locale, variantUid }, + toEntryEditRestriction(restriction) ); } ); diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts index 7867e3af..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 { isFieldBlockedByAutoDraft } from "../utils/fieldLockIndicator"; +import { + getEntryEditRestrictionForField, + getPeerLockForField, +} from "../utils/fieldLockIndicator"; import EventListenerHandlerParams from "./types"; import { toggleHighlightedCommentIconDisplay } from "../generators/generateHighlightedComment"; import { VB_EmptyBlockParentClass } from "../.."; @@ -182,13 +185,14 @@ export async function handleBuilderInteraction( const eventDetails = getCsDataOfElement(params.event); - // A field locked by another user, or on an entry the parent has restricted (e.g. an older - // version is open), is not editable — block entering edit mode. This + // 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 && isFieldBlockedByAutoDraft(eventDetails.fieldMetadata)) { + // 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 1b2967d7..3a9e8eca 100644 --- a/src/visualBuilder/listeners/mouseHover.ts +++ b/src/visualBuilder/listeners/mouseHover.ts @@ -21,7 +21,6 @@ import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermiss import { isCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; import { getParentCslp, getWholeFieldElement } from "../utils/getWholeFieldElement"; import { - getEntryEditRestrictionForField, getPeerLockForField, isFieldBlockedByAutoDraft, } from "../utils/fieldLockIndicator"; @@ -107,9 +106,7 @@ async function addOutline(params?: AddOutlineParams): Promise { } addHoverOutline( editableElement as HTMLElement, - fieldDisabled || - Boolean(peerLock) || - Boolean(getEntryEditRestrictionForField(fieldMetadata)), + fieldDisabled || isFieldBlockedByAutoDraft(fieldMetadata), isVariant ); const fieldSchema = await FieldSchemaMap.getFieldSchema( @@ -140,10 +137,7 @@ async function addOutline(params?: AddOutlineParams): Promise { } addHoverOutline( editableElement, - fieldDisabled || - isDisabled || - Boolean(peerLock) || - Boolean(getEntryEditRestrictionForField(fieldMetadata)), + fieldDisabled || isDisabled || isFieldBlockedByAutoDraft(fieldMetadata), isVariant ); } diff --git a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts index e3f035c9..17fae8bf 100644 --- a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts +++ b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi, MockedObject } from "vitest"; import { EventManager } from "@contentstack/advanced-post-message"; import { + ENTRY_RESTRICTION_MESSAGES, getEntryEditRestrictionForField, isFieldBlockedByAutoDraft, } from "../fieldLockIndicator"; @@ -14,7 +15,6 @@ import { import { getEntryLockInfo } from "../getEntryLockInfo"; import { useEntryEditRestrictionUpdateEvent } from "../../eventManager/useEntryEditRestrictionUpdateEvent"; import { VisualBuilderPostMessageEvents } from "../types/postMessage.types"; -import { DisableReason } from "../isFieldDisabled"; import visualBuilderPostMessage from "../visualBuilderPostMessage"; vi.mock("../visualBuilderPostMessage", () => ({ @@ -44,18 +44,18 @@ describe("getEntryEditRestrictionForField", () => { it("returns the older-version message for a restricted entry", () => { setEntryEditRestriction(scope, "olderVersion"); expect(getEntryEditRestrictionForField(meta())).toBe( - DisableReason.OlderEntryVersion + ENTRY_RESTRICTION_MESSAGES.olderVersion ); }); it("returns a distinct message per restriction", () => { setEntryEditRestriction(scope, "unlocalized"); expect(getEntryEditRestrictionForField(meta())).toBe( - DisableReason.UnlocalizedEntry + ENTRY_RESTRICTION_MESSAGES.unlocalized ); setEntryEditRestriction(scope, "unsavedVariant"); expect(getEntryEditRestrictionForField(meta())).toBe( - DisableReason.UnsavedVariant + ENTRY_RESTRICTION_MESSAGES.unsavedVariant ); }); @@ -68,7 +68,7 @@ describe("getEntryEditRestrictionForField", () => { it("applies a base-entry restriction to that entry's variant fields", () => { setEntryEditRestriction(scope, "olderVersion"); expect(getEntryEditRestrictionForField(meta({ variant: "v1" }))).toBe( - DisableReason.OlderEntryVersion + ENTRY_RESTRICTION_MESSAGES.olderVersion ); }); @@ -123,6 +123,23 @@ describe("restriction updates", () => { 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", () => { @@ -163,4 +180,29 @@ describe("snapshot seeding", () => { 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"); + }); }); 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/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts index 27cf783c..ae1566ad 100644 --- a/src/visualBuilder/utils/fieldLockIndicator.ts +++ b/src/visualBuilder/utils/fieldLockIndicator.ts @@ -3,7 +3,6 @@ import { getEntryFieldLockInfo, } from "./fieldLockStore"; import { getEntryLockInfo } from "./getEntryLockInfo"; -import { DisableReason } from "./isFieldDisabled"; import type { EntryEditRestriction, EntryFieldLock, @@ -64,13 +63,14 @@ export function getPeerLockForField( return null; } -// Read at call time: isFieldDisabled imports the VisualBuilder module, which loads this one. -const restrictionMessage = (restriction: EntryEditRestriction): string => - ({ - olderVersion: DisableReason.OlderEntryVersion, - unlocalized: DisableReason.UnlocalizedEntry, - unsavedVariant: DisableReason.UnsavedVariant, - })[restriction]; +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 @@ -90,7 +90,7 @@ export function getEntryEditRestrictionForField( variantUid: fieldMetadata.variant, }) : null) ?? getEntryEditRestriction(scope); - return restriction ? restrictionMessage(restriction) : null; + return restriction ? ENTRY_RESTRICTION_MESSAGES[restriction] : null; } /** True when auto-draft blocks editing this field: a peer lock or an entry restriction. */ diff --git a/src/visualBuilder/utils/fieldLockStore.ts b/src/visualBuilder/utils/fieldLockStore.ts index 11289275..b5b97db4 100644 --- a/src/visualBuilder/utils/fieldLockStore.ts +++ b/src/visualBuilder/utils/fieldLockStore.ts @@ -120,36 +120,69 @@ export function getEntryEditRestrictionWriteSeq(): number { return writeSeq; } -export function setEntryEditRestriction( - scope: EntryLockScopeParts, +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 -): void { - const key = entryLockScopeKey(scope); +): boolean { + restrictionWrites.set(key, ++writeSeq); + if ((restrictions.get(key) ?? null) === restriction) return false; if (restriction) { restrictions.set(key, restriction); } else { restrictions.delete(key); } - restrictionWrites.set(key, ++writeSeq); - notifyLockListeners(); + return true; +} + +export function setEntryEditRestriction( + scope: EntryLockScopeParts, + restriction: EntryEditRestriction | null +): void { + if (writeRestriction(entryLockScopeKey(scope), restriction)) { + notifyLockListeners(); + } } /** - * Seeds restrictions from a snapshot keyed `uid:locale[:variant]` (the parent's entry key), - * skipping any scope updated after `seqBeforeRequest`. + * 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( - snapshot: Record, + 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 [parentKey, restriction] of Object.entries(snapshot)) { - const [entryUid, locale, variantUid] = parentKey.split(":"); - const key = entryLockScopeKey({ entryUid, locale, variantUid }); + for (const [key, restriction] of next) { if ((restrictionWrites.get(key) ?? 0) > seqBeforeRequest) continue; - restrictions.set(key, restriction); - restrictionWrites.set(key, ++writeSeq); - changed = true; + changed = writeRestriction(key, restriction) || changed; } if (changed) notifyLockListeners(); } diff --git a/src/visualBuilder/utils/getEntryLockInfo.ts b/src/visualBuilder/utils/getEntryLockInfo.ts index bfd7e8e1..7e475428 100644 --- a/src/visualBuilder/utils/getEntryLockInfo.ts +++ b/src/visualBuilder/utils/getEntryLockInfo.ts @@ -1,7 +1,6 @@ import { VisualBuilderPostMessageEvents } from "./types/postMessage.types"; import visualBuilderPostMessage from "./visualBuilderPostMessage"; import { - EntryEditRestriction, EntryFieldLockInfo, EntryLockScope, getEntryEditRestrictionWriteSeq, @@ -29,7 +28,7 @@ export async function getEntryLockInfo( try { const response = await visualBuilderPostMessage?.send<{ fieldLockInfo?: EntryFieldLockInfo; - editRestrictions?: Record; + editRestrictions?: Record; error?: boolean; }>(VisualBuilderPostMessageEvents.GET_ENTRY_LOCK_INFO, { ...scope }); @@ -41,10 +40,14 @@ export async function getEntryLockInfo( } const fieldLockInfo = response.fieldLockInfo; - seedEntryEditRestrictions( - response.editRestrictions ?? {}, - restrictionSeqBeforeRequest - ); + // 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..437cf81f 100644 --- a/src/visualBuilder/utils/handleIndividualFields.ts +++ b/src/visualBuilder/utils/handleIndividualFields.ts @@ -6,6 +6,7 @@ import { getFieldData } from "./getFieldData"; import { getFieldType } from "./getFieldType"; import { handleFieldInput, handleFieldKeyDown } from "./handleFieldMouseDown"; import { isFieldDisabled } from "./isFieldDisabled"; +import { getEntryEditRestrictionForField } from "./fieldLockIndicator"; import { handleAddButtonsForMultiple, removeAddInstanceButtons, @@ -57,13 +58,14 @@ export async function handleIndividualFields( variantUid: variant, fieldPathWithIndex, }); - const { isDisabled: disabled } = isFieldDisabled( - fieldSchema, - eventDetails, - resolvedVariantPermissions, - entryAcl, - entryWorkflowStageDetails - ); + 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/isFieldDisabled.ts b/src/visualBuilder/utils/isFieldDisabled.ts index 3631927c..dbdf2c52 100644 --- a/src/visualBuilder/utils/isFieldDisabled.ts +++ b/src/visualBuilder/utils/isFieldDisabled.ts @@ -20,12 +20,6 @@ export const DisableReason = { DisabledVariant: "This field is not editable as it doesn't match the selected variant", UnlocalizedVariant: "This field is not editable as it is not localized", - OlderEntryVersion: - "You're viewing an older version of this entry. Switch to the latest version to edit.", - UnlocalizedEntry: - "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.", None: "", EntryUpdateRestricted: "You do not have permission to edit this entry", WorkflowStagePermission: ({ stageName }: { stageName: string }) => From 6fc7e27b1c2fc5a2401eb8c5d87324c4cca0538e Mon Sep 17 00:00:00 2001 From: SahilCs15 Date: Tue, 6 Oct 2026 18:13:24 +0530 Subject: [PATCH 3/4] fix(visual-builder): wait for the first lock snapshot before inline editing (DRFT-913) On a fresh canvas the first click could make a restricted field editable before the entry's restriction arrived; inline editing now waits (bounded) for that scope's in-flight snapshot. --- .../__test__/entryEditRestriction.test.ts | 36 +++++++++++++++ src/visualBuilder/utils/fieldLockIndicator.ts | 44 ++++++++++++++++--- .../utils/handleIndividualFields.ts | 10 ++++- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts index 17fae8bf..45426191 100644 --- a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts +++ b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts @@ -4,6 +4,8 @@ import { ENTRY_RESTRICTION_MESSAGES, getEntryEditRestrictionForField, isFieldBlockedByAutoDraft, + requestEntryLockInfoOnce, + waitForEntryLockInfo, } from "../fieldLockIndicator"; import { clearAllEntryFieldLockInfo, @@ -205,4 +207,38 @@ describe("snapshot seeding", () => { 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/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts index ae1566ad..bb435051 100644 --- a/src/visualBuilder/utils/fieldLockIndicator.ts +++ b/src/visualBuilder/utils/fieldLockIndicator.ts @@ -126,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, @@ -137,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/handleIndividualFields.ts b/src/visualBuilder/utils/handleIndividualFields.ts index 437cf81f..3eba70c2 100644 --- a/src/visualBuilder/utils/handleIndividualFields.ts +++ b/src/visualBuilder/utils/handleIndividualFields.ts @@ -6,7 +6,10 @@ import { getFieldData } from "./getFieldData"; import { getFieldType } from "./getFieldType"; import { handleFieldInput, handleFieldKeyDown } from "./handleFieldMouseDown"; import { isFieldDisabled } from "./isFieldDisabled"; -import { getEntryEditRestrictionForField } from "./fieldLockIndicator"; +import { + getEntryEditRestrictionForField, + waitForEntryLockInfo, +} from "./fieldLockIndicator"; import { handleAddButtonsForMultiple, removeAddInstanceButtons, @@ -58,6 +61,11 @@ export async function handleIndividualFields( variantUid: variant, fieldPathWithIndex, }); + await waitForEntryLockInfo({ + entryUid: entry_uid, + locale, + ...(variant ? { variantUid: variant } : {}), + }); const disabled = isFieldDisabled( fieldSchema, From 7c5d60cbb2b73ffd0b2c0cd75dc4f034e9c66e22 Mon Sep 17 00:00:00 2001 From: SahilCs15 Date: Tue, 6 Oct 2026 19:33:16 +0530 Subject: [PATCH 4/4] fix(visual-builder): disable toolbar actions and focus overlay on restricted entries (DRFT-913) The toolbar's delete, move, edit and app buttons and the focus overlay redraws only read isFieldDisabled, so a restricted entry's field showed disabled while its actions stayed live. --- src/visualBuilder/components/FieldToolbar.tsx | 16 +++++- .../components/__test__/fieldToolbar.test.tsx | 52 +++++++++++++++++++ src/visualBuilder/index.ts | 11 ++-- .../__test__/updateFocussedState.test.ts | 35 +++++++++++++ .../utils/updateFocussedState.ts | 16 +++--- 5 files changed, 117 insertions(+), 13 deletions(-) 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__/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/index.ts b/src/visualBuilder/index.ts index 18947b39..49378844 100644 --- a/src/visualBuilder/index.ts +++ b/src/visualBuilder/index.ts @@ -27,6 +27,7 @@ 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"; @@ -196,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, 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/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