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