diff --git a/src/visualBuilder/components/FieldToolbar.tsx b/src/visualBuilder/components/FieldToolbar.tsx index 766641a1..d06f9f5f 100644 --- a/src/visualBuilder/components/FieldToolbar.tsx +++ b/src/visualBuilder/components/FieldToolbar.tsx @@ -47,6 +47,10 @@ import { getEntryEditRestrictionForField } from "../utils/fieldLockIndicator"; import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore"; import { ResolvedVariantPermissions } from "../utils/getResolvedVariantPermissions"; import { isCustomFieldMultipleInstance as checkIsCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; +import { + isFieldLockRefused, + isFieldStillSelected, +} from "../utils/fieldLockRefused"; export type FieldDetails = Pick< VisualBuilderCslpEventDetails, @@ -64,17 +68,50 @@ interface MultipleFieldToolbarProps { resolvedVariantPermissions?: ResolvedVariantPermissions | undefined; } -function handleReplaceAsset(fieldMetadata: CslpData) { - // TODO avoid sending whole fieldMetadata - visualBuilderPostMessage?.send( - VisualBuilderPostMessageEvents.OPEN_ASSET_MODAL, - { - fieldMetadata, +/** + * Sends a replace request and deselects the field if the parent refuses it for + * a collaborator's lock, so the toolbar does not linger on a field we cannot + * edit. + */ +async function sendReplaceRequest( + fieldMetadata: CslpData, + request: Promise | undefined, + hideOverlay: () => void +): Promise { + try { + const response = await request; + if ( + isFieldLockRefused(response) && + isFieldStillSelected(fieldMetadata.cslpValue) + ) { + hideOverlay(); } + } catch (error) { + console.error( + "Visual Builder: Failed to open the replace modal", + error + ); + } +} + +function handleReplaceAsset(fieldMetadata: CslpData, hideOverlay: () => void) { + // TODO avoid sending whole fieldMetadata + return sendReplaceRequest( + fieldMetadata, + visualBuilderPostMessage?.send( + VisualBuilderPostMessageEvents.OPEN_ASSET_MODAL, + { + fieldMetadata, + } + ), + hideOverlay ); } -function handleReplaceReference(fieldMetadata: CslpData) { +function handleReplaceReference( + fieldMetadata: CslpData, + hideOverlay: () => void +) { const isMultipleInstance = fieldMetadata.multipleFieldMetadata.index > -1 && fieldMetadata.fieldPathWithIndex === @@ -83,16 +120,22 @@ function handleReplaceReference(fieldMetadata: CslpData) { ? fieldMetadata.instance.fieldPathWithIndex : fieldMetadata.fieldPathWithIndex; - visualBuilderPostMessage?.send( - VisualBuilderPostMessageEvents.OPEN_REFERENCE_MODAL, - { - entry_uid: fieldMetadata.entry_uid, - content_type_uid: fieldMetadata.content_type_uid, - locale: fieldMetadata.locale, - fieldPath: fieldMetadata.fieldPath, - fieldPathWithIndex: fieldMetadata.fieldPathWithIndex, - entryPath, - } + return sendReplaceRequest( + fieldMetadata, + visualBuilderPostMessage?.send( + VisualBuilderPostMessageEvents.OPEN_REFERENCE_MODAL, + { + entry_uid: fieldMetadata.entry_uid, + content_type_uid: fieldMetadata.content_type_uid, + locale: fieldMetadata.locale, + // The parent derives the field lock path from the variant entry. + variant: fieldMetadata.variant, + fieldPath: fieldMetadata.fieldPath, + fieldPathWithIndex: fieldMetadata.fieldPathWithIndex, + entryPath, + } + ), + hideOverlay ); } @@ -298,10 +341,13 @@ function FieldToolbarComponent( e.stopPropagation(); e.preventDefault(); if (fieldType === FieldDataType.REFERENCE) { - handleReplaceReference(fieldMetadata); + void handleReplaceReference( + fieldMetadata, + props.hideOverlay + ); return; } else if (fieldType === FieldDataType.FILE) { - handleReplaceAsset(fieldMetadata); + void handleReplaceAsset(fieldMetadata, props.hideOverlay); return; } }} diff --git a/src/visualBuilder/components/__test__/addInstanceButton.test.tsx b/src/visualBuilder/components/__test__/addInstanceButton.test.tsx index 066d33b7..dc4e774f 100644 --- a/src/visualBuilder/components/__test__/addInstanceButton.test.tsx +++ b/src/visualBuilder/components/__test__/addInstanceButton.test.tsx @@ -38,6 +38,7 @@ describe("AddInstanceButtonComponent", () => { fieldMetadata={{}} index={0} onClick={onClickCallback} + onFieldLockRefused={vi.fn()} label="Add instance" // @ts-expect-error mocking signal loading={{ value: false }} @@ -66,6 +67,7 @@ describe("AddInstanceButtonComponent", () => { fieldMetadata={{}} index={0} onClick={onClickCallback} + onFieldLockRefused={vi.fn()} label="Add instance" // @ts-expect-error mocking signal loading={{ value: false }} @@ -99,6 +101,7 @@ describe("AddInstanceButtonComponent", () => { fieldMetadata={{}} index={0} onClick={onClickCallback} + onFieldLockRefused={vi.fn()} label="Add instance" // @ts-expect-error mocking signal loading={{ value: false }} @@ -114,4 +117,34 @@ describe("AddInstanceButtonComponent", () => { }); expect(onClickCallback).toHaveBeenCalled(); }); + test("calls onFieldLockRefused instead of onClick when the lock is refused", async () => { + visualBuilderPostMessage!.send.mockResolvedValueOnce({ + fieldLockRefused: true, + }); + const onClickCallback = vi.fn(); + const onFieldLockRefused = vi.fn(); + await act(() => { + render( + + ); + }); + await act(() => { + fireEvent.click( + getByTestId(document.body, "visual-builder-add-instance-button") + ); + }); + expect(onFieldLockRefused).toHaveBeenCalled(); + expect(onClickCallback).not.toHaveBeenCalled(); + }); }); diff --git a/src/visualBuilder/components/__test__/emptyBlock.test.tsx b/src/visualBuilder/components/__test__/emptyBlock.test.tsx index 6201afb5..46a8a1c3 100644 --- a/src/visualBuilder/components/__test__/emptyBlock.test.tsx +++ b/src/visualBuilder/components/__test__/emptyBlock.test.tsx @@ -40,6 +40,8 @@ describe("EmptyBlock", () => { }); afterEach(() => { + // A test's resolved value would otherwise answer `send` in the next one. + (visualBuilderPostMessage as any).send.mockReset(); // RTL only removes containers it created, so this one would outlive the test host?.remove(); host = null; @@ -85,6 +87,24 @@ describe("EmptyBlock", () => { }); }); + test("does not wait for a new instance when the lock is refused", async () => { + (visualBuilderPostMessage as any).send.mockResolvedValue({ + fieldLockRefused: true, + }); + const { getByTestId } = render(); + + fireEvent.click(getByTestId("visual-builder__empty-block-add-button")); + + await waitFor(() => { + expect((visualBuilderPostMessage as any).send).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.ADD_INSTANCE, + expect.anything() + ); + }); + await Promise.resolve(); + expect(observeParentAndFocusNewInstance).not.toHaveBeenCalled(); + }); + test("claims the field lock before adding, so a peer sees it", async () => { host = document.createElement("div"); host.setAttribute("data-cslp", mockDetails.fieldMetadata.cslpValue); diff --git a/src/visualBuilder/components/__test__/fieldToolbar.test.tsx b/src/visualBuilder/components/__test__/fieldToolbar.test.tsx index 7f2c4ae3..df5de50d 100644 --- a/src/visualBuilder/components/__test__/fieldToolbar.test.tsx +++ b/src/visualBuilder/components/__test__/fieldToolbar.test.tsx @@ -31,6 +31,7 @@ import { clearAllEntryFieldLockInfo, setEntryEditRestriction, } from "../../utils/fieldLockStore"; +import { VisualBuilder } from "../.."; vi.mock("../../utils/instanceHandlers", () => ({ handleMoveInstance: vi.fn(), @@ -483,6 +484,150 @@ describe("FieldToolbarComponent", () => { }); }); + describe("replace refused by a collaborator's field lock", () => { + const referenceFieldSchema = { + uid: "refs", + data_type: "reference", + display_name: "Refs", + reference_to: ["page"], + field_metadata: { ref_multiple: true }, + multiple: true, + } as unknown as ISchemaFieldMap; + const cases = [ + { + name: "file", + schema: mockMultipleFileFieldSchema, + event: VisualBuilderPostMessageEvents.OPEN_ASSET_MODAL, + }, + { + name: "reference", + schema: referenceFieldSchema, + event: VisualBuilderPostMessageEvents.OPEN_REFERENCE_MODAL, + }, + ]; + const instanceMetadata: CslpData = { + ...mockMultipleFieldMetadata, + cslpValue: "page.entry.en-us.refs.0", + fieldPathWithIndex: "refs", + instance: { fieldPathWithIndex: "refs.0" }, + }; + + const selectElementWithCslp = (cslp: string) => { + const element = document.createElement("div"); + element.setAttribute("data-cslp", cslp); + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + element; + }; + + const clickReplace = async ( + name: string, + schema: ISchemaFieldMap, + response: unknown, + fieldMetadata: CslpData = instanceMetadata + ) => { + vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() => + Promise.resolve(schema) + ); + vi.mocked(visualBuilderPostMessage!.send).mockImplementation( + (eventName: string) => + Promise.resolve( + eventName === + VisualBuilderPostMessageEvents.OPEN_ASSET_MODAL || + eventName === + VisualBuilderPostMessageEvents.OPEN_REFERENCE_MODAL + ? response + : {} + ) as any + ); + const hideOverlay = vi.fn(); + const { container } = render( + + ); + const replaceButton = await findByTestId( + container, + `visual-builder-replace-${name}` + ); + await act(async () => { + fireEvent.click(replaceButton); + }); + return hideOverlay; + }; + + afterEach(() => { + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + null; + // clearAllMocks keeps implementations; put back the factory's `send`. + vi.mocked(visualBuilderPostMessage!.send).mockReset(); + }); + + test.each(cases)( + "deselects the $name field when the lock is refused", + async ({ name, schema, event }) => { + selectElementWithCslp(instanceMetadata.cslpValue); + + const hideOverlay = await clickReplace(name, schema, { + fieldLockRefused: true, + }); + + expect(visualBuilderPostMessage!.send).toHaveBeenCalledWith( + event, + expect.anything() + ); + await waitFor(() => expect(hideOverlay).toHaveBeenCalled()); + } + ); + + test.each(cases)( + "leaves a newer selection alone when the $name lock is refused", + async ({ name, schema }) => { + selectElementWithCslp("page.entry.en-us.title"); + + const hideOverlay = await clickReplace(name, schema, { + fieldLockRefused: true, + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(hideOverlay).not.toHaveBeenCalled(); + } + ); + + test.each(cases)( + "keeps the $name field selected when the modal opens", + async ({ name, schema }) => { + selectElementWithCslp(instanceMetadata.cslpValue); + + const hideOverlay = await clickReplace(name, schema, undefined); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(hideOverlay).not.toHaveBeenCalled(); + } + ); + + test("sends the variant with the reference replace request", async () => { + selectElementWithCslp(instanceMetadata.cslpValue); + + await clickReplace("reference", referenceFieldSchema, undefined, { + ...instanceMetadata, + variant: "variant_1", + }); + + expect(visualBuilderPostMessage!.send).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.OPEN_REFERENCE_MODAL, + expect.objectContaining({ variant: "variant_1" }) + ); + }); + }); + describe("'Replace button' visibility for multiple file fields", () => { beforeEach(() => { // Override the mock for this describe block - resolve immediately @@ -492,7 +637,7 @@ describe("FieldToolbarComponent", () => { }); afterEach(() => { - // Restore will happen in outer afterEach via clearAllMocks + // Nothing to restore: the outer beforeEach resets getFieldSchema per test. }); test("'replace button' is hidden for parent wrapper of multiple file field", async () => { diff --git a/src/visualBuilder/components/addInstanceButton.tsx b/src/visualBuilder/components/addInstanceButton.tsx index 650341a3..f6dc621e 100644 --- a/src/visualBuilder/components/addInstanceButton.tsx +++ b/src/visualBuilder/components/addInstanceButton.tsx @@ -7,10 +7,13 @@ import { CslpData } from "../../cslp/types/cslp.types"; import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; import { Signal } from "@preact/signals"; +import { isFieldLockRefused } from "../utils/fieldLockRefused"; interface AddInstanceButtonProps { value: any; onClick: (event: MouseEvent) => void; + /** Called instead of `onClick` when a collaborator holds the field lock. */ + onFieldLockRefused: () => void; label?: string | undefined; fieldSchema: ISchemaFieldMap | undefined; fieldMetadata: CslpData; @@ -28,8 +31,9 @@ function AddInstanceButtonComponent( const onClick = async (event: MouseEvent) => { loading.value = true; + let response: unknown; try { - await visualBuilderPostMessage?.send( + response = await visualBuilderPostMessage?.send( VisualBuilderPostMessageEvents.ADD_INSTANCE, { fieldMetadata, @@ -40,6 +44,10 @@ function AddInstanceButtonComponent( console.error("Visual Builder: Failed to add instance", error); } loading.value = false; + if (isFieldLockRefused(response)) { + props.onFieldLockRefused(); + return; + } props.onClick(event); }; diff --git a/src/visualBuilder/components/emptyBlock.tsx b/src/visualBuilder/components/emptyBlock.tsx index 617b7e2f..ad2fe32f 100644 --- a/src/visualBuilder/components/emptyBlock.tsx +++ b/src/visualBuilder/components/emptyBlock.tsx @@ -10,6 +10,7 @@ import { startCase, toLower } from "lodash-es"; import { getDOMEditStack } from "../utils/getCsDataOfElement"; import { isFieldBlockedByAutoDraft } from "../utils/fieldLockIndicator"; import { DATA_CSLP_ATTR_SELECTOR } from "../utils/constants"; +import { isFieldLockRefused } from "../utils/fieldLockRefused"; interface EmptyBlockProps { details: { @@ -49,13 +50,17 @@ export function EmptyBlock(props: EmptyBlockProps): JSX.Element { } try { - await visualBuilderPostMessage?.send( + const response = await visualBuilderPostMessage?.send( VisualBuilderPostMessageEvents.ADD_INSTANCE, { fieldMetadata: details.fieldMetadata, index: 0, } ); + // A collaborator holds the field: no instance is coming to focus. Nothing + // to release either: the refusal is the answer to the FOCUS_FIELD claim + // above, so the parent already holds nothing for this field. + if (isFieldLockRefused(response)) return; } catch (error) { console.error("Visual Builder: Failed to add instance", error); return; diff --git a/src/visualBuilder/generators/__test__/generateAddInstanceButtons.test.tsx b/src/visualBuilder/generators/__test__/generateAddInstanceButtons.test.tsx index 2595260f..07e1649f 100644 --- a/src/visualBuilder/generators/__test__/generateAddInstanceButtons.test.tsx +++ b/src/visualBuilder/generators/__test__/generateAddInstanceButtons.test.tsx @@ -32,6 +32,7 @@ describe("generateAddInstanceButton", () => { // @ts-expect-error mock field metadata fieldMetadata: { hello: "world" }, onClick: vi.fn(), + onFieldLockRefused: vi.fn(), // @ts-expect-error mocking preact signal loading: { value: false }, index: 0, @@ -47,6 +48,7 @@ describe("generateAddInstanceButton", () => { // @ts-expect-error mock field metadata fieldMetadata: { hello: "world" }, onClick: vi.fn(), + onFieldLockRefused: vi.fn(), // @ts-expect-error mocking preact signal loading: { value: false }, index: 0, @@ -58,6 +60,7 @@ describe("generateAddInstanceButton", () => { value: "", fieldMetadata: { hello: "world" }, onClick: expect.any(Function), + onFieldLockRefused: expect.any(Function), loading: { value: false }, index: 0, label: "Add Instance", diff --git a/src/visualBuilder/generators/generateAddInstanceButtons.tsx b/src/visualBuilder/generators/generateAddInstanceButtons.tsx index d39272c2..de9d6367 100644 --- a/src/visualBuilder/generators/generateAddInstanceButtons.tsx +++ b/src/visualBuilder/generators/generateAddInstanceButtons.tsx @@ -18,6 +18,7 @@ export function generateAddInstanceButton({ index, loading, onClick, + onFieldLockRefused, label, }: { fieldSchema: ISchemaFieldMap | undefined; @@ -26,6 +27,7 @@ export function generateAddInstanceButton({ index: number; loading: Signal; onClick: (event: MouseEvent) => void; + onFieldLockRefused: () => void; label?: string | undefined; }): HTMLButtonElement { const wrapper = document.createDocumentFragment(); @@ -37,6 +39,7 @@ export function generateAddInstanceButton({ value={value} label={label} onClick={onClick} + onFieldLockRefused={onFieldLockRefused} fieldSchema={fieldSchema} fieldMetadata={fieldMetadata} />, diff --git a/src/visualBuilder/utils/__test__/fieldLockRefused.test.ts b/src/visualBuilder/utils/__test__/fieldLockRefused.test.ts new file mode 100644 index 00000000..03c6774d --- /dev/null +++ b/src/visualBuilder/utils/__test__/fieldLockRefused.test.ts @@ -0,0 +1,35 @@ +import { VisualBuilder } from "../.."; +import { isFieldLockRefused, isFieldStillSelected } from "../fieldLockRefused"; + +describe("isFieldLockRefused", () => { + test.each([ + [{ fieldLockRefused: true }, true], + [{ fieldLockRefused: false }, false], + [{}, false], + [undefined, false], + [null, false], + ])("reads %o as %s", (response, expected) => { + expect(isFieldLockRefused(response)).toBe(expected); + }); +}); + +describe("isFieldStillSelected", () => { + afterEach(() => { + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + null; + }); + + test("matches the selected element by cslp", () => { + const element = document.createElement("div"); + element.setAttribute("data-cslp", "page.entry.en-us.banner"); + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + element; + + expect(isFieldStillSelected("page.entry.en-us.banner")).toBe(true); + expect(isFieldStillSelected("page.entry.en-us.title")).toBe(false); + }); + + test("is false when nothing is selected", () => { + expect(isFieldStillSelected("page.entry.en-us.banner")).toBe(false); + }); +}); diff --git a/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts b/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts index c5ea60d5..615d9722 100644 --- a/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts +++ b/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts @@ -14,6 +14,7 @@ import visualBuilderPostMessage from "../visualBuilderPostMessage"; import { VisualBuilderPostMessageEvents } from "../types/postMessage.types"; import { singleLineFieldSchema } from "../../../__test__/data/fields"; import { signal } from "@preact/signals"; +import { VisualBuilder } from "../.."; Object.defineProperty(globalThis, "crypto", { value: { @@ -480,6 +481,63 @@ describe("handleAddButtonsForMultiple", () => { vi.clearAllMocks(); }); + describe("when the field lock is refused", () => { + const clickAddRefused = async () => { + vi.mocked(visualBuilderPostMessage!.send).mockResolvedValueOnce( + { fieldLockRefused: true } + ); + handleAddButtonsForMultiple( + eventDetails, + { + editableElement: firstChild, + visualBuilderContainer: visualBuilderContainer, + resizeObserver: mockResizeObserver, + }, + { + fieldSchema: singleLineFieldSchema, + expectedFieldData: [], + disabled: false, + label: undefined, + } + ); + ( + visualBuilderContainer.querySelector( + `[data-testid="visual-builder-add-instance-button"]` + ) as HTMLButtonElement + ).click(); + await sleep(0); + }; + + afterEach(() => { + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + null; + }); + + test("deselects the field it was added from", async () => { + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + firstChild; + + await clickAddRefused(); + + expect( + VisualBuilder.VisualBuilderGlobalState.value + .previousSelectedEditableDOM + ).toBeNull(); + }); + + test("leaves a field selected since then alone", async () => { + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + secondChild; + + await clickAddRefused(); + + expect( + VisualBuilder.VisualBuilderGlobalState.value + .previousSelectedEditableDOM + ).toBe(secondChild); + }); + }); + test("should send an add instance message to the parent", async () => { handleAddButtonsForMultiple( eventDetails, @@ -585,6 +643,7 @@ describe("removeAddInstanceButtons", () => { fieldMetadata: { hello: "world" }, value: "", onClick: vi.fn(), + onFieldLockRefused: vi.fn(), loading: signal(false), }); nextButton = generateAddInstanceButton({ @@ -593,6 +652,7 @@ describe("removeAddInstanceButtons", () => { // @ts-expect-error mock field metadata fieldMetadata: { hello: "world" }, onClick: vi.fn(), + onFieldLockRefused: vi.fn(), loading: signal(false), }); overlayWrapper = document.createElement("div"); @@ -691,6 +751,7 @@ describe("removeAddInstanceButtons", () => { // @ts-expect-error mock field metadata fieldMetadata: { hello: "world" }, onClick: vi.fn(), + onFieldLockRefused: vi.fn(), loading: signal(false), }); visualBuilderContainer.appendChild(button); @@ -727,6 +788,7 @@ describe("removeAddInstanceButtons", () => { // @ts-expect-error mock field metadata fieldMetadata: { hello: "world" }, onClick: vi.fn(), + onFieldLockRefused: vi.fn(), loading: signal(false), }); visualBuilderContainer.appendChild(button); diff --git a/src/visualBuilder/utils/fieldLockRefused.ts b/src/visualBuilder/utils/fieldLockRefused.ts new file mode 100644 index 00000000..3111ea46 --- /dev/null +++ b/src/visualBuilder/utils/fieldLockRefused.ts @@ -0,0 +1,25 @@ +import { VisualBuilder } from ".."; + +/** + * Whether the parent refused a toolbar action because a collaborator holds the + * field lock. It answers OPEN_ASSET_MODAL, OPEN_REFERENCE_MODAL and ADD_INSTANCE + * with `{ fieldLockRefused: true }` and does not open the modal. + */ +export function isFieldLockRefused(response: unknown): boolean { + return ( + (response as { fieldLockRefused?: unknown } | null | undefined) + ?.fieldLockRefused === true + ); +} + +/** + * Whether the canvas selection is still the field with this cslp. The refusal + * arrives after a lock round trip, by which time the user may have selected + * another field, and deselecting that one would be wrong. + */ +export function isFieldStillSelected(cslp: string): boolean { + const selected = + VisualBuilder.VisualBuilderGlobalState.value + .previousSelectedEditableDOM; + return !!selected && selected.getAttribute("data-cslp") === cslp; +} diff --git a/src/visualBuilder/utils/multipleElementAddButton.ts b/src/visualBuilder/utils/multipleElementAddButton.ts index 97fbe82a..c4546b6a 100644 --- a/src/visualBuilder/utils/multipleElementAddButton.ts +++ b/src/visualBuilder/utils/multipleElementAddButton.ts @@ -5,6 +5,7 @@ import { } from "../generators/generateAddInstanceButtons"; import getChildrenDirection from "./getChildrenDirection"; import { hideOverlay } from "../generators/generateOverlay"; +import { isFieldStillSelected } from "./fieldLockRefused"; import { hideHoverOutline } from "../listeners/mouseHover"; import { ISchemaFieldMap } from "./types/index.types"; import { signal } from "@preact/signals"; @@ -105,6 +106,14 @@ export function handleAddButtonsForMultiple( }); }; + // No instance is coming, so only deselect, and only if the user has not + // moved to another field while the lock was requested. + const onFieldLockRefused = () => { + if (isFieldStillSelected(eventDetails.fieldMetadata.cslpValue)) { + hideOverlayAndHoverOutline(); + } + }; + // this is a shared loading state between the // next and previous button for the duration // between the add-instance post message being @@ -117,6 +126,7 @@ export function handleAddButtonsForMultiple( fieldMetadata: eventDetails.fieldMetadata, index: prevIndex, onClick: onMessageSent.bind(null, prevIndex), + onFieldLockRefused, loading, label, }); @@ -127,6 +137,7 @@ export function handleAddButtonsForMultiple( fieldMetadata: eventDetails.fieldMetadata, index: nextIndex, onClick: onMessageSent.bind(null, nextIndex), + onFieldLockRefused, loading, label, });