Skip to content

Commit 3f1697f

Browse files
authored
Merge pull request #535 from contentstack/DRFT-207
DRFT-207: added support for field locking through canvas
2 parents 2635b07 + 4d0607c commit 3f1697f

11 files changed

Lines changed: 438 additions & 66 deletions

File tree

src/visualBuilder/components/fieldLabelWrapper.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types";
66
import { FieldSchemaMap } from "../utils/fieldSchemaMap";
77
import { DisableReason, isFieldDisabled } from "../utils/isFieldDisabled";
88
import visualBuilderPostMessage from "../utils/visualBuilderPostMessage";
9-
import { CaretIcon, CaretRightIcon, InfoIcon } from "./icons";
9+
import { CaretIcon, CaretRightIcon, InfoIcon, LockIcon } from "./icons";
1010
import { LoadingIcon } from "./icons/loading";
1111
import { FieldTypeIconsMap, getFieldIcon } from "../generators/generateCustomCursor";
1212
import { uniqBy } from "lodash-es";
@@ -64,6 +64,7 @@ interface FieldLabelWrapperProps {
6464
fieldMetadata: CslpData;
6565
eventDetails: VisualBuilderCslpEventDetails;
6666
parentPaths: string[];
67+
isLocked?: boolean;
6768
getParentEditableElement: (cslp: string) => HTMLElement | null;
6869
}
6970

@@ -339,6 +340,15 @@ function FieldLabelWrapperComponent(
339340
[visualBuilderStyles()[
340341
"visual-builder__focused-toolbar--variant"
341342
]]: currentField.isVariant,
343+
},
344+
{
345+
"visual-builder__focused-toolbar--field-locked":
346+
props.isLocked,
347+
},
348+
{
349+
[visualBuilderStyles()[
350+
"visual-builder__focused-toolbar--field-locked"
351+
]]: props.isLocked,
342352
}
343353
)}
344354
onClick={() => setIsDropdownOpen((prev) => !prev)}
@@ -430,6 +440,19 @@ function FieldLabelWrapperComponent(
430440
{currentField.text}
431441
</div>
432442
) : null}
443+
{props.isLocked ? (
444+
<div
445+
className={classNames(
446+
"visual-builder__lock-icon",
447+
visualBuilderStyles()[
448+
"visual-builder__lock-icon"
449+
]
450+
)}
451+
data-testid="visual-builder__lock-icon"
452+
>
453+
<LockIcon />
454+
</div>
455+
) : null}
433456
{getCurrentFieldIcon()}
434457
{error ? <CslpError /> : null}
435458
</button>

src/visualBuilder/components/icons/index.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,23 @@ export function CaretRightIcon(): JSX.Element {
358358

359359
)
360360
}
361+
362+
export function LockIcon(): JSX.Element {
363+
return (
364+
<svg
365+
data-testid="visual-builder__lock-icon"
366+
width="16"
367+
height="16"
368+
viewBox="0 0 16 16"
369+
fill="none"
370+
xmlns="http://www.w3.org/2000/svg"
371+
>
372+
<path
373+
fill-rule="evenodd"
374+
clip-rule="evenodd"
375+
d="M5.5 6.5V5.5C5.5 3.567 7.067 2 9 2C10.933 2 12.5 3.567 12.5 5.5V6.5C13.0523 6.5 13.5 6.94772 13.5 7.5V12.5C13.5 13.0523 13.0523 13.5 12.5 13.5H5.5C4.94772 13.5 4.5 13.0523 4.5 12.5V7.5C4.5 6.94772 4.94772 6.5 5.5 6.5ZM6.5 5.5V6.5H11.5V5.5C11.5 4.39543 10.6046 3.5 9 3.5C7.39543 3.5 6.5 4.39543 6.5 5.5ZM5.5 7.5V12.5H12.5V7.5H5.5Z"
376+
fill="white"
377+
/>
378+
</svg>
379+
);
380+
}

