diff --git a/src/visualBuilder/components/__test__/emptyBlock.test.tsx b/src/visualBuilder/components/__test__/emptyBlock.test.tsx
index d7d5b2c8..fb53a33d 100644
--- a/src/visualBuilder/components/__test__/emptyBlock.test.tsx
+++ b/src/visualBuilder/components/__test__/emptyBlock.test.tsx
@@ -6,6 +6,8 @@ import { observeParentAndFocusNewInstance } from "../../utils/multipleElementAdd
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";
vi.mock("../../utils/visualBuilderPostMessage", () => ({
default: {
@@ -17,20 +19,32 @@ vi.mock("../../utils/multipleElementAddButton", () => ({
observeParentAndFocusNewInstance: vi.fn(),
}));
+vi.mock("../../utils/fieldLockIndicator", () => ({
+ getPeerLockForField: vi.fn(() => null),
+}));
+
describe("EmptyBlock", () => {
const mockDetails = {
fieldMetadata: {
- cslpValue: "parent.cslp.value",
+ cslpValue: "ct.entry.en-us.blocks_field",
} as CslpData,
fieldSchema: {
display_name: "Test Block",
} as ISchemaFieldMap,
};
- afterEach(() => {
+ let host: HTMLElement | null = null;
+
+ beforeEach(() => {
vi.clearAllMocks();
});
+ afterEach(() => {
+ // RTL only removes containers it created, so this one would outlive the test
+ host?.remove();
+ host = null;
+ });
+
test("should render correctly", () => {
const { getByText, getByTestId } = render(
@@ -70,4 +84,61 @@ describe("EmptyBlock", () => {
index: 0,
});
});
+
+ test("claims the field lock before adding, so a peer sees it", async () => {
+ host = document.createElement("div");
+ host.setAttribute("data-cslp", mockDetails.fieldMetadata.cslpValue);
+ document.body.appendChild(host);
+
+ const { getByTestId } = render(, {
+ container: host as HTMLElement,
+ });
+ fireEvent.click(getByTestId("visual-builder__empty-block-add-button"));
+
+ await waitFor(() => {
+ expect((visualBuilderPostMessage as any).send).toHaveBeenCalledWith(
+ VisualBuilderPostMessageEvents.FOCUS_FIELD,
+ { DOMEditStack: getDOMEditStack(host as HTMLElement) }
+ );
+ });
+
+ // the lock must be claimed first, or the parent applies the add with no lock
+ const events = (visualBuilderPostMessage as any).send.mock.calls.map(
+ (call: unknown[]) => call[0]
+ );
+ expect(events).toEqual([
+ VisualBuilderPostMessageEvents.FOCUS_FIELD,
+ VisualBuilderPostMessageEvents.ADD_INSTANCE,
+ ]);
+ });
+
+ test("does not send an empty edit stack, which the parent reads as a deselect", async () => {
+ // no ancestor carries data-cslp, so the stack comes back empty
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId("visual-builder__empty-block-add-button"));
+
+ await waitFor(() => {
+ expect((visualBuilderPostMessage as any).send).toHaveBeenCalledWith(
+ VisualBuilderPostMessageEvents.ADD_INSTANCE,
+ { fieldMetadata: mockDetails.fieldMetadata, index: 0 }
+ );
+ });
+ expect((visualBuilderPostMessage as any).send).not.toHaveBeenCalledWith(
+ VisualBuilderPostMessageEvents.FOCUS_FIELD,
+ expect.anything()
+ );
+ });
+
+ test("adds nothing when a peer holds the field", async () => {
+ (getPeerLockForField as any).mockReturnValueOnce({
+ user: { uid: "peer" },
+ });
+
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId("visual-builder__empty-block-add-button"));
+
+ await waitFor(() => expect(getPeerLockForField).toHaveBeenCalled());
+ expect((visualBuilderPostMessage as any).send).not.toHaveBeenCalled();
+ expect(observeParentAndFocusNewInstance).not.toHaveBeenCalled();
+ });
});
diff --git a/src/visualBuilder/components/emptyBlock.tsx b/src/visualBuilder/components/emptyBlock.tsx
index ad932e70..4ca4a818 100644
--- a/src/visualBuilder/components/emptyBlock.tsx
+++ b/src/visualBuilder/components/emptyBlock.tsx
@@ -7,6 +7,9 @@ import { ISchemaFieldMap } from "../utils/types/index.types";
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 { DATA_CSLP_ATTR_SELECTOR } from "../utils/constants";
interface EmptyBlockProps {
details: {
@@ -20,14 +23,44 @@ export function EmptyBlock(props: EmptyBlockProps): JSX.Element {
const blockParentName = details.fieldSchema.display_name;
- async function sendAddInstanceEvent() {
- await visualBuilderPostMessage?.send(
- VisualBuilderPostMessageEvents.ADD_INSTANCE,
- {
- fieldMetadata: details.fieldMetadata,
- index: 0,
- }
- );
+ 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;
+
+ // 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.
+ const fieldElement =
+ document.querySelector(
+ `[${DATA_CSLP_ATTR_SELECTOR}="${details.fieldMetadata.cslpValue}"]`
+ ) ?? event.currentTarget;
+
+ // The empty-state add never selects the field, so nothing else claims the
+ // lock. Fire and forget: the parent does not await the claim either.
+ const DOMEditStack = getDOMEditStack(fieldElement);
+ // An empty stack reads as a deselect on the parent and would RELEASE the lock.
+ if (DOMEditStack.length) {
+ visualBuilderPostMessage?.send(
+ VisualBuilderPostMessageEvents.FOCUS_FIELD,
+ { DOMEditStack }
+ );
+ }
+
+ try {
+ await visualBuilderPostMessage?.send(
+ VisualBuilderPostMessageEvents.ADD_INSTANCE,
+ {
+ fieldMetadata: details.fieldMetadata,
+ index: 0,
+ }
+ );
+ } catch (error) {
+ console.error("Visual Builder: Failed to add instance", error);
+ return;
+ }
+
observeParentAndFocusNewInstance({
parentCslp: details.fieldMetadata.cslpValue,
index: 0,
@@ -67,7 +100,7 @@ export function EmptyBlock(props: EmptyBlockProps): JSX.Element {
"visual-builder__empty-block-add-button"
]
)}
- onClick={() => sendAddInstanceEvent()}
+ onClick={sendAddInstanceEvent}
type="button"
data-testid="visual-builder__empty-block-add-button"
>