Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/visualBuilder/components/VisualBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ function VisualBuilderComponent(props: VisualBuilderProps): JSX.Element | null {
)}
data-testid="visual-builder__hover-outline"
></div>
<div
className={classNames(
"visual-builder__lock-avatar visual-builder__lock-avatar--hidden",
visualBuilderStyles()["visual-builder__lock-avatar"],
visualBuilderStyles()["visual-builder__lock-avatar--hidden"]
)}
data-testid="visual-builder__lock-avatar"
></div>
<div
className={classNames(
"visual-builder__focused-toolbar",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
mockFieldMetadata,
mockEntryPermissionsResponse,
} from "./fieldLabelWrapper.mocks";
import {
setEntryFieldLockInfo,
clearAllEntryFieldLockInfo,
} from "../../../utils/fieldLockStore";

// Local cache for this test file (can't use imported cache in vi.mock due to hoisting)
const testFieldSchemaCache: Record<string, Record<string, any>> = {};
Expand Down Expand Up @@ -207,6 +211,7 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => {
beforeEach(() => {
// Reset all mocks to their default state before each test
vi.clearAllMocks();
clearAllEntryFieldLockInfo();

// Reset isFieldDisabled to default
(isFieldDisabled as any).mockReturnValue({
Expand Down Expand Up @@ -279,6 +284,7 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => {
afterEach(() => {
// Clean up field schema cache after each test
FieldSchemaMap.clear();
clearAllEntryFieldLockInfo();
// Clean up DOM after each test to prevent state pollution
document.body.innerHTML = "";
});
Expand Down Expand Up @@ -329,4 +335,52 @@ describe("FieldLabelWrapperComponent - Disabled Class", () => {
"visual-builder__focused-toolbar--field-disabled"
);
});

test("renders the disabled (locked) class when the field is peer-locked, even without a permission disable", async () => {
// isFieldDisabled stays false (no permission/workflow disable); the peer
// lock alone must drive the same disabled label affordance.
(isFieldDisabled as any).mockReturnValue({
isDisabled: false,
reason: "",
});
setEntryFieldLockInfo(
{
entryUid: mockFieldMetadata.entry_uid,
locale: mockFieldMetadata.locale,
},
{
[mockFieldMetadata.fieldPath]: {
user: { uid: "u1", name: "Ada Lovelace" },
ttl: "2030-01-01",
isLocked: true,
isOwn: false,
},
}
);

const { container } = render(
<FieldLabelWrapperComponent
fieldMetadata={mockFieldMetadata}
eventDetails={mockEventDetails}
parentPaths={[]}
getParentEditableElement={mockGetParentEditable}
/>
);

await act(async () => {
await new Promise<void>((resolve) =>
queueMicrotask(() => resolve())
);
});

const fieldLabel = (await findByTestId(
container as HTMLElement,
"visual-builder__focused-toolbar__field-label-wrapper",
{},
{ timeout: 1000 }
)) as HTMLElement;
expect(fieldLabel).toHaveClass(
"visual-builder__focused-toolbar--field-disabled"
);
});
});
111 changes: 85 additions & 26 deletions src/visualBuilder/components/fieldLabelWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classNames from "classnames";
import React, { useEffect, useRef, useState } from "preact/compat";
import React, { useCallback, useEffect, useRef, useState } from "preact/compat";
import { extractDetailsFromCslp, isValidCslp } from "../../cslp";
import { CslpData } from "../../cslp/types/cslp.types";
import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types";
Expand All @@ -20,6 +20,8 @@ import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermiss
import { VariantIndicator } from "./VariantIndicator";
import { handleRevalidateFieldData } from "../eventManager/useRevalidateFieldDataPostMessageEvent";
import { RESULT_TYPES } from "../utils/constants";
import { getPeerLockForField, lockAvatarInfo } from "../utils/fieldLockIndicator";
import { subscribeEntryFieldLockInfo } from "../utils/fieldLockStore";

interface ReferenceParentMap {
[entryUid: string]: {
Expand Down Expand Up @@ -211,6 +213,73 @@ function FieldLabelWrapperComponent(
const [error, setError] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);

// Base (non-lock) label state captured by the data fetch below. The peer
// lock is layered on top separately so the affordance re-renders when the
// lock mirror changes, without re-running the (network) fetch.
const labelBaseRef = useRef<{
text: string;
contentTypeName: string;
fieldDisabled: boolean;
reason: string;
workflowRequestUi?: "request" | "pending";
handleLinkVariant: () => void;
handleRequestEditAccess: () => void | Promise<void>;
hasParentPaths: boolean;
isReference: boolean;
referenceFieldName: string;
parentContentTypeName: string;
isVariant: boolean;
prefixIcon: any;
} | null>(null);

const applyLockAffordance = useCallback(() => {
const base = labelBaseRef.current;
if (!base) return;
const peerLock = getPeerLockForField(props.fieldMetadata);
const effectiveDisabled = base.fieldDisabled || Boolean(peerLock);
const effectiveReason =
!base.fieldDisabled && peerLock
? `This field is locked by ${
lockAvatarInfo(peerLock).name || "another user"
}`
: base.reason;
const usePlainDataTooltip =
effectiveReason &&
!effectiveReason.includes(DisableReason.CanLinkVariant) &&
base.workflowRequestUi == null;
setCurrentField({
text: base.text,
contentTypeName: base.contentTypeName,
icon: effectiveDisabled ? (
<FieldLabelDisabledIcon
reason={effectiveReason}
{...(base.workflowRequestUi != null
? { workflowRequestUi: base.workflowRequestUi }
: {})}
usePlainDataTooltip={Boolean(usePlainDataTooltip)}
onLinkVariant={base.handleLinkVariant}
onRequestEditAccess={base.handleRequestEditAccess}
/>
) : base.hasParentPaths ? (
<CaretIcon />
) : (
<></>
),
isReference: base.isReference,
prefixIcon: base.prefixIcon,
disabled: effectiveDisabled,
referenceFieldName: base.referenceFieldName,
parentContentTypeName: base.parentContentTypeName,
isVariant: base.isVariant,
});
}, [props.fieldMetadata]);

// Re-layer the lock affordance whenever the SDK lock mirror changes.
useEffect(
() => subscribeEntryFieldLockInfo(applyLockAffordance),
[applyLockAffordance]
);

function calculateTopOffset(index: number) {
const height = -30; // from bottom
const offset = (index + 1) * height;
Expand Down Expand Up @@ -351,36 +420,26 @@ function FieldLabelWrapperComponent(
const hasParentPaths = !!props?.parentPaths?.length;
const isVariant = props.fieldMetadata.variant ? true : false;

const usePlainDataTooltip =
reason &&
!reason.includes(DisableReason.CanLinkVariant) &&
workflowRequestUi == null;

setCurrentField({
// A peer-held lock reads as disabled in the field label too (reusing
// the disabled info-icon + tooltip). Capture the base (non-lock)
// state and layer the lock on via applyLockAffordance, so a lock
// acquire/release re-renders the label without re-fetching.
labelBaseRef.current = {
text: currentFieldDisplayName,
contentTypeName: contentTypeName ?? "",
icon: fieldDisabled ? (
<FieldLabelDisabledIcon
reason={reason}
{...(workflowRequestUi != null
? { workflowRequestUi }
: {})}
usePlainDataTooltip={Boolean(usePlainDataTooltip)}
onLinkVariant={handleLinkVariant}
onRequestEditAccess={handleRequestEditAccess}
/>
) : hasParentPaths ? (
<CaretIcon />
) : (
<></>
),
fieldDisabled,
reason,
...(workflowRequestUi != null ? { workflowRequestUi } : {}),
handleLinkVariant,
handleRequestEditAccess,
hasParentPaths,
isReference,
prefixIcon: getFieldIcon(fieldSchema),
disabled: fieldDisabled,
referenceFieldName,
parentContentTypeName,
isVariant: isVariant,
});
isVariant,
prefixIcon: getFieldIcon(fieldSchema),
};
applyLockAffordance();

if (displayNames) {
setDisplayNames(displayNames);
Expand Down
38 changes: 38 additions & 0 deletions src/visualBuilder/eventManager/useEntryLockInfoUpdateEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import visualBuilderPostMessage from "../utils/visualBuilderPostMessage";
import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types";
import {
EntryFieldLockInfo,
setEntryFieldLockInfo,
} from "../utils/fieldLockStore";

interface EntryLockInfoUpdateEvent {
data: {
entryUid: string;
locale: string;
variantUid?: string;
fieldLockInfo: EntryFieldLockInfo;
};
}

/**
* Keeps the SDK's lock mirror live. The parent pushes an entry's current
* field-lock map whenever a peer focuses or blurs a field; we overwrite that
* entry's scope in the mirror so indicators and gating reflect it immediately.
* The parent stays the single source of truth — the SDK only mirrors.
*/
export function useEntryLockInfoUpdateEvent(): void {
visualBuilderPostMessage?.on(
VisualBuilderPostMessageEvents.ENTRY_LOCK_INFO_UPDATE,
(event: EntryLockInfoUpdateEvent) => {
const { entryUid, locale, variantUid, fieldLockInfo } = event.data;
setEntryFieldLockInfo(
{
entryUid,
locale,
...(variantUid ? { variantUid } : {}),
},
fieldLockInfo ?? {}
);
}
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { showLockAvatar, hideLockAvatar } from "../generateLockAvatar";
import { visualBuilderStyles } from "../../visualBuilder.style";

vi.mock("../../utils/visualBuilderPostMessage", () => ({
default: { send: vi.fn(), on: vi.fn() },
}));

const hiddenClass = () =>
visualBuilderStyles()["visual-builder__lock-avatar--hidden"];

const lock = {
user: { uid: "u1", name: "Ada Lovelace", initials: "AL", avatarColor: "#c2410c" },
ttl: "2030-01-01",
isLocked: true,
isOwn: false,
};

beforeEach(() => {
document.body.innerHTML = "";
const avatar = document.createElement("div");
avatar.className = `visual-builder__lock-avatar ${hiddenClass()}`;
document.body.appendChild(avatar);
});

describe("lock avatar overlay", () => {
it("shows the avatar with initials, colour, title, and unhides it", () => {
const target = document.createElement("div");
document.body.appendChild(target);

showLockAvatar(target, lock as never);

const avatar = document.querySelector<HTMLElement>(
".visual-builder__lock-avatar"
)!;
expect(avatar.textContent).toBe("AL");
expect(avatar.style.backgroundColor).not.toBe("");
expect(avatar.getAttribute("title")).toBe("Locked by Ada Lovelace");
expect(avatar.classList.contains(hiddenClass())).toBe(false);
});

it("hides the avatar", () => {
const avatar = document.querySelector<HTMLElement>(
".visual-builder__lock-avatar"
)!;
avatar.classList.remove(hiddenClass());

hideLockAvatar();

expect(avatar.classList.contains(hiddenClass())).toBe(true);
});
});
50 changes: 50 additions & 0 deletions src/visualBuilder/generators/generateLockAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { visualBuilderStyles } from "../visualBuilder.style";
import { lockAvatarInfo } from "../utils/fieldLockIndicator";
import type { EntryFieldLock } from "../utils/fieldLockStore";

const AVATAR_SIZE = 24;

/**
* Shows the locking author's avatar at the hovered field's top-left corner,
* sitting just outside the field border. Reuses the single overlay avatar node
* (rendered by VisualBuilder), so it follows the hover the same way the hover
* outline does. Only used on hover of a peer-locked field.
*/
export function showLockAvatar(
targetElement: Element,
lock: EntryFieldLock
): void {
const avatar = document.querySelector<HTMLDivElement>(
".visual-builder__lock-avatar"
);
if (!avatar) {
return;
}
const rect = targetElement.getBoundingClientRect();
const { initials, color, name } = lockAvatarInfo(lock);

avatar.textContent = initials;
avatar.style.backgroundColor = color;
avatar.setAttribute(
"title",
name ? `Locked by ${name}` : "Locked by another user"
);
// Centre the badge on the field's top-left corner so it sits outside the border.
avatar.style.top = `${rect.top + window.scrollY - AVATAR_SIZE / 2}px`;
avatar.style.left = `${rect.left + window.scrollX - AVATAR_SIZE / 2}px`;
avatar.classList.remove(
visualBuilderStyles()["visual-builder__lock-avatar--hidden"]
);
}

export function hideLockAvatar(): void {
const avatar = document.querySelector<HTMLDivElement>(
".visual-builder__lock-avatar"
);
if (!avatar) {
return;
}
avatar.classList.add(
visualBuilderStyles()["visual-builder__lock-avatar--hidden"]
);
}
2 changes: 2 additions & 0 deletions src/visualBuilder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { h } from "preact";
import { extractDetailsFromCslp, isValidCslp } from "../cslp";
import initUI from "./components";
import { useDraftFieldsPostMessageEvent } from "./eventManager/useDraftFieldsPostMessageEvent";
import { useEntryLockInfoUpdateEvent } from "./eventManager/useEntryLockInfoUpdateEvent";
import { useHideFocusOverlayPostMessageEvent } from "./eventManager/useHideFocusOverlayPostMessageEvent";
import { useScrollToField } from "./eventManager/useScrollToField";
import { debounceAddVariantFieldClass, getHighlightVariantFieldsStatus, setHighlightVariantFields, useVariantFieldsPostMessageEvent } from "./eventManager/useVariantsPostMessageEvent";
Expand Down Expand Up @@ -414,6 +415,7 @@ export class VisualBuilder {
useOnEntryUpdatePostMessageEvent();
useRecalculateVariantDataCSLPValues();
useDraftFieldsPostMessageEvent();
useEntryLockInfoUpdateEvent();
useVariantFieldsPostMessageEvent({ isSSR: config.ssr ?? false });
}
})
Expand Down
Loading
Loading