diff --git a/src/visualBuilder/__test__/click/fields/multi-line.test.tsx b/src/visualBuilder/__test__/click/fields/multi-line.test.tsx index 5892dcd7..346eda0b 100644 --- a/src/visualBuilder/__test__/click/fields/multi-line.test.tsx +++ b/src/visualBuilder/__test__/click/fields/multi-line.test.tsx @@ -11,6 +11,8 @@ import { VisualBuilderPostMessageEvents } from "../../../utils/types/postMessage import { VisualBuilder } from "../../../index"; import { triggerAndWaitForClickAction } from "../../../../__test__/utils"; +const EXAMPLE_STAGE_NAME = "Example Stage"; + vi.mock("../../../components/FieldToolbar", () => { return { default: () => { @@ -94,23 +96,28 @@ describe("When an element is clicked in visual builder mode", () => { beforeAll(async () => { (visualBuilderPostMessage?.send as Mock).mockImplementation( (eventName: string, args) => { - if ( - eventName === - VisualBuilderPostMessageEvents.GET_FIELD_DATA - ) { - return Promise.resolve({ - fieldData: "Hello world", - }); - } else if ( - eventName === - VisualBuilderPostMessageEvents.GET_FIELD_DISPLAY_NAMES - ) { - return Promise.resolve({ - "all_fields.bltapikey.en-us.single_line": - "Single Line", - }); + switch (eventName) { + case VisualBuilderPostMessageEvents.GET_FIELD_DATA: + return Promise.resolve({ + fieldData: "Hello world", + }); + case VisualBuilderPostMessageEvents.GET_FIELD_DISPLAY_NAMES: + return Promise.resolve({ + "all_fields.bltapikey.en-us.single_line": + "Single Line", + }); + case VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS: + return Promise.resolve({ + stage: { name: EXAMPLE_STAGE_NAME }, + permissions: { + entry: { + update: true, + }, + }, + }); + default: + return Promise.resolve({}); } - return Promise.resolve({}); } ); @@ -185,20 +192,33 @@ describe("When an element is clicked in visual builder mode", () => { beforeAll(async () => { (visualBuilderPostMessage?.send as Mock).mockImplementation( (eventName: string, args) => { - if ( - eventName === - VisualBuilderPostMessageEvents.GET_FIELD_DATA - ) { - const values: Record = { - multi_line_textbox_multiple_: ["Hello", "world"], - "multi_line_textbox_multiple_.0": "Hello", - "multi_line_textbox_multiple_.1": "world", - }; - return Promise.resolve({ - fieldData: values[args.entryPath], - }); + switch (eventName) { + case VisualBuilderPostMessageEvents.GET_FIELD_DATA: { + const values: Record = { + multi_line_textbox_multiple_: [ + "Hello", + "world", + ], + "multi_line_textbox_multiple_.0": "Hello", + "multi_line_textbox_multiple_.1": "world", + }; + return Promise.resolve({ + fieldData: values[args.entryPath], + }); + } + case VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS: { + return Promise.resolve({ + stage: { name: EXAMPLE_STAGE_NAME }, + permissions: { + entry: { + update: true, + }, + }, + }); + } + default: + return Promise.resolve({}); } - return Promise.resolve({}); } ); diff --git a/src/visualBuilder/__test__/click/fields/number.test.tsx b/src/visualBuilder/__test__/click/fields/number.test.tsx index 66669c22..9190d8eb 100644 --- a/src/visualBuilder/__test__/click/fields/number.test.tsx +++ b/src/visualBuilder/__test__/click/fields/number.test.tsx @@ -11,6 +11,8 @@ import { VisualBuilderPostMessageEvents } from "../../../utils/types/postMessage import { VisualBuilder } from "../../../index"; import { triggerAndWaitForClickAction } from "../../../../__test__/utils"; +const EXAMPLE_STAGE_NAME = "Example Stage"; + const VALUES = { number: "10.5", }; @@ -112,6 +114,18 @@ describe("When an element is clicked in visual builder mode", () => { return Promise.resolve({ "all_fields.bltapikey.en-us.number": "Number", }); + } else if ( + eventName === + VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS + ) { + return Promise.resolve({ + stage: { name: EXAMPLE_STAGE_NAME }, + permissions: { + entry: { + update: true, + }, + }, + }); } return Promise.resolve({}); } @@ -196,6 +210,18 @@ describe("When an element is clicked in visual builder mode", () => { return Promise.resolve({ fieldData: values[args.entryPath], }); + } else if ( + eventName === + VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS + ) { + return Promise.resolve({ + stage: { name: EXAMPLE_STAGE_NAME }, + permissions: { + entry: { + update: true, + }, + }, + }); } return Promise.resolve({}); } diff --git a/src/visualBuilder/__test__/click/fields/single-line.test.tsx b/src/visualBuilder/__test__/click/fields/single-line.test.tsx index a85720ea..55a5899c 100644 --- a/src/visualBuilder/__test__/click/fields/single-line.test.tsx +++ b/src/visualBuilder/__test__/click/fields/single-line.test.tsx @@ -10,7 +10,8 @@ import { Mock, vi } from "vitest"; import { VisualBuilderPostMessageEvents } from "../../../utils/types/postMessage.types"; import { VisualBuilder } from "../../../index"; import { triggerAndWaitForClickAction } from "../../../../__test__/utils"; -import { act } from "preact/test-utils"; + +const EXAMPLE_STAGE_NAME = "Example Stage"; const VALUES = { singleLine: "Single line", @@ -99,23 +100,28 @@ describe("When an element is clicked in visual builder mode", () => { beforeAll(async () => { (visualBuilderPostMessage?.send as Mock).mockImplementation( (eventName: string) => { - if ( - eventName === - VisualBuilderPostMessageEvents.GET_FIELD_DATA - ) { - return Promise.resolve({ - fieldData: VALUES.singleLine, - }); - } else if ( - eventName === - VisualBuilderPostMessageEvents.GET_FIELD_DISPLAY_NAMES - ) { - return Promise.resolve({ - "all_fields.bltapikey.en-us.single_line": - "Single Line", - }); + switch (eventName) { + case VisualBuilderPostMessageEvents.GET_FIELD_DATA: + return Promise.resolve({ + fieldData: VALUES.singleLine, + }); + case VisualBuilderPostMessageEvents.GET_FIELD_DISPLAY_NAMES: + return Promise.resolve({ + "all_fields.bltapikey.en-us.single_line": + "Single Line", + }); + case VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS: + return Promise.resolve({ + stage: { name: EXAMPLE_STAGE_NAME }, + permissions: { + entry: { + update: true, + }, + }, + }); + default: + return Promise.resolve({}); } - return Promise.resolve({}); } ); @@ -205,20 +211,32 @@ describe("When an element is clicked in visual builder mode", () => { beforeAll(async () => { (visualBuilderPostMessage?.send as Mock).mockImplementation( (eventName: string, args) => { - if ( - eventName === - VisualBuilderPostMessageEvents.GET_FIELD_DATA - ) { - const values: Record = { - single_line_textbox_multiple_: ["Hello", "world"], - "single_line_textbox_multiple_.0": "Hello", - "single_line_textbox_multiple_.1": "world", - }; - return Promise.resolve({ - fieldData: values[args.entryPath], - }); + switch (eventName) { + case VisualBuilderPostMessageEvents.GET_FIELD_DATA: { + const values: Record = { + single_line_textbox_multiple_: [ + "Hello", + "world", + ], + "single_line_textbox_multiple_.0": "Hello", + "single_line_textbox_multiple_.1": "world", + }; + return Promise.resolve({ + fieldData: values[args.entryPath], + }); + } + case VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS: + return Promise.resolve({ + stage: { name: EXAMPLE_STAGE_NAME }, + permissions: { + entry: { + update: true, + }, + }, + }); + default: + return Promise.resolve({}); } - return Promise.resolve({}); } ); diff --git a/src/visualBuilder/components/FieldToolbar.tsx b/src/visualBuilder/components/FieldToolbar.tsx index 9009ae4e..f10a19fd 100644 --- a/src/visualBuilder/components/FieldToolbar.tsx +++ b/src/visualBuilder/components/FieldToolbar.tsx @@ -4,7 +4,6 @@ import getChildrenDirection from "../utils/getChildrenDirection"; import { ALLOWED_MODAL_EDITABLE_FIELD, ALLOWED_REPLACE_FIELDS, - DEFAULT_MULTIPLE_FIELDS, } from "../utils/constants"; import { getFieldType } from "../utils/getFieldType"; import { @@ -20,7 +19,6 @@ import { MoveLeftIcon, MoveRightIcon, ReplaceAssetIcon, - MoreIcon, } from "./icons"; import { fieldIcons } from "./icons/fields"; import classNames from "classnames"; @@ -42,10 +40,9 @@ import { } from "./FieldRevert/FieldRevertComponent"; import { LoadingIcon } from "./icons/loading"; import { EntryPermissions } from "../utils/getEntryPermissions"; -import { EmptyAppIcon } from "./icons/EmptyAppIcon"; import { FieldLocationAppList } from "./FieldLocationAppList"; import { FieldLocationIcon } from "./FieldLocationIcon"; - +import { WorkflowStageDetails } from "../utils/getWorkflowStageDetails"; export type FieldDetails = Pick< VisualBuilderCslpEventDetails, @@ -58,7 +55,8 @@ interface MultipleFieldToolbarProps { eventDetails: VisualBuilderCslpEventDetails; hideOverlay: () => void; isVariant?: boolean; - entryPermissions?: EntryPermissions; + entryPermissions?: EntryPermissions | undefined; + entryWorkflowStageDetails?: WorkflowStageDetails | undefined; } function handleReplaceAsset(fieldMetadata: CslpData) { @@ -118,6 +116,7 @@ function FieldToolbarComponent( eventDetails, isVariant: isVariantOrParentOfVariant, entryPermissions, + entryWorkflowStageDetails, } = props; const { fieldMetadata, editableElement: targetElement } = eventDetails; const [isFormLoading, setIsFormLoading] = useState(false); @@ -158,12 +157,12 @@ function FieldToolbarComponent( editableElement: targetElement, fieldMetadata, }, - entryPermissions + entryPermissions, + entryWorkflowStageDetails ); disableFieldActions = isDisabled; fieldType = getFieldType(fieldSchema); - Icon = fieldIcons[fieldType]; @@ -394,8 +393,6 @@ function FieldToolbarComponent( }; }, []); - - useEffect(() => { const fetchFieldLocationData = async () => { try { diff --git a/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx b/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx index 2f637bd4..3a65abc5 100644 --- a/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx +++ b/src/visualBuilder/components/__test__/fieldLabelWrapper.test.tsx @@ -6,7 +6,6 @@ import { singleLineFieldSchema } from "../../../__test__/data/fields"; import { asyncRender } from "../../../__test__/utils"; import { isFieldDisabled } from "../../utils/isFieldDisabled"; import { FieldSchemaMap } from "../../utils/fieldSchemaMap"; -import { getEntryPermissionsCached } from "../../utils/getEntryPermissionsCached"; import visualBuilderPostMessage from "../../utils/visualBuilderPostMessage"; import React from "preact/compat"; @@ -81,21 +80,33 @@ vi.mock("../../utils/isFieldDisabled", () => ({ vi.mock("../../../cslp", () => ({ extractDetailsFromCslp: vi.fn().mockImplementation((path) => { - return { - content_type_uid: "mockContentTypeUid", + return { + content_type_uid: "mockContentTypeUid", fieldPath: path, - cslpValue: path + cslpValue: path, }; }), })); -vi.mock("../../utils/getEntryPermissionsCached", () => ({ - getEntryPermissionsCached: vi.fn().mockResolvedValue({ - create: true, - read: true, - update: true, - delete: true, - publish: true, +vi.mock("../../utils/fetchEntryPermissionsAndStageDetails", () => ({ + fetchEntryPermissionsAndStageDetails: async () => ({ + acl: { + update: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + }, + }, + workflowStage: { + stage: undefined, + permissions: { + entry: { + update: true, + }, + }, + }, }), })); @@ -132,7 +143,6 @@ describe("FieldLabelWrapperComponent", () => { beforeEach(() => { vi.mocked(isFieldDisabled).mockReturnValue({ isDisabled: false, - // @ts-expect-error - reason is an unexported literal reason: "", }); @@ -237,7 +247,6 @@ describe("FieldLabelWrapperComponent", () => { test("renders with correct class when field is disabled", async () => { vi.mocked(isFieldDisabled).mockReturnValue({ isDisabled: true, - // @ts-expect-error - reason is an unexported literal reason: "You have only read access to this field", }); const { findByTestId } = await asyncRender( @@ -262,20 +271,10 @@ describe("FieldLabelWrapperComponent", () => { test("calls isFieldDisabled with correct arguments", async () => { const mockFieldSchema = { ...singleLineFieldSchema }; - const mockEntryPermissions = { - create: true, - read: true, - update: false, - delete: true, - publish: true, - }; vi.mocked(FieldSchemaMap.getFieldSchema).mockResolvedValue( mockFieldSchema ); - vi.mocked(getEntryPermissionsCached).mockResolvedValue( - mockEntryPermissions - ); await asyncRender( { expect(isFieldDisabled).toHaveBeenCalledWith( mockFieldSchema, mockEventDetails, - mockEntryPermissions + { + update: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + }, + }, + { + stage: undefined, + permissions: { + entry: { + update: true, + }, + }, + } ); }); @@ -351,7 +366,6 @@ describe("FieldLabelWrapperComponent", () => { expect(fieldLabelWrapper).toHaveAttribute("data-hovered-cslp", mockFieldMetadata.cslpValue); }); - test("does not render ContentTypeIcon when loading", async () => { // Mock the display names to never resolve to simulate loading state vi.mocked(visualBuilderPostMessage!.send).mockImplementation(() => { diff --git a/src/visualBuilder/components/fieldLabelWrapper.tsx b/src/visualBuilder/components/fieldLabelWrapper.tsx index 99c4003b..9ac2c22b 100644 --- a/src/visualBuilder/components/fieldLabelWrapper.tsx +++ b/src/visualBuilder/components/fieldLabelWrapper.tsx @@ -14,9 +14,9 @@ import { visualBuilderStyles } from "../visualBuilder.style"; import { CslpError } from "./CslpError"; import { hasPostMessageError } from "../utils/errorHandling"; import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; -import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; import { ContentTypeIcon } from "./icons"; import { ToolbarTooltip } from "./Tooltip"; +import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; interface ReferenceParentMap { [entryUid: string]: { @@ -55,7 +55,6 @@ async function getReferenceParentMap() { console.warn("[getFieldLabelWrapper] Error getting reference parent map", e); return {}; } - } interface FieldLabelWrapperProps { @@ -157,15 +156,18 @@ function FieldLabelWrapperComponent( return; } - const entryPermissions = await getEntryPermissionsCached({ - entryUid: props.fieldMetadata.entry_uid, - contentTypeUid: props.fieldMetadata.content_type_uid, - locale: props.fieldMetadata.locale, - }); + const { acl: entryAcl, workflowStage: entryWorkflowStageDetails } = + await fetchEntryPermissionsAndStageDetails({ + entryUid: props.fieldMetadata.entry_uid, + contentTypeUid: props.fieldMetadata.content_type_uid, + locale: props.fieldMetadata.locale, + variantUid: props.fieldMetadata.variant, + }); const { isDisabled: fieldDisabled, reason } = isFieldDisabled( fieldSchema, eventDetails, - entryPermissions + entryAcl, + entryWorkflowStageDetails ); const currentFieldDisplayName = diff --git a/src/visualBuilder/generators/__test__/generateToolbar.test.ts b/src/visualBuilder/generators/__test__/generateToolbar.test.ts index 13cbdd0e..1d8c9591 100644 --- a/src/visualBuilder/generators/__test__/generateToolbar.test.ts +++ b/src/visualBuilder/generators/__test__/generateToolbar.test.ts @@ -7,7 +7,6 @@ import { appendFieldPathDropdown } from "../generateToolbar"; import visualBuilderPostMessage from "../../utils/visualBuilderPostMessage"; import { VisualBuilderPostMessageEvents } from "../../utils/types/postMessage.types"; import { singleLineFieldSchema } from "../../../__test__/data/fields"; -import { sleep } from "../../../__test__/utils"; const MOCK_CSLP = "all_fields.bltapikey.en-us.single_line"; @@ -17,6 +16,28 @@ global.ResizeObserver = vi.fn().mockImplementation(() => ({ disconnect: vi.fn(), })); +vi.mock("../../utils/fetchEntryPermissionsAndStageDetails", () => ({ + fetchEntryPermissionsAndStageDetails: async () => ({ + acl: { + update: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + }, + }, + workflowStage: { + stage: undefined, + permissions: { + entry: { + update: true, + }, + }, + }, + }), +})); + describe("appendFieldPathDropdown", () => { let singleLineField: HTMLParagraphElement; let focusedToolbar: HTMLDivElement; diff --git a/src/visualBuilder/generators/__test__/generateToolbar.test.tsx b/src/visualBuilder/generators/__test__/generateToolbar.test.tsx index 614c7706..e4d1574e 100644 --- a/src/visualBuilder/generators/__test__/generateToolbar.test.tsx +++ b/src/visualBuilder/generators/__test__/generateToolbar.test.tsx @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { appendFocusedToolbar, appendFieldToolbar, appendFieldPathDropdown } from "../generateToolbar"; +import { + appendFieldToolbar, + appendFieldPathDropdown, +} from "../generateToolbar"; import { VisualBuilderCslpEventDetails } from "../../types/visualBuilder.types"; import { render } from "preact"; -import FieldToolbarComponent from "../../components/FieldToolbar"; -import FieldLabelWrapperComponent from "../../components/fieldLabelWrapper"; import { LIVE_PREVIEW_OUTLINE_WIDTH_IN_PX } from "../../utils/constants"; import React from "preact/compat"; @@ -21,6 +22,28 @@ vi.mock("../../components/fieldLabelWrapper", () => ({ default: vi.fn().mockImplementation(() =>
Test
), })); +vi.mock("../../utils/fetchEntryPermissionsAndStageDetails", () => ({ + fetchEntryPermissionsAndStageDetails: async () => ({ + acl: { + update: { + create: true, + read: true, + update: true, + delete: true, + publish: true, + }, + }, + workflowStage: { + stage: undefined, + permissions: { + entry: { + update: true, + }, + }, + }, + }), +})); + describe("generateToolbar", () => { const eventDetails: VisualBuilderCslpEventDetails = { editableElement: document.createElement("div"), diff --git a/src/visualBuilder/generators/generateToolbar.tsx b/src/visualBuilder/generators/generateToolbar.tsx index b15aa768..d1ca133d 100644 --- a/src/visualBuilder/generators/generateToolbar.tsx +++ b/src/visualBuilder/generators/generateToolbar.tsx @@ -10,9 +10,9 @@ import { import FieldToolbarComponent from "../components/FieldToolbar"; import { render } from "preact"; import FieldLabelWrapperComponent from "../components/fieldLabelWrapper"; -import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; +import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; export function appendFocusedToolbar( eventDetails: VisualBuilderCslpEventDetails, @@ -51,11 +51,13 @@ export async function appendFieldToolbar( ) && !isHover ) return; - const entryPermissions = await getEntryPermissionsCached({ - entryUid: eventDetails.fieldMetadata.entry_uid, - contentTypeUid: eventDetails.fieldMetadata.content_type_uid, - locale: eventDetails.fieldMetadata.locale, - }); + const { acl: entryPermissions, workflowStage: entryWorkflowStageDetails } = + await fetchEntryPermissionsAndStageDetails({ + entryUid: eventDetails.fieldMetadata.entry_uid, + contentTypeUid: eventDetails.fieldMetadata.content_type_uid, + locale: eventDetails.fieldMetadata.locale, + variantUid: eventDetails.fieldMetadata.variant, + }); const wrapper = document.createDocumentFragment(); render( , wrapper ); diff --git a/src/visualBuilder/listeners/mouseClick.ts b/src/visualBuilder/listeners/mouseClick.ts index d81a1ab7..d65ed0b3 100644 --- a/src/visualBuilder/listeners/mouseClick.ts +++ b/src/visualBuilder/listeners/mouseClick.ts @@ -30,7 +30,8 @@ import { isCollabThread } from "../generators/generateThread"; import { toggleCollabPopup } from "../generators/generateThread"; import { fixSvgXPath } from "../utils/collabUtils"; import { v4 as uuidV4 } from "uuid"; -import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; +import { CslpData } from "../../cslp/types/cslp.types"; +import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; export type HandleBuilderInteractionParams = Omit< EventListenerHandlerParams, @@ -45,7 +46,11 @@ type AddFocusOverlayParams = Pick< type AddFocusedToolbarParams = Pick< EventListenerHandlerParams, "eventDetails" | "focusedToolbar" -> & { hideOverlay: () => void; isVariant: boolean, options?: { isHover?: boolean } }; +> & { + hideOverlay: () => void; + isVariant: boolean; + options?: { isHover?: boolean }; +}; function addOverlay(params: AddFocusOverlayParams) { if (!params.overlayWrapper || !params.editableElement) return; @@ -295,35 +300,35 @@ function addOverlayAndToolbar( async function handleFieldSchemaAndIndividualFields( params: HandleBuilderInteractionParams, eventDetails: any, - fieldMetadata: any, + fieldMetadata: CslpData, editableElement: Element, previousSelectedElement: Element | null ) { - const { content_type_uid, entry_uid, fieldPath, locale } = fieldMetadata; + const { + content_type_uid, + entry_uid, + fieldPath, + locale, + variant: variantUid, + } = fieldMetadata; const fieldSchema = await FieldSchemaMap.getFieldSchema( content_type_uid, fieldPath ); - let entryAcl; - try { - entryAcl = await getEntryPermissionsCached({ + const { acl: entryAcl, workflowStage: entryWorkflowStageDetails } = + await fetchEntryPermissionsAndStageDetails({ entryUid: entry_uid, contentTypeUid: content_type_uid, locale, + variantUid, }); - } catch (error) { - console.error( - "[Visual Builder] Error retrieving entry permissions:", - error - ); - return; - } if (fieldSchema) { const { isDisabled } = isFieldDisabled( fieldSchema, eventDetails, - entryAcl + entryAcl, + entryWorkflowStageDetails ); if (isDisabled) { addOverlay({ diff --git a/src/visualBuilder/listeners/mouseHover.ts b/src/visualBuilder/listeners/mouseHover.ts index 38adc4d8..fee58c28 100644 --- a/src/visualBuilder/listeners/mouseHover.ts +++ b/src/visualBuilder/listeners/mouseHover.ts @@ -13,14 +13,11 @@ import { visualBuilderStyles } from "../visualBuilder.style"; import { VB_EmptyBlockParentClass } from "../.."; import Config from "../../configManager/configManager"; import { isCollabThread } from "../generators/generateThread"; -import { getEntryPermissionsCached } from "../utils/getEntryPermissionsCached"; -import { EntryPermissions } from "../utils/getEntryPermissions"; -import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; -import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; import { HandleBuilderInteractionParams } from "./mouseClick"; -import { appendFieldPathDropdown, removeFieldToolbar } from "../generators/generateToolbar"; +import { appendFieldPathDropdown } from "../generators/generateToolbar"; import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import { CslpData } from "../../cslp/types/cslp.types"; +import { fetchEntryPermissionsAndStageDetails } from "../utils/fetchEntryPermissionsAndStageDetails"; const config = Config.get(); export interface HandleMouseHoverParams @@ -71,46 +68,43 @@ function handleCursorPosition( } } -function addOutline(params?: AddOutlineParams): void { - if(!params) { +async function addOutline(params?: AddOutlineParams): Promise { + if (!params) { return; } - const { editableElement, eventDetails, content_type_uid, fieldPath, fieldMetadata, fieldDisabled } = params; + const { + editableElement, + eventDetails, + content_type_uid, + fieldPath, + fieldMetadata, + fieldDisabled, + } = params; if (!editableElement) return; addHoverOutline(editableElement as HTMLElement, fieldDisabled); - FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath).then( - (fieldSchema) => { - let entryAcl: EntryPermissions | undefined; - if (!fieldSchema) return; - getEntryPermissionsCached({ - entryUid: fieldMetadata.entry_uid, - contentTypeUid: fieldMetadata.content_type_uid, - locale: fieldMetadata.locale, - }) - .then((data) => { - entryAcl = data; - }) - .catch((error) => { - console.error( - "[Visual Builder] Error retrieving entry permissions:", - error - ); - }) - .finally(() => { - const { isDisabled: fieldDisabled } = - isFieldDisabled( - fieldSchema, - eventDetails, - entryAcl - ); - addHoverOutline(editableElement, fieldDisabled); - }); - } + const fieldSchema = await FieldSchemaMap.getFieldSchema( + content_type_uid, + fieldPath ); + if (!fieldSchema) return; + const { acl: entryAcl, workflowStage: entryWorkflowStageDetails } = + await fetchEntryPermissionsAndStageDetails({ + entryUid: fieldMetadata.entry_uid, + contentTypeUid: fieldMetadata.content_type_uid, + locale: fieldMetadata.locale, + variantUid: fieldMetadata.variant, + }); + const { isDisabled } = isFieldDisabled( + fieldSchema, + eventDetails, + entryAcl, + entryWorkflowStageDetails + ); + addHoverOutline(editableElement, fieldDisabled || isDisabled); } const debouncedAddOutline = debounce(addOutline, 50, { trailing: true }); -const showOutline = (params?: AddOutlineParams): void => debouncedAddOutline(params); +const showOutline = (params?: AddOutlineParams): Promise | undefined => debouncedAddOutline(params); function hideDefaultCursor(): void { if ( @@ -223,7 +217,7 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { hideCustomCursor(params.customCursor); return; } - if( + if ( eventTarget && (isFieldPathDropdown(eventTarget) || isFieldPathParent(eventTarget)) ) { @@ -291,10 +285,7 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { handleCursorPosition(params.event, params.customCursor); showCustomCursor(params.customCursor); return; - } else if ( - config?.collab.enable && - !config?.collab.isFeedbackMode - ) { + } else if (config?.collab.enable && !config?.collab.isFeedbackMode) { hideCustomCursor(params.customCursor); return; } @@ -318,48 +309,11 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { }); } - /** - * We called it seperately inside the code block to ensure that - * the code will not wait for the promise to resolve. - * If we get a cache miss, we will send a message to the iframe - * without blocking the code. - */ - FieldSchemaMap.getFieldSchema(content_type_uid, fieldPath).then( - (fieldSchema) => { - if (!fieldSchema) return; - - let entryAcl: EntryPermissions | undefined; - getEntryPermissionsCached({ - entryUid: fieldMetadata.entry_uid, - contentTypeUid: fieldMetadata.content_type_uid, - locale: fieldMetadata.locale, - }) - .then((data) => { - entryAcl = data; - }) - .catch((error) => { - console.error( - "[Visual Builder] Error retrieving entry permissions:", - error - ); - }) - .finally(() => { - if (!params.customCursor) return; - const { isDisabled: fieldDisabled } = - isFieldDisabled( - fieldSchema, - eventDetails, - entryAcl - ); - const fieldType = getFieldType(fieldSchema); - generateCustomCursor({ - fieldType, - customCursor: params.customCursor, - fieldDisabled, - }); - }); - } - ); + // we can generate the cursor asynchronously + generateCursor({ + eventDetails, + customCursor: params.customCursor, + }); handleCursorPosition(params.event, params.customCursor); showCustomCursor(params.customCursor); @@ -402,6 +356,45 @@ const throttledMouseHover = throttle(async (params: HandleMouseHoverParams) => { editableElement; }, 10); -const handleMouseHover = async (params: HandleMouseHoverParams): Promise => await throttledMouseHover(params); +async function generateCursor({ + eventDetails, + customCursor, +}: { + eventDetails: VisualBuilderCslpEventDetails; + customCursor: HTMLDivElement | null; +}) { + if (!customCursor) return; + const { fieldMetadata } = eventDetails; + const fieldSchema = await FieldSchemaMap.getFieldSchema( + fieldMetadata.content_type_uid, + fieldMetadata.fieldPath + ); + if (!fieldSchema) { + return; + } + const { acl: entryAcl, workflowStage: entryWorkflowStageDetails } = + await fetchEntryPermissionsAndStageDetails({ + entryUid: fieldMetadata.entry_uid, + contentTypeUid: fieldMetadata.content_type_uid, + locale: fieldMetadata.locale, + variantUid: fieldMetadata.variant, + }); + const { isDisabled: fieldDisabled } = isFieldDisabled( + fieldSchema, + eventDetails, + entryAcl, + entryWorkflowStageDetails + ); + const fieldType = getFieldType(fieldSchema); + generateCustomCursor({ + fieldType, + customCursor, + fieldDisabled, + }); +} + +const handleMouseHover = async ( + params: HandleMouseHoverParams +): Promise => await throttledMouseHover(params); export default handleMouseHover; diff --git a/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts b/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts index 0fc5ff25..85d364da 100644 --- a/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts +++ b/src/visualBuilder/utils/__test__/handleIndividualFields.test.ts @@ -9,7 +9,7 @@ import { handleAddButtonsForMultiple, removeAddInstanceButtons } from "../multip import { VisualBuilderPostMessageEvents } from "../types/postMessage.types"; import visualBuilderPostMessage from "../visualBuilderPostMessage"; import { VisualBuilder } from "../.."; -import { act, screen } from "@testing-library/preact"; +import { act } from "@testing-library/preact"; import { VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY } from "../constants"; import { FieldDataType } from "../types/index.types"; @@ -31,6 +31,7 @@ describe("handleIndividualFields", () => { beforeEach(() => { eventDetails = { + // @ts-expect-error mocking only required properties fieldMetadata: { content_type_uid: "contentTypeUid", entry_uid: "entryUid", @@ -79,6 +80,16 @@ describe("handleIndividualFields", () => { update: true, delete: true, publish: true, + }, + { + permissions: { + entry: { + update: true, + }, + }, + stage: { + name: "Unknown" + } } ); expect(eventDetails.editableElement.getAttribute(VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY)).toBe(fieldType); diff --git a/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts b/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts index 4b373c21..78814895 100644 --- a/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts +++ b/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts @@ -1,151 +1,183 @@ -import { describe, it, expect } from 'vitest'; -import { isFieldDisabled } from '../isFieldDisabled'; -import { ISchemaFieldMap } from '../types/index.types'; -import { FieldDetails } from '../../components/FieldToolbar'; -import Config from '../../../configManager/configManager'; -import { VisualBuilder } from '../..'; - -describe('isFieldDisabled', () => { - it('should return disabled state due to read-only role', () => { +import { describe, it, expect } from "vitest"; +import { isFieldDisabled } from "../isFieldDisabled"; +import { ISchemaFieldMap } from "../types/index.types"; +import { FieldDetails } from "../../components/FieldToolbar"; +import Config from "../../../configManager/configManager"; +import { VisualBuilder } from "../.."; +import { EntryPermissions } from "../getEntryPermissions"; + +describe("isFieldDisabled", () => { + it("should return disabled state due to read-only role", () => { + // @ts-expect-error mocking only required properties const fieldSchemaMap: ISchemaFieldMap = { field_metadata: { updateRestrict: true, }, }; const eventFieldDetails: FieldDetails = { - editableElement: document.createElement('div'), + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties fieldMetadata: { - locale: 'en-us', + locale: "en-us", }, }; const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails); expect(result.isDisabled).toBe(true); - expect(result.reason).toBe('You have only read access to this field'); + expect(result.reason).toBe("You have only read access to this field"); }); - it('should return disabled state due to non-localizable fields', () => { + it("should return disabled state due to non-localizable fields", () => { Config.get = () => ({ + // @ts-expect-error mocking only required properties stackDetails: { - masterLocale: 'en-us', + masterLocale: "en-us", }, }); + // @ts-expect-error mocking only required properties const fieldSchemaMap: ISchemaFieldMap = { non_localizable: true, }; const eventFieldDetails: FieldDetails = { - editableElement: document.createElement('div'), + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties fieldMetadata: { - locale: 'fr-fr', + locale: "fr-fr", }, }; const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails); expect(result.isDisabled).toBe(true); - expect(result.reason).toBe('Editing this field is restricted in localized entries'); + expect(result.reason).toBe( + "Editing this field is restricted in localized entries" + ); }); - it('should return disabled state due to unlinked variant', () => { + it("should return disabled state due to unlinked variant", () => { + // @ts-expect-error mocking only required properties const fieldSchemaMap: ISchemaFieldMap = { field_metadata: { isUnlinkedVariant: true, }, }; const eventFieldDetails: FieldDetails = { - editableElement: document.createElement('div'), + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties fieldMetadata: { - locale: 'en-us', + locale: "en-us", }, }; const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails); expect(result.isDisabled).toBe(true); - expect(result.reason).toBe('This field is not editable as it is not linked to the selected variant'); + expect(result.reason).toBe( + "This field is not editable as it is not linked to the selected variant" + ); }); - it('should return disabled state due to unlocalized variant', () => { + it("should return disabled state due to unlocalized variant", () => { VisualBuilder.VisualBuilderGlobalState = { + // @ts-expect-error mocking only required properties value: { - locale: 'en-us', - variant: true, + locale: "en-us", + variant: "default", }, }; + // @ts-expect-error mocking only required properties const fieldSchemaMap: ISchemaFieldMap = {}; const eventFieldDetails: FieldDetails = { - editableElement: document.createElement('div'), + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties fieldMetadata: { - locale: 'fr-fr', + locale: "fr-fr", }, }; const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails); expect(result.isDisabled).toBe(true); - expect(result.reason).toBe('This field is not editable as it is not localized'); + expect(result.reason).toBe( + "This field is not editable as it is not localized" + ); }); - it('should return disabled state due to audience mode', () => { + it("should return disabled state due to audience mode", () => { VisualBuilder.VisualBuilderGlobalState = { + // @ts-expect-error mocking only required properties value: { audienceMode: true, }, }; + // @ts-expect-error mocking only required properties const fieldSchemaMap: ISchemaFieldMap = {}; const eventFieldDetails: FieldDetails = { - editableElement: document.createElement('div'), + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties fieldMetadata: { - locale: 'en-us', + locale: "en-us", }, }; const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails); expect(result.isDisabled).toBe(true); - expect(result.reason).toBe('Open an Experience from Audience widget to start editing'); + expect(result.reason).toBe( + "Open an Experience from Audience widget to start editing" + ); }); - it('should return disabled state due to disabled variant', () => { + it("should return disabled state due to disabled variant", () => { VisualBuilder.VisualBuilderGlobalState = { + // @ts-expect-error mocking only required properties value: { audienceMode: true, }, }; + // @ts-expect-error mocking only required properties const fieldSchemaMap: ISchemaFieldMap = {}; const eventFieldDetails: FieldDetails = { - editableElement: document.createElement('div'), + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties fieldMetadata: { - locale: 'en-us', + locale: "en-us", }, }; - eventFieldDetails.editableElement.classList.add('visual-builder__disabled-variant-field'); + eventFieldDetails.editableElement.classList.add( + "visual-builder__disabled-variant-field" + ); const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails); expect(result.isDisabled).toBe(true); - expect(result.reason).toBe('This field is not editable as it doesn\'t match the selected variant'); + expect(result.reason).toBe( + "This field is not editable as it doesn't match the selected variant" + ); VisualBuilder.VisualBuilderGlobalState = { + // @ts-expect-error mocking only required properties value: { audienceMode: false, }, }; }); - it('should return enabled state when no restrictions apply', () => { + it("should return enabled state when no restrictions apply", () => { + // @ts-expect-error mocking only required properties const fieldSchemaMap: ISchemaFieldMap = {}; const eventFieldDetails: FieldDetails = { - editableElement: document.createElement('div'), + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties fieldMetadata: { - locale: 'en-us', + locale: "en-us", }, }; const result = isFieldDisabled(fieldSchemaMap, eventFieldDetails); expect(result.isDisabled).toBe(false); - expect(result.reason).toBe(''); + expect(result.reason).toBe(""); }); - - it('should return disabled state due to read-only role', () => { + + it("should return disabled state due to read-only role", () => { const fieldSchemaMap: ISchemaFieldMap = { data_type: "block", display_name: "Test Block", @@ -228,4 +260,275 @@ describe('isFieldDisabled', () => { ); }); + describe("workflow stage restrictions", () => { + it("should return disabled state due to workflow stage permission restriction", () => { + // @ts-expect-error mocking only required properties + const fieldSchemaMap: ISchemaFieldMap = {}; + const eventFieldDetails: FieldDetails = { + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties + fieldMetadata: { + locale: "en-us", + }, + }; + const entryPermissions: EntryPermissions = { + update: true, + create: true, + read: true, + delete: true, + publish: true, + }; + const workflowStageDetails = { + stage: { + name: "Review Stage", + }, + permissions: { + entry: { + update: false, + }, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + expect(result.reason).toBe( + "You do not have Edit access to this entry on the 'Review Stage' workflow stage" + ); + }); + + it("should return disabled state due to both entry permissions and workflow stage restrictions", () => { + // @ts-expect-error mocking only required properties + const fieldSchemaMap: ISchemaFieldMap = {}; + const eventFieldDetails: FieldDetails = { + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties + fieldMetadata: { + locale: "en-us", + }, + }; + const entryPermissions: EntryPermissions = { + update: false, + create: true, + read: true, + delete: true, + publish: true, + }; + const workflowStageDetails = { + stage: { + name: "Final Review", + }, + permissions: { + entry: { + update: false, + }, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + expect(result.reason).toBe( + "You do not have permission to edit this entry as per the role(s) assigned to you and the workflow stage rules for the 'Final Review' stage." + ); + }); + + it("should return enabled state when workflow stage allows editing", () => { + // @ts-expect-error mocking only required properties + const fieldSchemaMap: ISchemaFieldMap = {}; + const eventFieldDetails: FieldDetails = { + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties + fieldMetadata: { + locale: "en-us", + }, + }; + const entryPermissions: EntryPermissions = { + update: true, + create: true, + read: true, + delete: true, + publish: true, + }; + const workflowStageDetails = { + stage: { + name: "Draft", + }, + permissions: { + entry: { + update: true, + }, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(false); + expect(result.reason).toBe(""); + }); + + it("should handle workflow stage details with undefined stage name", () => { + // @ts-expect-error mocking only required properties + const fieldSchemaMap: ISchemaFieldMap = {}; + const eventFieldDetails: FieldDetails = { + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties + fieldMetadata: { + locale: "en-us", + }, + }; + const entryPermissions: EntryPermissions = { + update: true, + create: true, + read: true, + delete: true, + publish: true, + }; + const workflowStageDetails = { + stage: undefined, + permissions: { + entry: { + update: false, + }, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + expect(result.reason).toBe( + "You do not have Edit access to this entry on the 'Unknown' workflow stage" + ); + }); + + it("should handle workflow stage details with missing stage name", () => { + // @ts-expect-error mocking only required properties + const fieldSchemaMap: ISchemaFieldMap = {}; + const eventFieldDetails: FieldDetails = { + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties + fieldMetadata: { + locale: "en-us", + }, + }; + const entryPermissions: EntryPermissions = { + update: false, + create: true, + read: true, + delete: true, + publish: true, + }; + const workflowStageDetails = { + stage: { + name: undefined, + }, + permissions: { + entry: { + update: false, + }, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + entryPermissions, + // @ts-expect-error testing missing name property + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + expect(result.reason).toBe( + "You do not have permission to edit this entry as per the role(s) assigned to you and the workflow stage rules for the 'Unknown' stage." + ); + }); + + it("should prioritize workflow stage restriction over other restrictions", () => { + // @ts-expect-error mocking only required properties + const fieldSchemaMap: ISchemaFieldMap = { + field_metadata: { + updateRestrict: true, + }, + }; + const eventFieldDetails: FieldDetails = { + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties + fieldMetadata: { + locale: "en-us", + }, + }; + const entryPermissions: EntryPermissions = { + update: true, + create: true, + read: true, + delete: true, + publish: true, + }; + const workflowStageDetails = { + stage: { + name: "Protected Stage", + }, + permissions: { + entry: { + update: false, + }, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + // Should return read-only role message first based on getDisableReason logic + expect(result.reason).toBe( + "You have only read access to this field" + ); + }); + + it("should return enabled state when no workflow stage details provided", () => { + // @ts-expect-error mocking only required properties + const fieldSchemaMap: ISchemaFieldMap = {}; + const eventFieldDetails: FieldDetails = { + editableElement: document.createElement("div"), + // @ts-expect-error mocking only required properties + fieldMetadata: { + locale: "en-us", + }, + }; + const entryPermissions: EntryPermissions = { + update: true, + create: true, + read: true, + delete: true, + publish: true, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + entryPermissions, + undefined + ); + expect(result.isDisabled).toBe(false); + expect(result.reason).toBe(""); + }); + }); }); diff --git a/src/visualBuilder/utils/__test__/updateFocussedState.test.ts b/src/visualBuilder/utils/__test__/updateFocussedState.test.ts index 27c825bd..f427eaf0 100644 --- a/src/visualBuilder/utils/__test__/updateFocussedState.test.ts +++ b/src/visualBuilder/utils/__test__/updateFocussedState.test.ts @@ -1,22 +1,33 @@ import { describe, it, expect, vi } from "vitest"; -import { updateFocussedState, updateFocussedStateOnMutation } from "../updateFocussedState"; +import { + updateFocussedState, + updateFocussedStateOnMutation, +} from "../updateFocussedState"; import { VisualBuilder } from "../.."; -import { addFocusOverlay, hideFocusOverlay } from "../../generators/generateOverlay"; +import { + addFocusOverlay, + hideFocusOverlay, +} from "../../generators/generateOverlay"; import { mockGetBoundingClientRect } from "../../../__test__/utils"; import { act } from "@testing-library/preact"; import { singleLineFieldSchema } from "../../../__test__/data/fields"; import { getEntryPermissionsCached } from "../getEntryPermissionsCached"; +import { getWorkflowStageDetails } from "../getWorkflowStageDetails"; import { isFieldDisabled } from "../isFieldDisabled"; vi.mock("../../generators/generateOverlay", () => ({ addFocusOverlay: vi.fn(), - hideFocusOverlay: vi.fn() + hideFocusOverlay: vi.fn(), })); vi.mock("../getEntryPermissionsCached", () => ({ getEntryPermissionsCached: vi.fn(), })); +vi.mock("../getWorkflowStageDetails", () => ({ + getWorkflowStageDetails: vi.fn(), +})); + vi.mock("../../utils/isFieldDisabled", () => ({ isFieldDisabled: vi.fn().mockReturnValue({ isDisabled: false }), })); @@ -37,17 +48,19 @@ describe("updateFocussedState", () => { beforeEach(() => { let previousSelectedEditableDOM: HTMLElement; previousSelectedEditableDOM = document.createElement("div"); - previousSelectedEditableDOM.setAttribute("data-cslp", "content_type_uid.entry_uid.locale.field_path"); + previousSelectedEditableDOM.setAttribute( + "data-cslp", + "content_type_uid.entry_uid.locale.field_path" + ); document.body.appendChild(previousSelectedEditableDOM); - VisualBuilder.VisualBuilderGlobalState.value - .previousSelectedEditableDOM = previousSelectedEditableDOM; - }) + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + previousSelectedEditableDOM; + }); afterEach(() => { document.body.innerHTML = ""; - VisualBuilder.VisualBuilderGlobalState.value - .previousSelectedEditableDOM = null; - - }) + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + null; + }); it("should return early if required elements are not provided", async () => { const result = await updateFocussedState({ editableElement: null, @@ -60,12 +73,16 @@ describe("updateFocussedState", () => { }); it("should hide focus overlay if newPreviousSelectedElement is not found", () => { - const resizeObserverMock = { disconnect: vi.fn() } as unknown as ResizeObserver; + const resizeObserverMock = { + disconnect: vi.fn(), + } as unknown as ResizeObserver; const overlayWrapperMock = document.createElement("div"); const focusedToolbarMock = document.createElement("div"); const visualBuilderContainerMock = document.createElement("div"); - const spyQuerySelector = vi.spyOn(document, "querySelector").mockReturnValue(null); + const spyQuerySelector = vi + .spyOn(document, "querySelector") + .mockReturnValue(null); document.querySelector = vi.fn().mockReturnValue(null); updateFocussedState({ @@ -78,45 +95,58 @@ describe("updateFocussedState", () => { expect(hideFocusOverlay).toHaveBeenCalled(); spyQuerySelector.mockRestore(); - }); it("should update pseudo editable element styles", async () => { const editableElementMock = document.createElement("div"); - editableElementMock.setAttribute("data-cslp", "content_type_uid.entry_uid.locale.field_path"); + editableElementMock.setAttribute( + "data-cslp", + "content_type_uid.entry_uid.locale.field_path" + ); const visualBuilderContainerMock = document.createElement("div"); const overlayWrapperMock = document.createElement("div"); const focusedToolbarMock = document.createElement("div"); - const resizeObserverMock = { disconnect: vi.fn() } as unknown as ResizeObserver; + const resizeObserverMock = { + disconnect: vi.fn(), + } as unknown as ResizeObserver; const pseudoEditableElementMock = document.createElement("div"); - pseudoEditableElementMock.classList.add("visual-builder__pseudo-editable-element"); + pseudoEditableElementMock.classList.add( + "visual-builder__pseudo-editable-element" + ); visualBuilderContainerMock.appendChild(pseudoEditableElementMock); - + await act(async () => { - await updateFocussedState({ - editableElement: editableElementMock, - visualBuilderContainer: visualBuilderContainerMock, - overlayWrapper: overlayWrapperMock, - focusedToolbar: focusedToolbarMock, - resizeObserver: resizeObserverMock, - }); - }) + await updateFocussedState({ + editableElement: editableElementMock, + visualBuilderContainer: visualBuilderContainerMock, + overlayWrapper: overlayWrapperMock, + focusedToolbar: focusedToolbarMock, + resizeObserver: resizeObserverMock, + }); + }); expect(pseudoEditableElementMock.style.visibility).toBe("visible"); }); it("should update position of toolbar", async () => { const editableElementMock = document.createElement("div"); - mockGetBoundingClientRect(editableElementMock) - editableElementMock.setAttribute("data-cslp", "content_type_uid.entry_uid.locale.field_path"); + mockGetBoundingClientRect(editableElementMock); + editableElementMock.setAttribute( + "data-cslp", + "content_type_uid.entry_uid.locale.field_path" + ); const visualBuilderContainerMock = document.createElement("div"); const overlayWrapperMock = document.createElement("div"); const focusedToolbarMock = document.createElement("div"); - const resizeObserverMock = { disconnect: vi.fn() } as unknown as ResizeObserver; + const resizeObserverMock = { + disconnect: vi.fn(), + } as unknown as ResizeObserver; const pseudoEditableElementMock = document.createElement("div"); - pseudoEditableElementMock.classList.add("visual-builder__pseudo-editable-element"); + pseudoEditableElementMock.classList.add( + "visual-builder__pseudo-editable-element" + ); visualBuilderContainerMock.appendChild(pseudoEditableElementMock); document.querySelector = vi.fn().mockReturnValue(editableElementMock); @@ -154,10 +184,23 @@ describe("updateFocussedState", () => { publish: true, }; + const mockWorkflowStageDetails = { + permissions: { + entry: { + update: true, + }, + }, + stage: undefined, + }; + vi.mocked(getEntryPermissionsCached).mockResolvedValue( mockEntryPermissions ); + vi.mocked(getWorkflowStageDetails).mockResolvedValue( + mockWorkflowStageDetails + ); + await act(async () => { await updateFocussedState({ editableElement: editableElementMock, @@ -180,7 +223,8 @@ describe("updateFocussedState", () => { editableElement: editableElementMock, fieldMetadata: expect.any(Object), }, - mockEntryPermissions + mockEntryPermissions, + mockWorkflowStageDetails ); expect(addFocusOverlay).toHaveBeenCalledWith( @@ -195,24 +239,28 @@ describe("updateFocussedStateOnMutation", () => { beforeEach(() => { let previousSelectedEditableDOM: HTMLElement; previousSelectedEditableDOM = document.createElement("div"); - previousSelectedEditableDOM.setAttribute("data-cslp", "content_type_uid.entry_uid.locale.field_path"); + previousSelectedEditableDOM.setAttribute( + "data-cslp", + "content_type_uid.entry_uid.locale.field_path" + ); document.body.appendChild(previousSelectedEditableDOM); - VisualBuilder.VisualBuilderGlobalState.value - .previousSelectedEditableDOM = previousSelectedEditableDOM; - }) + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + previousSelectedEditableDOM; + }); afterEach(() => { document.body.innerHTML = ""; - VisualBuilder.VisualBuilderGlobalState.value - .previousSelectedEditableDOM = null; - - }) + VisualBuilder.VisualBuilderGlobalState.value.previousSelectedEditableDOM = + null; + }); it("should return early if focusOverlayWrapper is not provided", () => { const result = updateFocussedStateOnMutation(null, null, null, null); expect(result).toBeUndefined(); }); it("should hide focus overlay if newSelectedElement is not found", () => { - const resizeObserverMock = { disconnect: vi.fn() } as unknown as ResizeObserver; + const resizeObserverMock = { + disconnect: vi.fn(), + } as unknown as ResizeObserver; const focusOverlayWrapperMock = document.createElement("div"); const focusedToolbarMock = document.createElement("div"); const visualBuilderContainerMock = document.createElement("div"); @@ -257,4 +305,4 @@ describe("updateFocussedStateOnMutation", () => { expect(focusOutlineMock.style.width).toBe("100px"); expect(focusOutlineMock.style.height).toBe("100px"); }); -}); \ No newline at end of file +}); diff --git a/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts b/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts new file mode 100644 index 00000000..f9ed2b24 --- /dev/null +++ b/src/visualBuilder/utils/fetchEntryPermissionsAndStageDetails.ts @@ -0,0 +1,50 @@ +import { getEntryPermissionsCached } from "./getEntryPermissionsCached"; +import { getWorkflowStageDetails } from "./getWorkflowStageDetails"; + +export async function fetchEntryPermissionsAndStageDetails({ + entryUid, + contentTypeUid, + locale, + variantUid, +}: { + entryUid: string; + contentTypeUid: string; + locale: string; + variantUid?: string | undefined; +}) { + const entryAclPromise = getEntryPermissionsCached({ + entryUid, + contentTypeUid, + locale, + }); + const entryWorkflowStageDetailsPromise = getWorkflowStageDetails({ + entryUid, + contentTypeUid, + locale, + variantUid, + }); + const results = await Promise.allSettled([ + entryAclPromise, + entryWorkflowStageDetailsPromise, + ]); + if (results[0].status === "rejected") { + console.debug( + "[Visual Builder] Error retrieving entry permissions", + results[0].reason + ); + } + if (results[1].status === "rejected") { + console.debug( + "[Visual Builder] Error retrieving entry stage details", + results[1].reason + ); + } + const acl = + results[0].status === "fulfilled" ? results[0].value : undefined; + const workflowStage = + results[1].status === "fulfilled" ? results[1].value : undefined; + return { + acl, + workflowStage, + }; +} diff --git a/src/visualBuilder/utils/getEntryPermissions.ts b/src/visualBuilder/utils/getEntryPermissions.ts index 1a61c33e..15860531 100644 --- a/src/visualBuilder/utils/getEntryPermissions.ts +++ b/src/visualBuilder/utils/getEntryPermissions.ts @@ -1,3 +1,4 @@ +import { VisualBuilderPostMessageEvents } from "./types/postMessage.types"; import visualBuilderPostMessage from "./visualBuilderPostMessage"; export interface EntryPermissions { @@ -20,7 +21,7 @@ export async function getEntryPermissions({ try { const permissions = await visualBuilderPostMessage?.send( - "get-permissions", + VisualBuilderPostMessageEvents.GET_PERMISSIONS, { type: "entry", entryUid, diff --git a/src/visualBuilder/utils/getWorkflowStageDetails.ts b/src/visualBuilder/utils/getWorkflowStageDetails.ts new file mode 100644 index 00000000..98b63ac9 --- /dev/null +++ b/src/visualBuilder/utils/getWorkflowStageDetails.ts @@ -0,0 +1,59 @@ +import { VisualBuilderPostMessageEvents } from "./types/postMessage.types"; +import visualBuilderPostMessage from "./visualBuilderPostMessage"; + +export async function getWorkflowStageDetails({ + entryUid, + contentTypeUid, + locale, + variantUid, +}: { + entryUid: string; + contentTypeUid: string; + locale: string; + variantUid?: string | undefined; +}): Promise { + try { + const result = + await visualBuilderPostMessage?.send( + VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS, + { + entryUid, + contentTypeUid, + locale, + variantUid, + } + ); + if (result) { + return result; + } + } catch (e) { + console.debug( + "[Visual Builder] Error fetching workflow stage details", + e + ); + } + // allow editing when things go wrong, + return { + stage: { + name: "Unknown", + }, + permissions: { + entry: { + update: true, + }, + }, + }; +} + +export interface WorkflowStageDetails { + stage: + | { + name: string; + } + | undefined; + permissions: { + entry: { + update: boolean; + }; + }; +} diff --git a/src/visualBuilder/utils/handleIndividualFields.ts b/src/visualBuilder/utils/handleIndividualFields.ts index 63be0e51..30f4bd5c 100644 --- a/src/visualBuilder/utils/handleIndividualFields.ts +++ b/src/visualBuilder/utils/handleIndividualFields.ts @@ -14,8 +14,8 @@ import { isFieldMultiple } from "./isFieldMultiple"; import { handleInlineEditableField } from "./handleInlineEditableField"; import { VisualBuilderEditContext } from "./types/index.types"; import { pasteAsPlainText } from "./pasteAsPlainText"; -import { getEntryPermissionsCached } from "./getEntryPermissionsCached"; import { removeFieldToolbar } from "../generators/generateToolbar"; +import { fetchEntryPermissionsAndStageDetails } from "./fetchEntryPermissionsAndStageDetails"; /** * It handles all the fields based on their data type and its "multiple" property. @@ -47,15 +47,17 @@ export async function handleIndividualFields( const fieldType = getFieldType(fieldSchema); - const entryAcl = await getEntryPermissionsCached({ - entryUid: entry_uid, - contentTypeUid: content_type_uid, - locale, - }); + const { acl: entryAcl, workflowStage: entryWorkflowStageDetails } = + await fetchEntryPermissionsAndStageDetails({ + entryUid: entry_uid, + contentTypeUid: content_type_uid, + locale, + }); const { isDisabled: disabled } = isFieldDisabled( fieldSchema, eventDetails, - entryAcl + entryAcl, + entryWorkflowStageDetails ); editableElement.setAttribute( diff --git a/src/visualBuilder/utils/isFieldDisabled.ts b/src/visualBuilder/utils/isFieldDisabled.ts index 7ad202b0..868ab1b1 100644 --- a/src/visualBuilder/utils/isFieldDisabled.ts +++ b/src/visualBuilder/utils/isFieldDisabled.ts @@ -1,30 +1,40 @@ -import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; import Config from "../../configManager/configManager"; import { ISchemaFieldMap } from "./types/index.types"; import { VisualBuilder } from ".."; import { FieldDetails } from "../components/FieldToolbar"; import { EntryPermissions } from "./getEntryPermissions"; +import { WorkflowStageDetails } from "./getWorkflowStageDetails"; -enum DisableReason { - ReadOnly = "You have only read access to this field", - LocalizedEntry = "Editing this field is restricted in localized entries", - UnlinkedVariant = "This field is not editable as it is not linked to the selected variant", - AudienceMode = "Open an Experience from Audience widget to start editing", - DisabledVariant = "This field is not editable as it doesn't match the selected variant", - UnlocalizedVariant = "This field is not editable as it is not localized", - None = "", - EntryUpdateRestricted = "You do not have permission to edit this entry", -} +const DisableReason = { + ReadOnly: "You have only read access to this field", + LocalizedEntry: "Editing this field is restricted in localized entries", + UnlinkedVariant: + "This field is not editable as it is not linked to the selected variant", + AudienceMode: "Open an Experience from Audience widget to start editing", + DisabledVariant: + "This field is not editable as it doesn't match the selected variant", + UnlocalizedVariant: "This field is not editable as it is not localized", + None: "", + EntryUpdateRestricted: "You do not have permission to edit this entry", + WorkflowStagePermission: ({ stageName }: { stageName: string }) => + `You do not have Edit access to this entry on the '${stageName}' workflow stage`, + EntryUpdateRestrictedRoleAndWorkflowStage: ({ + stageName, + }: { + stageName: string; + }) => + `You do not have permission to edit this entry as per the role(s) assigned to you and the workflow stage rules for the '${stageName}' stage.`, +} as const; interface FieldDisableState { isDisabled: boolean; - reason: DisableReason; + reason: string; } -const getDisableReason = (flags: Record): DisableReason => { - if (flags.updateRestrictDueToEntryUpdateRestriction) { - return DisableReason.EntryUpdateRestricted; - } +const getDisableReason = ( + flags: Record, + params?: Record +) => { if (flags.updateRestrictDueToRole) return DisableReason.ReadOnly; if (flags.updateRestrictDueToNonLocalizableFields) return DisableReason.LocalizedEntry; @@ -36,13 +46,30 @@ const getDisableReason = (flags: Record): DisableReason => { return DisableReason.AudienceMode; if (flags.updateRestrictDueToDisabledVariant) return DisableReason.DisabledVariant; + if ( + flags.updateRestrictDueToEntryUpdateRestriction && + flags.updateRestrictDueToWorkflowStagePermission + ) { + return DisableReason.EntryUpdateRestrictedRoleAndWorkflowStage({ + stageName: params?.stageName ? params.stageName : "Unknown", + }); + } + if (flags.updateRestrictDueToEntryUpdateRestriction) { + return DisableReason.EntryUpdateRestricted; + } + if (flags.updateRestrictDueToWorkflowStagePermission) { + return DisableReason.WorkflowStagePermission({ + stageName: params?.stageName ? params.stageName : "Unknown", + }); + } return DisableReason.None; }; export const isFieldDisabled = ( fieldSchemaMap: ISchemaFieldMap, eventFieldDetails: FieldDetails, - entryPermissions?: EntryPermissions + entryPermissions?: EntryPermissions, + entryWorkflowStageDetails?: WorkflowStageDetails ): FieldDisableState => { const { editableElement, fieldMetadata } = eventFieldDetails; const masterLocale = Config.get().stackDetails.masterLocale || "en-us"; @@ -71,6 +98,13 @@ export const isFieldDisabled = ( flags.updateRestrictDueToEntryUpdateRestriction = true; } + if ( + entryWorkflowStageDetails && + !entryWorkflowStageDetails.permissions.entry.update + ) { + flags.updateRestrictDueToWorkflowStagePermission = true; + } + if ( VisualBuilder.VisualBuilderGlobalState.value.audienceMode && !editableElement.classList.contains("visual-builder__variant-field") && @@ -88,7 +122,9 @@ export const isFieldDisabled = ( } const isDisabled = Object.values(flags).some(Boolean); - const reason = getDisableReason(flags); + const reason = getDisableReason(flags, { + stageName: entryWorkflowStageDetails?.stage?.name, + }); return { isDisabled, reason }; }; diff --git a/src/visualBuilder/utils/types/postMessage.types.ts b/src/visualBuilder/utils/types/postMessage.types.ts index ec960e3e..f14c63f0 100644 --- a/src/visualBuilder/utils/types/postMessage.types.ts +++ b/src/visualBuilder/utils/types/postMessage.types.ts @@ -28,6 +28,8 @@ export enum VisualBuilderPostMessageEvents { COLLAB_MISSING_THREADS = "collab-missing-threads", FIELD_LOCATION_DATA = "field-location-data", FIELD_LOCATION_SELECTED_APP = "field-location-selected-app", + GET_PERMISSIONS = "get-permissions", + GET_WORKFLOW_STAGE_DETAILS = "get-workflow-stage-details", // FROM visual builder GET_ALL_ENTRIES_IN_CURRENT_PAGE = "get-entries-in-current-page", diff --git a/src/visualBuilder/utils/updateFocussedState.ts b/src/visualBuilder/utils/updateFocussedState.ts index a2093f3f..a5799fab 100644 --- a/src/visualBuilder/utils/updateFocussedState.ts +++ b/src/visualBuilder/utils/updateFocussedState.ts @@ -6,7 +6,6 @@ import { hideFocusOverlay, } from "../generators/generateOverlay"; import { hideHoverOutline } from "../listeners/mouseHover"; -import { getEntryPermissionsCached } from "./getEntryPermissionsCached"; import { LIVE_PREVIEW_OUTLINE_WIDTH_IN_PX, RIGHT_EDGE_BUFFER, @@ -17,6 +16,7 @@ import { FieldSchemaMap } from "./fieldSchemaMap"; import getChildrenDirection from "./getChildrenDirection"; import { getPsuedoEditableElementStyles } from "./getPsuedoEditableStylesElement"; import { isFieldDisabled } from "./isFieldDisabled"; +import { fetchEntryPermissionsAndStageDetails } from "./fetchEntryPermissionsAndStageDetails"; interface ToolbarPositionParams { focusedToolbar: HTMLElement | null; @@ -146,15 +146,18 @@ export async function updateFocussedState({ fieldMetadata.content_type_uid, fieldMetadata.fieldPath ); - const entryAcl = await getEntryPermissionsCached({ - entryUid: fieldMetadata.entry_uid, - contentTypeUid: fieldMetadata.content_type_uid, - locale: fieldMetadata.locale, - }); + const { acl: entryAcl, workflowStage: entryWorkflowStageDetails } = + await fetchEntryPermissionsAndStageDetails({ + entryUid: fieldMetadata.entry_uid, + contentTypeUid: fieldMetadata.content_type_uid, + locale: fieldMetadata.locale, + variantUid: fieldMetadata.variant, + }); const { isDisabled } = isFieldDisabled( fieldSchema, { editableElement, fieldMetadata }, - entryAcl + entryAcl, + entryWorkflowStageDetails ); addFocusOverlay(previousSelectedEditableDOM, overlayWrapper, isDisabled);