Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions src/visualBuilder/components/__test__/emptyBlock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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(
<EmptyBlock details={mockDetails} />
Expand Down Expand Up @@ -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);
Comment thread
SahilCs15 marked this conversation as resolved.

const { getByTestId } = render(<EmptyBlock details={mockDetails} />, {
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(<EmptyBlock details={mockDetails} />);
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(<EmptyBlock details={mockDetails} />);
fireEvent.click(getByTestId("visual-builder__empty-block-add-button"));

await waitFor(() => expect(getPeerLockForField).toHaveBeenCalled());
expect((visualBuilderPostMessage as any).send).not.toHaveBeenCalled();
Comment thread
SahilCs15 marked this conversation as resolved.
expect(observeParentAndFocusNewInstance).not.toHaveBeenCalled();
});
});
51 changes: 42 additions & 9 deletions src/visualBuilder/components/emptyBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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<HTMLButtonElement>
) {
// 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({
Comment on lines +59 to 64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch swallows failure but control falls through to observeParentAndFocusNewInstance. Before this change a rejected ADD_INSTANCE propagated and skipped the observe; now a failed add still starts an observer waiting for an instance that will never appear (and the mutation observer / focus attempt lingers).

Either return from the catch, or move the observe into the try after the await.

Suggested change
} catch (error) {
console.error("Visual Builder: Failed to add instance", error);
}
observeParentAndFocusNewInstance({
} catch (error) {
console.error("Visual Builder: Failed to add instance", error);
return;
}
observeParentAndFocusNewInstance({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 07a1c65. The catch returns now, so a failed add no longer leaves an observer waiting on an instance that never arrives.

I went with the return rather than moving the observe inside the try, so the success path still reads top to bottom.

parentCslp: details.fieldMetadata.cslpValue,
index: 0,
Expand Down Expand Up @@ -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"
>
Expand Down
Loading