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" >