src/visualBuilder/generators/generateHoverOutline.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { visualBuilderStyles } from "../visualBuilder.style";
99
export function addHoverOutline(
1010
targetElement: Element,
1111
disabled?: boolean,
12-
isVariant?: boolean
12+
isVariant?: boolean,
13+
isLocked?: boolean
1314
): void {
1415
const targetElementDimension = targetElement.getBoundingClientRect();
1516

@@ -22,14 +23,30 @@ export function addHoverOutline(
2223
visualBuilderStyles()["visual-builder__hover-outline--hidden"]
2324
);
2425

25-
if (disabled) {
26+
if (isLocked) {
2627
hoverOutline.classList.add(
28+
visualBuilderStyles()["visual-builder__hover-outline--locked"]
29+
);
30+
hoverOutline.classList.remove(
2731
visualBuilderStyles()["visual-builder__hover-outline--disabled"]
2832
);
33+
hoverOutline.classList.remove(
34+
visualBuilderStyles()["visual-builder__hover-outline--variant"]
35+
);
36+
} else if (disabled) {
37+
hoverOutline.classList.add(
38+
visualBuilderStyles()["visual-builder__hover-outline--disabled"]
39+
);
40+
hoverOutline.classList.remove(
41+
visualBuilderStyles()["visual-builder__hover-outline--locked"]
42+
);
2943
} else {
3044
hoverOutline.classList.remove(
3145
visualBuilderStyles()["visual-builder__hover-outline--disabled"]
3246
);
47+
hoverOutline.classList.remove(
48+
visualBuilderStyles()["visual-builder__hover-outline--locked"]
49+
);
3350
if (isVariant) {
3451
hoverOutline.classList.add(
3552
visualBuilderStyles()["visual-builder__hover-outline--variant"]

src/visualBuilder/generators/generateOverlay.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ export function hideFocusOverlay(elements: HideOverlayParams): void {
109109
} = elements;
110110

111111
if (visualBuilderOverlayWrapper) {
112+
const previousSelectedEditableDOM =
113+
VisualBuilder.VisualBuilderGlobalState.value
114+
.previousSelectedEditableDOM;
115+
116+
if (previousSelectedEditableDOM) {
117+
sendUnlockFieldEvent(previousSelectedEditableDOM);
118+
}
119+
112120
visualBuilderOverlayWrapper.classList.remove("visible");
113121

114122
// Cleanup overlay styles: Top, Right, Bottom, Left & Outline
@@ -128,7 +136,7 @@ export function hideFocusOverlay(elements: HideOverlayParams): void {
128136
eventType: VisualBuilderPostMessageEvents.UPDATE_FIELD,
129137
});
130138
} else if (noTrigger) {
131-
const { previousSelectedEditableDOM, focusFieldValue } =
139+
const { focusFieldValue } =
132140
VisualBuilder.VisualBuilderGlobalState.value || {};
133141
if (
134142
previousSelectedEditableDOM &&
@@ -209,6 +217,38 @@ export function sendFieldEvent(options: ISendFieldEventParams): void {
209217
});
210218
}
211219
}
220+
221+
export function sendUnlockFieldEvent(editableElement: Element | null): void {
222+
if (!editableElement) {
223+
return;
224+
}
225+
226+
const cslpData = editableElement.getAttribute("data-cslp");
227+
if (!cslpData) {
228+
return;
229+
}
230+
231+
const isLocked =
232+
editableElement.getAttribute("data-field-locked") === "true";
233+
if (!isLocked) {
234+
return;
235+
}
236+
237+
const fieldMetadata = extractDetailsFromCslp(cslpData);
238+
239+
visualBuilderPostMessage?.send(
240+
VisualBuilderPostMessageEvents.UNLOCK_FIELD,
241+
{
242+
fieldMetadata: {
243+
content_type_uid: fieldMetadata.content_type_uid,
244+
entry_uid: fieldMetadata.entry_uid,
245+
fieldPath: fieldMetadata.fieldPath,
246+
locale: fieldMetadata.locale,
247+
variant: fieldMetadata.variant,
248+
},
249+
}
250+
);
251+
}
212252
interface HideOverlayParams
213253
extends Pick<
214254
EventListenerHandlerParams,

src/visualBuilder/generators/generateToolbar.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function appendFocusedToolbar(
2424
}
2525
): void {
2626
appendFieldPathDropdown(eventDetails, focusedToolbarElement, options);
27-
if(options?.isHover) {
27+
if (options?.isHover) {
2828
return;
2929
}
3030
appendFieldToolbar(
@@ -79,9 +79,10 @@ export function appendFieldPathDropdown(
7979
focusedToolbarElement: HTMLDivElement,
8080
options?: {
8181
isHover?: boolean;
82+
isLocked?: boolean;
8283
}
8384
): void {
84-
const { isHover } = options || {};
85+
const { isHover, isLocked: providedIsLocked } = options || {};
8586
const fieldLabelWrapper = document.querySelector(
8687
".visual-builder__focused-toolbar__field-label-wrapper"
8788
) as HTMLDivElement | null;
@@ -134,13 +135,18 @@ export function appendFieldPathDropdown(
134135
focusedToolbarElement.style.top = `${adjustedDistanceFromTop}px`;
135136

136137
const parentPaths = collectParentCSLPPaths(targetElement, 2);
138+
const isLocked =
139+
providedIsLocked !== undefined
140+
? providedIsLocked
141+
: targetElement.getAttribute("data-field-locked") === "true";
137142

138143
const wrapper = document.createDocumentFragment();
139144
render(
140145
<FieldLabelWrapperComponent
141146
fieldMetadata={fieldMetadata}
142147
eventDetails={eventDetails}
143148
parentPaths={parentPaths}
149+
isLocked={isLocked}
144150
getParentEditableElement={(cslp: string) => {
145151
const parentElement = targetElement.closest(
146152
`[${DATA_CSLP_ATTR_SELECTOR}="${cslp}"]`

src/visualBuilder/listeners/__test__/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ vi.mock("../mouseHover", () => ({
1717
hideCustomCursor: vi.fn(),
1818
hideHoverOutline: vi.fn(),
1919
showCustomCursor: vi.fn(),
20+
removeAllLockedFieldStyling: vi.fn(),
2021
}));
2122

2223
vi.mock("../../generators/generateToolbar", () => ({

src/visualBuilder/listeners/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import handleMouseHover, {
88
hideCustomCursor,
99
hideHoverOutline,
1010
showCustomCursor,
11+
removeAllLockedFieldStyling,
1112
} from "./mouseHover";
1213
import EventListenerHandlerParams from "./types";
1314

@@ -47,10 +48,14 @@ const eventHandlers = {
4748
cancelPendingMouseHover();
4849
cancelPendingHoverToolbar();
4950
cancelPendingAddOutline();
50-
51+
5152
hideCustomCursor(params.customCursor);
5253
hideHoverOutline(params.visualBuilderContainer);
53-
if(!VisualBuilder?.VisualBuilderGlobalState?.value?.isFocussed && params?.focusedToolbar) {
54+
removeAllLockedFieldStyling();
55+
if (
56+
!VisualBuilder?.VisualBuilderGlobalState?.value?.isFocussed &&
57+
params?.focusedToolbar
58+
) {
5459
removeFieldToolbar(params.focusedToolbar);
5560
}
5661
},

src/visualBuilder/listeners/mouseClick.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010

1111
import { appendFocusedToolbar } from "../generators/generateToolbar";
1212

13-
import { addFocusOverlay, hideOverlay } from "../generators/generateOverlay";
13+
import {
14+
addFocusOverlay,
15+
hideOverlay,
16+
sendUnlockFieldEvent,
17+
} from "../generators/generateOverlay";
1418

1519
import visualBuilderPostMessage from "../utils/visualBuilderPostMessage";
1620

@@ -32,6 +36,7 @@ import { fixSvgXPath } from "../utils/collabUtils";
3236
import { v4 as uuidV4 } from "uuid";
3337
import { CslpData } from "../../cslp/types/cslp.types";
3438
import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails";
39+
import { checkAndApplyFieldLockStatus } from "./mouseHover";
3540

3641
export type HandleBuilderInteractionParams = Omit<
3742
EventListenerHandlerParams,
@@ -172,6 +177,28 @@ export async function handleBuilderInteraction(
172177
}
173178

174179
const { editableElement, fieldMetadata } = eventDetails;
180+
181+
const previousSelectedEditableDOM =
182+
VisualBuilder.VisualBuilderGlobalState.value
183+
.previousSelectedEditableDOM;
184+
185+
if (
186+
previousSelectedEditableDOM &&
187+
previousSelectedEditableDOM !== editableElement
188+
) {
189+
sendUnlockFieldEvent(previousSelectedEditableDOM);
190+
}
191+
192+
const isFieldLocked = await checkFieldLockStatus(fieldMetadata);
193+
if (isFieldLocked) {
194+
await checkAndApplyFieldLockStatus(
195+
editableElement,
196+
fieldMetadata,
197+
"click"
198+
);
199+
return;
200+
}
201+
175202
const variantStatus = await getFieldVariantStatus(fieldMetadata);
176203
const isVariant = variantStatus
177204
? Object.values(variantStatus).some((value) => value === true)
@@ -316,14 +343,17 @@ async function handleFieldSchemaAndIndividualFields(
316343
content_type_uid,
317344
fieldPath
318345
);
319-
const { acl: entryAcl, workflowStage: entryWorkflowStageDetails, resolvedVariantPermissions } =
320-
await fetchEntryPermissionsAndStageDetails({
321-
entryUid: entry_uid,
322-
contentTypeUid: content_type_uid,
323-
locale,
324-
variantUid,
325-
fieldPathWithIndex,
326-
});
346+
const {
347+
acl: entryAcl,
348+
workflowStage: entryWorkflowStageDetails,
349+
resolvedVariantPermissions,
350+
} = await fetchEntryPermissionsAndStageDetails({
351+
entryUid: entry_uid,
352+
contentTypeUid: content_type_uid,
353+
locale,
354+
variantUid,
355+
fieldPathWithIndex,
356+
});
327357

328358
if (fieldSchema) {
329359
const { isDisabled } = isFieldDisabled(
@@ -376,4 +406,27 @@ function observeEditableElementChanges(
376406
focusElementObserver.observe(editableElement, { attributes: true });
377407
}
378408

409+
async function checkFieldLockStatus(fieldMetadata: CslpData): Promise<boolean> {
410+
try {
411+
const response = (await visualBuilderPostMessage?.send(
412+
VisualBuilderPostMessageEvents.CHECK_OR_ACQUIRE_FIELD_LOCK,
413+
{
414+
fieldMetadata: {
415+
content_type_uid: fieldMetadata.content_type_uid,
416+
entry_uid: fieldMetadata.entry_uid,
417+
fieldPath: fieldMetadata.fieldPath,
418+
locale: fieldMetadata.locale,
419+
variant: fieldMetadata.variant,
420+
},
421+
type: "click",
422+
}
423+
)) as { isLocked?: boolean } | undefined;
424+
425+
return response?.isLocked || false;
426+
} catch (error) {
427+
console.warn("Failed to check field lock status:", error);
428+
return false;
429+
}
430+
}
431+
379432
export default handleBuilderInteraction;

0 commit comments

Comments
 (0)