Skip to content

Commit 66bb3f0

Browse files
feat(VB-1583): restrict toolbar actions for multiple custom field instances
For multiple custom fields, show the edit modal button only on whole-field selection and suppress all toolbar actions (move, delete, form, add-instance) for individual instances. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c9d59c9 commit 66bb3f0

7 files changed

Lines changed: 272 additions & 2 deletions

File tree

src/__test__/data/fields.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,29 @@ export const mockMultipleFileFieldSchema: ISchemaFieldMap = {
5050
non_localizable: false,
5151
unique: false,
5252
};
53+
54+
export const mockMultipleCustomFieldSchema: ISchemaFieldMap = {
55+
extension_uid: "test_extension_uid",
56+
field_metadata: { extension: true },
57+
config: {},
58+
data_type: "number",
59+
display_name: "Custom Field",
60+
uid: "custom_field",
61+
mandatory: false,
62+
multiple: true,
63+
non_localizable: false,
64+
unique: false,
65+
} as unknown as ISchemaFieldMap;
66+
67+
export const mockSingleCustomFieldSchema: ISchemaFieldMap = {
68+
extension_uid: "test_extension_uid",
69+
field_metadata: { extension: true },
70+
config: {},
71+
data_type: "number",
72+
display_name: "Custom Field",
73+
uid: "custom_field",
74+
mandatory: false,
75+
multiple: false,
76+
non_localizable: false,
77+
unique: false,
78+
} as unknown as ISchemaFieldMap;

src/visualBuilder/components/FieldToolbar.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { FieldLocationAppList } from "./FieldLocationAppList";
4444
import { FieldLocationIcon } from "./FieldLocationIcon";
4545
import { WorkflowStageDetails } from "../utils/getWorkflowStageDetails";
4646
import { ResolvedVariantPermissions } from "../utils/getResolvedVariantPermissions";
47+
import { isCustomFieldMultipleInstance as checkIsCustomFieldMultipleInstance } from "../utils/isCustomFieldMultipleInstance";
4748

