From 6c3605b6171f7eeed2a419db34b78f97151f4952 Mon Sep 17 00:00:00 2001 From: Sairaj Chouhan Date: Tue, 3 Nov 2026 14:30:19 +0530 Subject: [PATCH 1/3] fix(visual-builder): grey structural actions while a collaborator is in the container Match an instance element on its own path, so a collaborator's lock in a sibling instance no longer marks it locked or blocks selecting it. Disable move, delete and add on a multiple container while a collaborator holds a lock on it, inside it or above it, with the holder in the tooltip. Co-Authored-By: Claude Opus 5.5 --- src/visualBuilder/components/FieldToolbar.tsx | 46 +++++++--- .../__test__/addInstanceButton.test.tsx | 66 ++++++++++++++ .../components/__test__/fieldToolbar.test.tsx | 56 ++++++++++++ .../components/addInstanceButton.tsx | 25 +++++- .../components/fieldLabelWrapper.tsx | 6 +- .../utils/__test__/fieldLockIndicator.test.ts | 63 ++++++++++++- src/visualBuilder/utils/fieldLockIndicator.ts | 89 +++++++++++++------ 7 files changed, 307 insertions(+), 44 deletions(-) diff --git a/src/visualBuilder/components/FieldToolbar.tsx b/src/visualBuilder/components/FieldToolbar.tsx index d06f9f5f..879f0ec7 100644 --- a/src/visualBuilder/components/FieldToolbar.tsx +++ b/src/visualBuilder/components/FieldToolbar.tsx @@ -43,7 +43,11 @@ import { EntryPermissions } from "../utils/getEntryPermissions"; import { FieldLocationAppList } from "./FieldLocationAppList"; import { FieldLocationIcon } from "./FieldLocationIcon"; import { WorkflowStageDetails } from "../utils/getWorkflowStageDetails"; -import { getEntryEditRestrictionForField } from "../utils/fieldLockIndicator"; +import { + getEntryEditRestrictionForField, + getPeerLockForContainer, + peerLockMessage, +} from "../utils/fieldLockIndicator"; import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore"; import { ResolvedVariantPermissions } from "../utils/getResolvedVariantPermissions"; import { isCustomFieldMultipleInstance as checkIsCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance"; @@ -201,12 +205,21 @@ function FieldToolbarComponent( const [restrictionReason, setRestrictionReason] = useState(() => getEntryEditRestrictionForField(fieldMetadata) ); + const [containerPeerLock, setContainerPeerLock] = useState(() => + getPeerLockForContainer(fieldMetadata) + ); useEffect(() => { - const update = () => + const update = () => { setRestrictionReason(getEntryEditRestrictionForField(fieldMetadata)); + setContainerPeerLock(getPeerLockForContainer(fieldMetadata)); + }; update(); return subscribeEntryFieldLockInfo(update); }, [fieldMetadata]); + // Add / move / delete change the whole container, so a collaborator anywhere in it blocks them. + const containerLockReason = containerPeerLock + ? peerLockMessage(containerPeerLock) + : null; let disableFieldActions = Boolean(restrictionReason); let isCustomFieldMultipleInstance = false; @@ -560,9 +573,10 @@ function FieldToolbarComponent( multipleFieldToolbarButtonClasses } data-tooltip={ - direction === "vertical" + containerLockReason ?? + (direction === "vertical" ? "Move up" - : "Move left" + : "Move left") } onClick={(e) => { e.preventDefault(); @@ -573,7 +587,9 @@ function FieldToolbarComponent( ); }} disabled={ - disableFieldActions || disableMoveLeft + disableFieldActions || + Boolean(containerLockReason) || + disableMoveLeft } > @@ -597,9 +614,10 @@ function FieldToolbarComponent( multipleFieldToolbarButtonClasses } data-tooltip={ - direction === "vertical" + containerLockReason ?? + (direction === "vertical" ? "Move down" - : "Move right" + : "Move right") } onClick={(e) => { e.preventDefault(); @@ -610,7 +628,9 @@ function FieldToolbarComponent( ); }} disabled={ - disableFieldActions || disableMoveRight + disableFieldActions || + Boolean(containerLockReason) || + disableMoveRight } > @@ -637,13 +658,18 @@ function FieldToolbarComponent( className={ multipleFieldToolbarButtonClasses } - data-tooltip={"Delete"} + data-tooltip={ + containerLockReason ?? "Delete" + } onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDeleteInstance(fieldMetadata); }} - disabled={disableFieldActions} + disabled={ + disableFieldActions || + Boolean(containerLockReason) + } > diff --git a/src/visualBuilder/components/__test__/addInstanceButton.test.tsx b/src/visualBuilder/components/__test__/addInstanceButton.test.tsx index dc4e774f..6af7d61a 100644 --- a/src/visualBuilder/components/__test__/addInstanceButton.test.tsx +++ b/src/visualBuilder/components/__test__/addInstanceButton.test.tsx @@ -10,6 +10,10 @@ import { singleLineFieldSchema } from "../../../__test__/data/fields"; import AddInstanceButtonComponent from "../addInstanceButton"; import visualBuilderPostMessageActual from "../../utils/visualBuilderPostMessage"; import { getDiscussionIdByFieldMetaData } from "../../utils/getDiscussionIdByFieldMetaData"; +import { + clearAllEntryFieldLockInfo, + setEntryFieldLockInfo, +} from "../../utils/fieldLockStore"; const visualBuilderPostMessage = vi.mocked(visualBuilderPostMessageActual); @@ -147,4 +151,66 @@ describe("AddInstanceButtonComponent", () => { expect(onFieldLockRefused).toHaveBeenCalled(); expect(onClickCallback).not.toHaveBeenCalled(); }); + describe("a collaborator inside the container", () => { + const fieldMetadata = { + entry_uid: "e1", + locale: "en-us", + fieldPathWithIndex: "group", + }; + const lockIn = (path: string) => + setEntryFieldLockInfo( + { entryUid: "e1", locale: "en-us" }, + { + [path]: { + user: { uid: "u2", name: "Watcher" }, + ttl: "2030-01-01", + isLocked: true, + isOwn: false, + }, + } + ); + const renderButton = async () => { + await act(() => { + render( + + ); + }); + return getByTestId( + document.body, + "visual-builder-add-instance-button" + ); + }; + + afterEach(() => clearAllEntryFieldLockInfo()); + + test("is disabled with the holder in the tooltip", async () => { + lockIn("group.1.title"); + const button = await renderButton(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute( + "data-tooltip", + "This field is locked by Watcher" + ); + }); + + test("re-enables when the lock goes", async () => { + lockIn("group.1"); + const button = await renderButton(); + await act(() => + setEntryFieldLockInfo({ entryUid: "e1", locale: "en-us" }, {}) + ); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute("data-tooltip", "Add section"); + }); + }); }); diff --git a/src/visualBuilder/components/__test__/fieldToolbar.test.tsx b/src/visualBuilder/components/__test__/fieldToolbar.test.tsx index df5de50d..128f7788 100644 --- a/src/visualBuilder/components/__test__/fieldToolbar.test.tsx +++ b/src/visualBuilder/components/__test__/fieldToolbar.test.tsx @@ -30,6 +30,7 @@ import { VisualBuilderPostMessageEvents } from "../../utils/types/postMessage.ty import { clearAllEntryFieldLockInfo, setEntryEditRestriction, + setEntryFieldLockInfo, } from "../../utils/fieldLockStore"; import { VisualBuilder } from "../.."; @@ -817,6 +818,61 @@ describe("FieldToolbarComponent", () => { await waitFor(() => expect(deleteButton()).toBeDisabled()); }); + describe("a collaborator inside the container", () => { + const lockIn = (path: string) => + setEntryFieldLockInfo(scope, { + [path]: { + user: { uid: "u2", name: "Watcher" }, + ttl: "2030-01-01", + isLocked: true, + isOwn: false, + }, + }); + const button = (container: Element, name: string) => + container.querySelector( + `[data-testid="visual-builder__focused-toolbar__multiple-field-toolbar__${name}-button"]` + ); + + test("greys move and delete with the holder in the tooltip", async () => { + lockIn("group.link.1"); + const deleteButton = await renderToolbar(); + + await waitFor(() => expect(deleteButton()).toBeDisabled()); + expect(deleteButton()).toHaveAttribute( + "data-tooltip", + "This field is locked by Watcher" + ); + const toolbar = deleteButton()!.parentElement!; + expect(button(toolbar, "move-left")).toBeDisabled(); + expect(button(toolbar, "move-right")).toBeDisabled(); + }); + + test("re-enables them when the lock is released", async () => { + lockIn("group.link.1"); + const deleteButton = await renderToolbar(); + await waitFor(() => expect(deleteButton()).toBeDisabled()); + + act(() => setEntryFieldLockInfo(scope, {})); + + await waitFor(() => + expect(deleteButton()).not.toBeDisabled() + ); + expect(deleteButton()).toHaveAttribute( + "data-tooltip", + "Delete" + ); + }); + + test("ignores a lock in another field", async () => { + lockIn("group.title"); + const deleteButton = await renderToolbar(); + + await waitFor(() => + expect(deleteButton()).not.toBeDisabled() + ); + }); + }); + test("disables the field actions when the restriction arrives after the toolbar opened", async () => { const deleteButton = await renderToolbar(); await waitFor(() => expect(deleteButton()).not.toBeDisabled()); diff --git a/src/visualBuilder/components/addInstanceButton.tsx b/src/visualBuilder/components/addInstanceButton.tsx index f6dc621e..feda752b 100644 --- a/src/visualBuilder/components/addInstanceButton.tsx +++ b/src/visualBuilder/components/addInstanceButton.tsx @@ -1,4 +1,4 @@ -import React from "preact/compat"; +import React, { useEffect, useState } from "preact/compat"; import classNames from "classnames"; import { visualBuilderStyles } from "../visualBuilder.style"; import { PlusIcon } from "./icons"; @@ -8,6 +8,11 @@ import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; import { Signal } from "@preact/signals"; import { isFieldLockRefused } from "../utils/fieldLockRefused"; +import { + getPeerLockForContainer, + peerLockMessage, +} from "../utils/fieldLockIndicator"; +import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore"; interface AddInstanceButtonProps { value: any; @@ -28,6 +33,15 @@ function AddInstanceButtonComponent( const fieldMetadata = props.fieldMetadata; const index = props.index; const loading = props.loading; + const [containerPeerLock, setContainerPeerLock] = useState(() => + getPeerLockForContainer(fieldMetadata) + ); + useEffect(() => { + const update = () => + setContainerPeerLock(getPeerLockForContainer(fieldMetadata)); + update(); + return subscribeEntryFieldLockInfo(update); + }, [fieldMetadata]); const onClick = async (event: MouseEvent) => { loading.value = true; @@ -71,12 +85,17 @@ function AddInstanceButtonComponent( const isMaxInstances = maxInstances ? props.value.length >= maxInstances : false; - const disabled = loading.value || isMaxInstances; + const disabled = + loading.value || isMaxInstances || Boolean(containerPeerLock); return ( ; + }); + const button = generateAddInstanceButton({ + fieldSchema: singleLineFieldSchema, + value: "", + // @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, + }); + document.body.appendChild(button); + await vi.waitFor(() => expect(mounted).toHaveBeenCalled()); + + removeAddInstanceButton(button); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(button.isConnected).toBe(false); + }); + test("should call the AddInstanceButtonComponent with the correct props", () => { generateAddInstanceButton({ fieldSchema: singleLineFieldSchema, diff --git a/src/visualBuilder/generators/generateAddInstanceButtons.tsx b/src/visualBuilder/generators/generateAddInstanceButtons.tsx index de9d6367..5acc9a42 100644 --- a/src/visualBuilder/generators/generateAddInstanceButtons.tsx +++ b/src/visualBuilder/generators/generateAddInstanceButtons.tsx @@ -47,9 +47,22 @@ export function generateAddInstanceButton({ ); const button = wrapper.children[0] as HTMLButtonElement; + renderRoots.set(button, wrapper); return button; } +// The button's preact root, so removing the button can unmount it and run its +// effect cleanups (the lock-info subscription). +const renderRoots = new WeakMap(); + +/** Removes a button made by generateAddInstanceButton and unmounts its component. */ +export function removeAddInstanceButton(button: HTMLButtonElement): void { + const root = renderRoots.get(button); + renderRoots.delete(button); + if (root) render(null, root); + button.remove(); +} + /** * Returns an array of HTMLButtonElement instances that can be used to add new instances to the visual builder. * @param visualBuilderContainer - The HTMLDivElement that contains the visual builder. diff --git a/src/visualBuilder/utils/multipleElementAddButton.ts b/src/visualBuilder/utils/multipleElementAddButton.ts index c4546b6a..9fa38fda 100644 --- a/src/visualBuilder/utils/multipleElementAddButton.ts +++ b/src/visualBuilder/utils/multipleElementAddButton.ts @@ -2,6 +2,7 @@ import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import { generateAddInstanceButton, getAddInstanceButtons, + removeAddInstanceButton, } from "../generators/generateAddInstanceButtons"; import getChildrenDirection from "./getChildrenDirection"; import { hideOverlay } from "../generators/generateOverlay"; @@ -196,7 +197,7 @@ export function removeAddInstanceButtons( true ); - addInstanceButtons?.forEach((button) => button.remove()); + addInstanceButtons?.forEach(removeAddInstanceButton); } const addInstanceButtons = getAddInstanceButtons(visualBuilderContainer); @@ -219,8 +220,8 @@ export function removeAddInstanceButtons( return; } - nextButton.remove(); - previousButton.remove(); + removeAddInstanceButton(nextButton); + removeAddInstanceButton(previousButton); } /** From c69964c01bb38811370b27d413d122b24f9d9469 Mon Sep 17 00:00:00 2001 From: Sairaj Chouhan Date: Fri, 6 Nov 2026 11:12:13 +0530 Subject: [PATCH 3/3] fix(visual-builder): check a field's own lock by its index-form path The exact lookup used fieldPath, which has no indexes, so it never matched an index-form lock key the scan didn't already cover. Co-Authored-By: Claude Opus 5.5 --- .../utils/__test__/fieldLockIndicator.test.ts | 8 ++++++++ src/visualBuilder/utils/fieldLockIndicator.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts b/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts index a0bb528e..2d62a992 100644 --- a/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts +++ b/src/visualBuilder/utils/__test__/fieldLockIndicator.test.ts @@ -141,6 +141,14 @@ describe("instance elements", () => { expect(getPeerLockForField(instance(1))?.user.uid).toBe("u1"); }); + it("names the instance's own holder before a peer inside it", () => { + setEntryFieldLockInfo(scope, { + "section.0.buckets.1.title": peer({ user: { uid: "u2" } }), + "section.0.buckets.1": peer(), + }); + expect(getPeerLockForField(instance(1))?.user.uid).toBe("u1"); + }); + it("does not match instance 1 against a lock on instance 10", () => { setEntryFieldLockInfo(scope, { "section.0.buckets.10": peer() }); expect(getPeerLockForField(instance(1))).toBeNull(); diff --git a/src/visualBuilder/utils/fieldLockIndicator.ts b/src/visualBuilder/utils/fieldLockIndicator.ts index bd42755a..0aa53861 100644 --- a/src/visualBuilder/utils/fieldLockIndicator.ts +++ b/src/visualBuilder/utils/fieldLockIndicator.ts @@ -69,15 +69,17 @@ function findPeerLockAround( export function getPeerLockForField( fieldMetadata: FieldMetadataForLock ): EntryFieldLock | null { - const scopeLocks = getScopeLocks(fieldMetadata); - const exact = asPeerLock(scopeLocks[fieldMetadata.fieldPath]); - if (exact) return exact; - const target = fieldMetadata.instance?.fieldPathWithIndex || fieldMetadata.fieldPathWithIndex || fieldMetadata.fieldPath; - return target ? findPeerLockAround(scopeLocks, target) : null; + if (!target) return null; + + // Own path first so its holder wins over a peer in a nested or parent field. + const scopeLocks = getScopeLocks(fieldMetadata); + return ( + asPeerLock(scopeLocks[target]) ?? findPeerLockAround(scopeLocks, target) + ); } /**