From a52201eb7c0addd950e671ce52170cddc2e5d3d1 Mon Sep 17 00:00:00 2001 From: SahilCs15 Date: Wed, 7 Oct 2026 14:44:09 +0530 Subject: [PATCH 1/2] feat(visual-builder): show fields disabled when another session changes or deletes their content type (DRFT-927) Adds the contentTypeUpdated and contentTypeDeleted restrictions with their own messages. --- .../__test__/entryEditRestriction.test.ts | 56 +++++++++++++++++++ src/visualBuilder/utils/fieldLockIndicator.ts | 4 ++ src/visualBuilder/utils/fieldLockStore.ts | 4 ++ 3 files changed, 64 insertions(+) diff --git a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts index 6c220f34..87348e00 100644 --- a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts +++ b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts @@ -59,6 +59,14 @@ describe("getEntryEditRestrictionForField", () => { expect(getEntryEditRestrictionForField(meta())).toBe( ENTRY_RESTRICTION_MESSAGES.entryLocked ); + setEntryEditRestriction(scope, "contentTypeUpdated"); + expect(getEntryEditRestrictionForField(meta())).toBe( + ENTRY_RESTRICTION_MESSAGES.contentTypeUpdated + ); + setEntryEditRestriction(scope, "contentTypeDeleted"); + expect(getEntryEditRestrictionForField(meta())).toBe( + ENTRY_RESTRICTION_MESSAGES.contentTypeDeleted + ); setEntryEditRestriction(scope, "unsavedVariant"); expect(getEntryEditRestrictionForField(meta())).toBe( ENTRY_RESTRICTION_MESSAGES.unsavedVariant @@ -130,6 +138,16 @@ describe("restriction updates", () => { expect(getEntryEditRestriction(scope)).toBeNull(); }); + it("accepts content type changes from the parent", () => { + useEntryEditRestrictionUpdateEvent(); + const handler = mockPostMessage.on.mock.calls[0][1] as any; + + handler({ data: { ...scope, restriction: "contentTypeDeleted" } }); + expect(getEntryEditRestriction(scope)).toBe("contentTypeDeleted"); + handler({ data: { ...scope, restriction: "contentTypeUpdated" } }); + expect(getEntryEditRestriction(scope)).toBe("contentTypeUpdated"); + }); + it("ignores a restriction value it does not know", () => { useEntryEditRestrictionUpdateEvent(); const handler = mockPostMessage.on.mock.calls[0][1] as any; @@ -246,3 +264,41 @@ describe("snapshot seeding", () => { ).resolves.toBeUndefined(); }); }); + +describe("content type restrictions from the editor", () => { + it.each([ + "olderVersion", + "entryLocked", + "contentTypeUpdated", + "contentTypeDeleted", + "unlocalized", + "unsavedVariant", + ] as const)("accepts %s and has a field message for it", (restriction) => { + useEntryEditRestrictionUpdateEvent(); + const handler = mockPostMessage.on.mock.calls[0][1] as any; + + handler({ data: { ...scope, restriction } }); + + expect(getEntryEditRestriction(scope)).toBe(restriction); + expect(getEntryEditRestrictionForField(meta())).toBe( + ENTRY_RESTRICTION_MESSAGES[restriction] + ); + expect(ENTRY_RESTRICTION_MESSAGES[restriction]).toEqual( + expect.any(String) + ); + }); + + it("seeds a content type reason from the lock snapshot and shows it on variant fields", async () => { + mockPostMessage.send.mockResolvedValueOnce({ + fieldLockInfo: {}, + editRestrictions: { "entry1:en-us": "contentTypeDeleted" }, + }); + + await getEntryLockInfo({ ...scope, contentTypeUid: "shoe" }); + + expect(getEntryEditRestrictionForField(meta({ variant: "v1" }))).toBe( + ENTRY_RESTRICTION_MESSAGES.contentTypeDeleted + ); + expect(isFieldBlockedByAutoDraft(meta({ variant: "v1" }))).toBe(true); + }); +}); diff --git a/src/visualBuilder/utils/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts index 7f7aec22..5dec8b92 100644 --- a/src/visualBuilder/utils/fieldLockIndicator.ts +++ b/src/visualBuilder/utils/fieldLockIndicator.ts @@ -68,6 +68,10 @@ export const ENTRY_RESTRICTION_MESSAGES: Record = "You're viewing an older version of this entry. Switch to the latest version to edit.", entryLocked: "This entry was updated by someone else. Reload the entry to keep editing.", + contentTypeUpdated: + "This entry's content type was changed. Reload the entry to keep editing.", + contentTypeDeleted: + "This entry's content type was deleted. It can no longer be edited here.", unlocalized: "This entry isn't localized in this language yet. Save it from the form to localize it, then edit here.", unsavedVariant: diff --git a/src/visualBuilder/utils/fieldLockStore.ts b/src/visualBuilder/utils/fieldLockStore.ts index 9a011504..ab6e768c 100644 --- a/src/visualBuilder/utils/fieldLockStore.ts +++ b/src/visualBuilder/utils/fieldLockStore.ts @@ -31,6 +31,8 @@ export type EntryFieldLockInfo = Record; export type EntryEditRestriction = | "olderVersion" | "entryLocked" + | "contentTypeUpdated" + | "contentTypeDeleted" | "unlocalized" | "unsavedVariant"; @@ -124,6 +126,8 @@ export function getEntryEditRestrictionWriteSeq(): number { const RESTRICTIONS: ReadonlySet = new Set([ "olderVersion", "entryLocked", + "contentTypeUpdated", + "contentTypeDeleted", "unlocalized", "unsavedVariant", ]); From d7a2295f32c0803e582e4cf75249e55f76c0af87 Mon Sep 17 00:00:00 2001 From: SahilCs15 Date: Wed, 7 Oct 2026 17:51:29 +0530 Subject: [PATCH 2/2] refactor(visual-builder): derive restriction allowlist from one list (DRFT-927) --- .../__test__/entryEditRestriction.test.ts | 47 +++++++------------ src/visualBuilder/utils/fieldLockStore.ts | 27 +++++------ 2 files changed, 30 insertions(+), 44 deletions(-) diff --git a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts index 87348e00..e23de6c5 100644 --- a/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts +++ b/src/visualBuilder/utils/__test__/entryEditRestriction.test.ts @@ -8,6 +8,7 @@ import { waitForEntryLockInfo, } from "../fieldLockIndicator"; import { + ENTRY_EDIT_RESTRICTIONS, clearAllEntryFieldLockInfo, getEntryEditRestriction, setEntryEditRestriction, @@ -59,14 +60,6 @@ describe("getEntryEditRestrictionForField", () => { expect(getEntryEditRestrictionForField(meta())).toBe( ENTRY_RESTRICTION_MESSAGES.entryLocked ); - setEntryEditRestriction(scope, "contentTypeUpdated"); - expect(getEntryEditRestrictionForField(meta())).toBe( - ENTRY_RESTRICTION_MESSAGES.contentTypeUpdated - ); - setEntryEditRestriction(scope, "contentTypeDeleted"); - expect(getEntryEditRestrictionForField(meta())).toBe( - ENTRY_RESTRICTION_MESSAGES.contentTypeDeleted - ); setEntryEditRestriction(scope, "unsavedVariant"); expect(getEntryEditRestrictionForField(meta())).toBe( ENTRY_RESTRICTION_MESSAGES.unsavedVariant @@ -266,27 +259,23 @@ describe("snapshot seeding", () => { }); describe("content type restrictions from the editor", () => { - it.each([ - "olderVersion", - "entryLocked", - "contentTypeUpdated", - "contentTypeDeleted", - "unlocalized", - "unsavedVariant", - ] as const)("accepts %s and has a field message for it", (restriction) => { - useEntryEditRestrictionUpdateEvent(); - const handler = mockPostMessage.on.mock.calls[0][1] as any; - - handler({ data: { ...scope, restriction } }); - - expect(getEntryEditRestriction(scope)).toBe(restriction); - expect(getEntryEditRestrictionForField(meta())).toBe( - ENTRY_RESTRICTION_MESSAGES[restriction] - ); - expect(ENTRY_RESTRICTION_MESSAGES[restriction]).toEqual( - expect.any(String) - ); - }); + it.each(ENTRY_EDIT_RESTRICTIONS)( + "accepts %s and has a field message for it", + (restriction) => { + useEntryEditRestrictionUpdateEvent(); + const handler = mockPostMessage.on.mock.calls[0][1] as any; + + handler({ data: { ...scope, restriction } }); + + expect(getEntryEditRestriction(scope)).toBe(restriction); + expect(getEntryEditRestrictionForField(meta())).toBe( + ENTRY_RESTRICTION_MESSAGES[restriction] + ); + expect(ENTRY_RESTRICTION_MESSAGES[restriction]).toEqual( + expect.any(String) + ); + } + ); it("seeds a content type reason from the lock snapshot and shows it on variant fields", async () => { mockPostMessage.send.mockResolvedValueOnce({ diff --git a/src/visualBuilder/utils/fieldLockStore.ts b/src/visualBuilder/utils/fieldLockStore.ts index ab6e768c..b0d5390a 100644 --- a/src/visualBuilder/utils/fieldLockStore.ts +++ b/src/visualBuilder/utils/fieldLockStore.ts @@ -27,14 +27,18 @@ export interface EntryFieldLock { export type EntryFieldLockInfo = Record; +/** Every reason the parent can turn editing off for a whole entry scope. */ +export const ENTRY_EDIT_RESTRICTIONS = [ + "olderVersion", + "entryLocked", + "contentTypeUpdated", + "contentTypeDeleted", + "unlocalized", + "unsavedVariant", +] as const; + /** Why the parent has turned editing off for a whole entry scope. */ -export type EntryEditRestriction = - | "olderVersion" - | "entryLocked" - | "contentTypeUpdated" - | "contentTypeDeleted" - | "unlocalized" - | "unsavedVariant"; +export type EntryEditRestriction = (typeof ENTRY_EDIT_RESTRICTIONS)[number]; /** The parts that identify a lock scope: entry + locale + variant. */ export interface EntryLockScopeParts { @@ -123,14 +127,7 @@ export function getEntryEditRestrictionWriteSeq(): number { return writeSeq; } -const RESTRICTIONS: ReadonlySet = new Set([ - "olderVersion", - "entryLocked", - "contentTypeUpdated", - "contentTypeDeleted", - "unlocalized", - "unsavedVariant", -]); +const RESTRICTIONS: ReadonlySet = new Set(ENTRY_EDIT_RESTRICTIONS); /** Narrows a value from the parent; anything unknown is treated as "no restriction". */ export function toEntryEditRestriction(