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 (