4849
export type FieldDetails = Pick<
4950
VisualBuilderCslpEventDetails,
@@ -153,6 +154,8 @@ function FieldToolbarComponent(
153154
const APP_LIST_MIN_WIDTH = 230;
154155

155156
let disableFieldActions = false;
157+
let isCustomFieldMultipleInstance = false;
158+
let isCustomFieldWholeMultiple = false;
156159
if (fieldSchema) {
157160
const { isDisabled } = isFieldDisabled(
158161
fieldSchema,
@@ -188,7 +191,15 @@ function FieldToolbarComponent(
188191
fieldMetadata.instance.fieldPathWithIndex ||
189192
fieldMetadata.multipleFieldMetadata?.index === -1);
190193

191-
isModalEditable = ALLOWED_MODAL_EDITABLE_FIELD.includes(fieldType) && !isWholeMultipleField;
194+
isCustomFieldMultipleInstance = checkIsCustomFieldMultipleInstance(fieldSchema, fieldMetadata);
195+
isCustomFieldWholeMultiple =
196+
fieldType === FieldDataType.CUSTOM_FIELD && isMultiple && isWholeMultipleField;
197+
198+
if (isCustomFieldWholeMultiple) {
199+
isModalEditable = true;
200+
} else {
201+
isModalEditable = ALLOWED_MODAL_EDITABLE_FIELD.includes(fieldType) && !isWholeMultipleField;
202+
}
192203

193204
isReplaceAllowed =
194205
ALLOWED_REPLACE_FIELDS.includes(fieldType) && !isWholeMultipleField;
@@ -425,6 +436,8 @@ function FieldToolbarComponent(
425436
}
426437
);
427438

439+
if (isCustomFieldMultipleInstance) return null;
440+
428441
return (
429442
<div
430443
className={classNames(

src/visualBuilder/components/__test__/fieldToolbar.test.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import FieldToolbarComponent from "../FieldToolbar";
1818
import {
1919
mockMultipleLinkFieldSchema,
2020
mockMultipleFileFieldSchema,
21+
mockMultipleCustomFieldSchema,
22+
mockSingleCustomFieldSchema,
2123
} from "../../../__test__/data/fields";
2224
import { VisualBuilderCslpEventDetails } from "../../types/visualBuilder.types";
2325
import { isFieldDisabled } from "../../utils/isFieldDisabled";
@@ -355,6 +357,119 @@ describe("FieldToolbarComponent", () => {
355357
});
356358
});
357359

360+
describe("Custom field multiple — toolbar visibility", () => {
361+
const customFieldInstanceMetadata: CslpData = {
362+
...mockMultipleFieldMetadata,
363+
fieldPathWithIndex: "custom_field",
364+
multipleFieldMetadata: {
365+
index: 0,
366+
parentDetails: {
367+
parentPath: "custom_field",
368+
parentCslpValue: "entry.ct.en-us",
369+
},
370+
},
371+
instance: { fieldPathWithIndex: "custom_field.0" },
372+
};
373+
374+
const customFieldWholeMetadata: CslpData = {
375+
...mockMultipleFieldMetadata,
376+
fieldPathWithIndex: "custom_field",
377+
instance: { fieldPathWithIndex: "custom_field" },
378+
};
379+
380+
test("renders nothing for a multiple custom field instance", async () => {
381+
vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() =>
382+
Promise.resolve(mockMultipleCustomFieldSchema)
383+
);
384+
385+
const { container } = render(
386+
<FieldToolbarComponent
387+
eventDetails={{
388+
...mockEventDetails,
389+
fieldMetadata: customFieldInstanceMetadata,
390+
}}
391+
hideOverlay={vi.fn()}
392+
/>
393+
);
394+
395+
await act(async () => {
396+
await new Promise((r) => setTimeout(r, 0));
397+
});
398+
399+
expect(
400+
container.querySelector(
401+
'[data-testid="visual-builder__focused-toolbar__multiple-field-toolbar"]'
402+
)
403+
).not.toBeInTheDocument();
404+
});
405+
406+
test("shows edit button for a multiple custom field whole-field selection", async () => {
407+
vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() =>
408+
Promise.resolve(mockMultipleCustomFieldSchema)
409+
);
410+
411+
const { container } = render(
412+
<FieldToolbarComponent
413+
eventDetails={{
414+
...mockEventDetails,
415+
fieldMetadata: customFieldWholeMetadata,
416+
}}
417+
hideOverlay={vi.fn()}
418+
/>
419+
);
420+
421+
await act(async () => {
422+
await new Promise((r) => setTimeout(r, 0));
423+
});
424+
425+
const editButton = await findByTestId(
426+
container,
427+
"visual-builder__focused-toolbar__multiple-field-toolbar__edit-button",
428+
{},
429+
{ timeout: 1000 }
430+
);
431+
expect(editButton).toBeInTheDocument();
432+
});
433+
434+
test("shows edit button for a single (non-multiple) custom field", async () => {
435+
vi.mocked(FieldSchemaMap.getFieldSchema).mockImplementation(() =>
436+
Promise.resolve(mockSingleCustomFieldSchema)
437+
);
438+
439+
const singleCustomFieldMetadata: CslpData = {
440+
...mockMultipleFieldMetadata,
441+
fieldPathWithIndex: "custom_field",
442+
multipleFieldMetadata: {
443+
index: -1,
444+
parentDetails: null,
445+
},
446+
instance: { fieldPathWithIndex: "custom_field" },
447+
};
448+
449+
const { container } = render(
450+
<FieldToolbarComponent
451+
eventDetails={{
452+
...mockEventDetails,
453+
fieldMetadata: singleCustomFieldMetadata,
454+
}}
455+
hideOverlay={vi.fn()}
456+
/>
457+
);
458+
459+
await act(async () => {
460+
await new Promise((r) => setTimeout(r, 0));
461+
});
462+
463+
const editButton = await findByTestId(
464+
container,
465+
"visual-builder__focused-toolbar__multiple-field-toolbar__edit-button",
466+
{},
467+
{ timeout: 1000 }
468+
);
469+
expect(editButton).toBeInTheDocument();
470+
});
471+
});
472+
358473
describe("'Replace button' visibility for multiple file fields", () => {
359474
beforeEach(() => {
360475
// Override the mock for this describe block - resolve immediately

src/visualBuilder/utils/__test__/handleIndividualFields.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,31 @@ describe("handleIndividualFields", () => {
133133
expect(handleAddButtonsForMultiple).toHaveBeenCalled();
134134
});
135135

136+
it("should NOT call handleAddButtonsForMultiple for a multiple custom field instance", async () => {
137+
const fieldSchema = {
138+
extension_uid: "test_ext",
139+
field_metadata: { extension: true },
140+
data_type: "number",
141+
multiple: true,
142+
};
143+
const isDisabled = { isDisabled: false };
144+
145+
(FieldSchemaMap.getFieldSchema as Mock).mockResolvedValue(fieldSchema);
146+
(getFieldData as Mock).mockResolvedValue([]);
147+
(getFieldType as Mock).mockReturnValue(FieldDataType.CUSTOM_FIELD);
148+
(isFieldDisabled as Mock).mockReturnValue(isDisabled);
149+
150+
// Instance: different paths + valid index
151+
eventDetails.fieldMetadata.multipleFieldMetadata = {
152+
index: 0,
153+
parentDetails: { parentPath: "fieldPath", parentCslpValue: "" },
154+
};
155+
156+
await handleIndividualFields(eventDetails, elements);
157+
158+
expect(handleAddButtonsForMultiple).not.toHaveBeenCalled();
159+
});
160+
136161
it("should handle inline editing for supported fields", async () => {
137162
const fieldSchema = {
138163
data_type: FieldDataType.SINGLELINE,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, it, expect } from "vitest";
2+
import { isCustomFieldMultipleInstance } from "../isCustomFieldMultipleInstance";
3+
import {
4+
mockMultipleCustomFieldSchema,
5+
mockSingleCustomFieldSchema,
6+
mockMultipleLinkFieldSchema,
7+
} from "../../../__test__/data/fields";
8+
import { CslpData } from "../../../cslp/types/cslp.types";
9+
10+
const instanceMetadata: CslpData = {
11+
entry_uid: "entry",
12+
content_type_uid: "ct",
13+
cslpValue: "",
14+
locale: "en-us",
15+
variant: undefined,
16+
fieldPath: "custom_field",
17+
fieldPathWithIndex: "custom_field",
18+
multipleFieldMetadata: {
19+
index: 0,
20+
parentDetails: {
21+
parentPath: "custom_field",
22+
parentCslpValue: "entry.ct.en-us",
23+
},
24+
},
25+
instance: {
26+
fieldPathWithIndex: "custom_field.0",
27+
},
28+
};
29+
30+
const wholeFieldMetadata: CslpData = {
31+
...instanceMetadata,
32+
fieldPathWithIndex: "custom_field",
33+
instance: { fieldPathWithIndex: "custom_field" },
34+
};
35+
36+
const wholeFieldByIndexMetadata: CslpData = {
37+
...instanceMetadata,
38+
multipleFieldMetadata: {
39+
index: -1,
40+
parentDetails: instanceMetadata.multipleFieldMetadata!.parentDetails,
41+
},
42+
};
43+
44+
describe("isCustomFieldMultipleInstance", () => {
45+
it("returns true for a multiple custom field instance", () => {
46+
expect(
47+
isCustomFieldMultipleInstance(mockMultipleCustomFieldSchema, instanceMetadata)
48+
).toBe(true);
49+
});
50+
51+
it("returns false when whole field is selected (same paths)", () => {
52+
expect(
53+
isCustomFieldMultipleInstance(mockMultipleCustomFieldSchema, wholeFieldMetadata)
54+
).toBe(false);
55+
});
56+
57+
it("returns false when whole field is selected (index === -1)", () => {
58+
expect(
59+
isCustomFieldMultipleInstance(mockMultipleCustomFieldSchema, wholeFieldByIndexMetadata)
60+
).toBe(false);
61+
});
62+
63+
it("returns false for a single (non-multiple) custom field", () => {
64+
expect(
65+
isCustomFieldMultipleInstance(mockSingleCustomFieldSchema, instanceMetadata)
66+
).toBe(false);
67+
});
68+
69+
it("returns false for a non-custom multiple field (e.g. link)", () => {
70+
expect(
71+
isCustomFieldMultipleInstance(mockMultipleLinkFieldSchema, instanceMetadata)
72+
).toBe(false);
73+
});
74+
});

src/visualBuilder/utils/handleIndividualFields.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { isFieldMultiple } from "./isFieldMultiple";
1414
import { handleInlineEditableField } from "./handleInlineEditableField";
1515
import { VisualBuilderEditContext } from "./types/index.types";
16+
import { isCustomFieldMultipleInstance } from "./isCustomFieldMultipleInstance";
1617
import { pasteAsPlainText } from "./pasteAsPlainText";
1718
import { removeFieldToolbar } from "../generators/generateToolbar";
1819
import { fetchEntryPermissionsAndStageDetails } from "./fetchEntryPermissionsAndStageDetails";
@@ -70,7 +71,7 @@ export async function handleIndividualFields(
7071
);
7172

7273
if (isFieldMultiple(fieldSchema)) {
73-
if (lastEditedField !== editableElement) {
74+
if (!isCustomFieldMultipleInstance(fieldSchema, fieldMetadata) && lastEditedField !== editableElement) {
7475
const addButtonLabel =
7576
fieldSchema.data_type === "blocks"
7677
? // ? `Add ${fieldSchema.display_name ?? "Modular Block"}`
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CslpData } from "../../cslp/types/cslp.types";
2+
import { FieldDataType, ISchemaFieldMap } from "./types/index.types";
3+
import { getFieldType } from "./getFieldType";
4+
import { isFieldMultiple } from "./isFieldMultiple";
5+
6+
export function isCustomFieldMultipleInstance(
7+
fieldSchema: ISchemaFieldMap,
8+
fieldMetadata: CslpData
9+
): boolean {
10+
return (
11+
getFieldType(fieldSchema) === FieldDataType.CUSTOM_FIELD &&
12+
isFieldMultiple(fieldSchema) &&
13+
fieldMetadata.fieldPathWithIndex !== fieldMetadata.instance.fieldPathWithIndex &&
14+
(fieldMetadata.multipleFieldMetadata?.index ?? -1) !== -1
15+
);
16+
}

0 commit comments

Comments
 (0)