diff --git a/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.mocks.ts b/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.mocks.ts index a59699ab..9a0bb1d9 100644 --- a/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.mocks.ts +++ b/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.mocks.ts @@ -20,6 +20,11 @@ export const mockStyles = { "visual-builder__tooltip--persistent": "visual-builder__tooltip--persistent", "visual-builder__custom-tooltip": "visual-builder__custom-tooltip", + "visual-builder__custom-tooltip--below": "visual-builder__custom-tooltip--below", + "visual-builder__custom-tooltip--workflow-access": + "visual-builder__custom-tooltip--workflow-access", + "visual-builder__tooltip--persistent--below": + "visual-builder__tooltip--persistent--below", "visual-builder__focused-toolbar__field-label-wrapper": "visual-builder__focused-toolbar__field-label-wrapper", "visual-builder__focused-toolbar--field-disabled": diff --git a/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.workflowRequest.test.tsx b/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.workflowRequest.test.tsx new file mode 100644 index 00000000..4bd3a49f --- /dev/null +++ b/src/visualBuilder/components/__test__/fieldlabelwrapper/fieldLabelWrapper.workflowRequest.test.tsx @@ -0,0 +1,421 @@ +import { render, waitFor, act, findByTestId, screen } from "@testing-library/preact"; +import { fireEvent } from "@testing-library/preact"; +import FieldLabelWrapperComponent from "../../fieldLabelWrapper"; +import { VisualBuilderCslpEventDetails } from "../../../types/visualBuilder.types"; +import { singleLineFieldSchema } from "../../../../__test__/data/fields"; +import { FieldSchemaMap } from "../../../utils/fieldSchemaMap"; +import visualBuilderPostMessage from "../../../utils/visualBuilderPostMessage"; +import { VisualBuilderPostMessageEvents } from "../../../utils/types/postMessage.types"; +import { fetchEntryPermissionsAndStageDetails } from "../../../utils/fetchEntryPermissionsAndStageDetails"; +import { WORKFLOW_STAGES } from "../../../utils/constants"; +import React from "preact/compat"; +import Config from "../../../../configManager/configManager"; +import { VisualBuilder } from "../../../index"; +import { mockFieldMetadata } from "./fieldLabelWrapper.mocks"; + +const testFieldSchemaCache: Record> = {}; + +vi.mock("../Tooltip", () => ({ + ToolbarTooltip: ({ children, data, disabled }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock("../../../utils/fieldSchemaMap", async (importOriginal) => { + const actual = + await importOriginal(); + return { + FieldSchemaMap: { + ...actual.FieldSchemaMap, + getFieldSchema: vi + .fn() + .mockImplementation( + (contentTypeUid: string, fieldPath: string) => { + if (testFieldSchemaCache[contentTypeUid]?.[fieldPath]) { + return Promise.resolve( + testFieldSchemaCache[contentTypeUid][fieldPath] + ); + } + const defaultSchema = { + display_name: "Field 0", + data_type: "text", + field_metadata: { + description: "", + default_value: "", + version: 3, + }, + uid: "test_field", + }; + if (!testFieldSchemaCache[contentTypeUid]) { + testFieldSchemaCache[contentTypeUid] = {}; + } + testFieldSchemaCache[contentTypeUid][fieldPath] = + defaultSchema; + return Promise.resolve(defaultSchema); + } + ), + setFieldSchema: vi + .fn() + .mockImplementation( + ( + contentTypeUid: string, + schemaMap: Record + ) => { + if (!testFieldSchemaCache[contentTypeUid]) { + testFieldSchemaCache[contentTypeUid] = {}; + } + Object.assign( + testFieldSchemaCache[contentTypeUid], + schemaMap + ); + } + ), + hasFieldSchema: vi + .fn() + .mockImplementation( + (contentTypeUid: string, fieldPath: string) => { + return !!testFieldSchemaCache[contentTypeUid]?.[ + fieldPath + ]; + } + ), + clear: vi.fn().mockImplementation(() => { + Object.keys(testFieldSchemaCache).forEach( + (key) => delete testFieldSchemaCache[key] + ); + }), + }, + }; +}); + +vi.mock("../../../utils/visualBuilderPostMessage", () => ({ + default: { + send: vi.fn().mockImplementation((eventName: string, fields: any) => { + if ( + eventName === + VisualBuilderPostMessageEvents.GET_FIELD_DISPLAY_NAMES + ) { + const result: Record = {}; + if (Array.isArray(fields)) { + fields.forEach((field: any) => { + const cslpValue = field?.cslpValue || field?.cslp || ""; + if (!cslpValue) return; + if (cslpValue === "mockFieldCslp") { + result[cslpValue] = "Field 0"; + } else if ( + cslpValue === + "contentTypeUid.entryUid.locale.parentPath1" + ) { + result[cslpValue] = "Field 1"; + } else if ( + cslpValue === + "contentTypeUid.entryUid.locale.parentPath2" + ) { + result[cslpValue] = "Field 2"; + } else if ( + cslpValue === + "contentTypeUid.entryUid.locale.parentPath3" + ) { + result[cslpValue] = "Field 3"; + } else { + result[cslpValue] = cslpValue; + } + }); + } + return Promise.resolve(result); + } else if ( + eventName === + VisualBuilderPostMessageEvents.GET_CONTENT_TYPE_NAME || + eventName === "get-content-type-name" + ) { + return Promise.resolve({ + contentTypeName: "Page CT", + }); + } else if ( + eventName === VisualBuilderPostMessageEvents.REFERENCE_MAP || + eventName === "get-reference-map" + ) { + return Promise.resolve({}); + } + return Promise.resolve({}); + }), + }, +})); + +vi.mock("../../../cslp", () => ({ + extractDetailsFromCslp: vi.fn().mockImplementation((path) => { + return { + content_type_uid: "mockContentTypeUid", + fieldPath: path, + cslpValue: path, + }; + }), +})); + +vi.mock("../../../utils/fetchEntryPermissionsAndStageDetails", () => ({ + fetchEntryPermissionsAndStageDetails: vi.fn(), +})); + +vi.mock("../generators/generateCustomCursor", () => ({ + getFieldIcon: vi.fn().mockReturnValue("mock-icon"), + FieldTypeIconsMap: { + reference: "reference-icon", + }, +})); + +vi.mock("../../../visualBuilder.style", async () => { + const { mockStyles } = await import("./fieldLabelWrapper.mocks"); + return { + visualBuilderStyles: vi.fn(() => mockStyles), + }; +}); + +vi.mock("../../VariantIndicator", () => ({ + VariantIndicator: () =>
Variant
, +})); + +vi.mock("../../../utils/errorHandling", () => ({ + hasPostMessageError: vi.fn().mockReturnValue(false), +})); + +const workflowRequestEditAccessResponse = { + acl: { + update: true, + create: true, + read: true, + delete: true, + publish: true, + }, + workflowStage: { + stage: { name: WORKFLOW_STAGES.REVIEW }, + permissions: { + entry: { + update: false, + }, + }, + requestEditAccess: { canRequest: true, hasPending: false }, + }, + resolvedVariantPermissions: { + update: true, + }, +}; + +describe("FieldLabelWrapperComponent — workflow request edit access", () => { + const mockEventDetails: VisualBuilderCslpEventDetails = { + editableElement: document.createElement("div"), + cslpData: "", + fieldMetadata: mockFieldMetadata, + }; + + const mockGetParentEditable = () => document.createElement("div"); + + beforeEach(() => { + vi.clearAllMocks(); + + Config.get = () => + ({ + stackDetails: { masterLocale: "en-us" }, + editButton: { position: "bottom-right" }, + }) as ReturnType; + + VisualBuilder.VisualBuilderGlobalState = { + value: { + locale: "en-us", + variant: undefined, + audienceMode: false, + }, + } as typeof VisualBuilder.VisualBuilderGlobalState; + + vi.mocked(fetchEntryPermissionsAndStageDetails).mockResolvedValue( + workflowRequestEditAccessResponse + ); + + vi.mocked(visualBuilderPostMessage!.send).mockImplementation( + (eventName: string, fields: any) => { + if ( + eventName === + VisualBuilderPostMessageEvents.GET_FIELD_DISPLAY_NAMES + ) { + const result: Record = {}; + if (Array.isArray(fields)) { + fields.forEach((field: any) => { + const cslpValue = + field?.cslpValue || field?.cslp || ""; + if (!cslpValue) return; + if (cslpValue === "mockFieldCslp") { + result[cslpValue] = "Field 0"; + } else if ( + cslpValue === + "contentTypeUid.entryUid.locale.parentPath1" + ) { + result[cslpValue] = "Field 1"; + } else if ( + cslpValue === + "contentTypeUid.entryUid.locale.parentPath2" + ) { + result[cslpValue] = "Field 2"; + } else if ( + cslpValue === + "contentTypeUid.entryUid.locale.parentPath3" + ) { + result[cslpValue] = "Field 3"; + } else { + result[cslpValue] = cslpValue; + } + }); + } + return Promise.resolve(result); + } else if ( + eventName === + VisualBuilderPostMessageEvents.GET_CONTENT_TYPE_NAME || + eventName === "get-content-type-name" + ) { + return Promise.resolve({ + contentTypeName: "Page CT", + }); + } else if ( + eventName === + VisualBuilderPostMessageEvents.REFERENCE_MAP || + eventName === "get-reference-map" + ) { + return Promise.resolve({}); + } + return Promise.resolve({}); + } + ); + + FieldSchemaMap.setFieldSchema(mockFieldMetadata.content_type_uid, { + [mockFieldMetadata.fieldPath]: singleLineFieldSchema, + }); + }); + + afterEach(() => { + FieldSchemaMap.clear(); + document.body.innerHTML = ""; + }); + + it("renders Request Edit Access and workflow tooltip class when canRequest", async () => { + const { container } = render( + + ); + + await act(async () => { + await new Promise((resolve) => + queueMicrotask(() => resolve()) + ); + }); + + await findByTestId( + container as HTMLElement, + "visual-builder__focused-toolbar__field-label-wrapper", + {}, + { timeout: 10000 } + ); + + await waitFor( + () => { + expect(screen.getByText("Request Edit Access")).toBeTruthy(); + }, + { timeout: 10000 } + ); + + expect( + container.querySelector( + ".visual-builder__custom-tooltip--workflow-access" + ) + ).toBeTruthy(); + }); + + it("renders pending copy when hasPending", async () => { + vi.mocked(fetchEntryPermissionsAndStageDetails).mockResolvedValue({ + ...workflowRequestEditAccessResponse, + workflowStage: { + stage: { name: WORKFLOW_STAGES.REVIEW }, + permissions: { entry: { update: false } }, + requestEditAccess: { + canRequest: false, + hasPending: true, + }, + }, + }); + + const { container } = render( + + ); + + await act(async () => { + await new Promise((resolve) => + queueMicrotask(() => resolve()) + ); + }); + + await findByTestId( + container as HTMLElement, + "visual-builder__focused-toolbar__field-label-wrapper", + {}, + { timeout: 10000 } + ); + + await waitFor( + () => { + expect( + screen.getByText(/awaiting approval/i) + ).toBeTruthy(); + }, + { timeout: 10000 } + ); + + expect( + container.querySelector( + ".visual-builder__custom-tooltip--workflow-access" + ) + ).toBeTruthy(); + }); + + it("sends OPEN_REQUEST_EDIT_ACCESS when Request Edit Access is clicked", async () => { + render( + + ); + + await waitFor( + () => { + expect(screen.getByText("Request Edit Access")).toBeTruthy(); + }, + { timeout: 10000 } + ); + + fireEvent.click(screen.getByText("Request Edit Access")); + + expect(visualBuilderPostMessage!.send).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.OPEN_REQUEST_EDIT_ACCESS, + { + entryUid: mockFieldMetadata.entry_uid, + contentTypeUid: mockFieldMetadata.content_type_uid, + locale: mockFieldMetadata.locale, + variantUid: mockFieldMetadata.variant, + } + ); + }); +}); diff --git a/src/visualBuilder/components/fieldLabelWrapper.tsx b/src/visualBuilder/components/fieldLabelWrapper.tsx index af2ed986..b92a2834 100644 --- a/src/visualBuilder/components/fieldLabelWrapper.tsx +++ b/src/visualBuilder/components/fieldLabelWrapper.tsx @@ -1,5 +1,5 @@ import classNames from "classnames"; -import React, { useEffect, useState } from "preact/compat"; +import React, { useEffect, useRef, useState } from "preact/compat"; import { extractDetailsFromCslp, isValidCslp } from "../../cslp"; import { CslpData } from "../../cslp/types/cslp.types"; import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types"; @@ -79,6 +79,116 @@ interface ICurrentField { parentContentTypeName: string; } +/** Space needed above the icon for the default (above) tooltip before flipping below. */ +const TOOLTIP_VIEWPORT_TOP_CLEARANCE_PX = 148; + +interface FieldLabelDisabledIconProps { + reason: string; + workflowRequestUi?: "request" | "pending"; + usePlainDataTooltip: boolean; + onLinkVariant: () => void; + onRequestEditAccess: () => void | Promise; +} + +function FieldLabelDisabledIcon( + props: FieldLabelDisabledIconProps +): JSX.Element { + const { + reason, + workflowRequestUi, + usePlainDataTooltip, + onLinkVariant, + onRequestEditAccess, + } = props; + const wrapRef = useRef(null); + const [showTooltipBelow, setShowTooltipBelow] = useState(false); + + const updateTooltipPlacement = () => { + const el = wrapRef.current; + if (!el) return; + const { top } = el.getBoundingClientRect(); + setShowTooltipBelow(top < TOOLTIP_VIEWPORT_TOP_CLEARANCE_PX); + }; + + const customTooltipClass = classNames( + visualBuilderStyles()["visual-builder__custom-tooltip"], + showTooltipBelow && + visualBuilderStyles()["visual-builder__custom-tooltip--below"] + ); + + const workflowAccessTooltipClass = classNames( + visualBuilderStyles()["visual-builder__custom-tooltip"], + showTooltipBelow && + visualBuilderStyles()["visual-builder__custom-tooltip--below"], + visualBuilderStyles()[ + "visual-builder__custom-tooltip--workflow-access" + ] + ); + + return ( +
+ {reason?.includes(DisableReason.CanLinkVariant) ? ( +
+ {(() => { + const [before, after] = reason.split( + DisableReason.UnderlinedAndClickableWord + ); + return ( + <> + {before} + + {DisableReason.UnderlinedAndClickableWord} + + {after} + + ); + })()} +
+ ) : null} + {workflowRequestUi === "request" && reason ? ( +
+ {reason}{" "} + { + e.stopPropagation(); + onRequestEditAccess(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRequestEditAccess(); + } + }} + > + Request Edit Access + +
+ ) : null} + {workflowRequestUi === "pending" && reason ? ( +
{reason}
+ ) : null} + +
+ ); +} + function FieldLabelWrapperComponent( props: FieldLabelWrapperProps ): JSX.Element { @@ -171,13 +281,36 @@ function FieldLabelWrapperComponent( variantUid: props.fieldMetadata.variant, fieldPathWithIndex: props.fieldMetadata.fieldPathWithIndex, }); - const { isDisabled: fieldDisabled, reason } = isFieldDisabled( + const { + isDisabled: fieldDisabled, + reason, + workflowRequestUi, + } = isFieldDisabled( fieldSchema, eventDetails, resolvedVariantPermissions, entryAcl, entryWorkflowStageDetails, ); + const handleRequestEditAccess = async () => { + try { + await visualBuilderPostMessage?.send( + VisualBuilderPostMessageEvents.OPEN_REQUEST_EDIT_ACCESS, + { + entryUid: props.fieldMetadata.entry_uid, + contentTypeUid: + props.fieldMetadata.content_type_uid, + locale: props.fieldMetadata.locale, + variantUid: props.fieldMetadata.variant, + } + ); + } catch (error) { + console.error( + "Error opening request edit access flow:", + error + ); + } + }; const handleLinkVariant = async () => { try { @@ -218,42 +351,24 @@ function FieldLabelWrapperComponent( const hasParentPaths = !!props?.parentPaths?.length; const isVariant = props.fieldMetadata.variant ? true : false; + const usePlainDataTooltip = + reason && + !reason.includes(DisableReason.CanLinkVariant) && + workflowRequestUi == null; + setCurrentField({ text: currentFieldDisplayName, contentTypeName: contentTypeName ?? "", icon: fieldDisabled ? ( -
- {reason - .includes(DisableReason.CanLinkVariant) && ( -
- {(() => { - const [before, after] = reason.split( - DisableReason.UnderlinedAndClickableWord - ); - return ( - <> - {before} - {DisableReason.UnderlinedAndClickableWord} - {after} - - ); - })()} -
- )} - -
+ ) : hasParentPaths ? ( ) : ( diff --git a/src/visualBuilder/utils/__test__/getWorkflowStageDetails.test.ts b/src/visualBuilder/utils/__test__/getWorkflowStageDetails.test.ts new file mode 100644 index 00000000..b14dbc29 --- /dev/null +++ b/src/visualBuilder/utils/__test__/getWorkflowStageDetails.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getWorkflowStageDetails } from "../getWorkflowStageDetails"; +import { VisualBuilderPostMessageEvents } from "../types/postMessage.types"; + +vi.mock("../visualBuilderPostMessage", () => ({ + default: { + send: vi.fn(), + }, +})); + +import visualBuilderPostMessage from "../visualBuilderPostMessage"; + +describe("getWorkflowStageDetails", () => { + beforeEach(() => { + vi.mocked(visualBuilderPostMessage!.send).mockReset(); + }); + + it("returns payload from postMessage when present", async () => { + const payload = { + stage: { name: "Draft" }, + permissions: { entry: { update: true } }, + requestEditAccess: { canRequest: true, hasPending: false }, + }; + vi.mocked(visualBuilderPostMessage!.send).mockResolvedValue(payload); + + const result = await getWorkflowStageDetails({ + entryUid: "e1", + contentTypeUid: "ct1", + locale: "en-us", + variantUid: "v1", + }); + + expect(visualBuilderPostMessage!.send).toHaveBeenCalledWith( + VisualBuilderPostMessageEvents.GET_WORKFLOW_STAGE_DETAILS, + { + entryUid: "e1", + contentTypeUid: "ct1", + locale: "en-us", + variantUid: "v1", + } + ); + expect(result).toEqual(payload); + }); + + it("returns permissive fallback when send returns undefined", async () => { + vi.mocked(visualBuilderPostMessage!.send).mockResolvedValue(undefined); + + const result = await getWorkflowStageDetails({ + entryUid: "e1", + contentTypeUid: "ct1", + locale: "en-us", + }); + + expect(result).toEqual({ + stage: { name: "Unknown" }, + permissions: { entry: { update: true } }, + requestEditAccess: { canRequest: false, hasPending: false }, + }); + }); + + it("returns permissive fallback when send throws", async () => { + vi.mocked(visualBuilderPostMessage!.send).mockRejectedValue( + new Error("network") + ); + + const result = await getWorkflowStageDetails({ + entryUid: "e1", + contentTypeUid: "ct1", + locale: "en-us", + }); + + expect(result.stage?.name).toBe("Unknown"); + expect(result.permissions.entry.update).toBe(true); + expect(result.requestEditAccess).toEqual({ + canRequest: false, + hasPending: false, + }); + }); +}); diff --git a/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts b/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts index dee18c8a..96884839 100644 --- a/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts +++ b/src/visualBuilder/utils/__test__/isFieldDisabled.test.ts @@ -11,7 +11,6 @@ import { ResolvedVariantPermissions } from "../getResolvedVariantPermissions"; const resolvedVariantPermissions: ResolvedVariantPermissions = { update: true, }; -import { WORKFLOW_STAGES } from "../constants"; describe("isFieldDisabled", () => { it("should return disabled state due to read-only role", () => { @@ -563,5 +562,146 @@ describe("isFieldDisabled", () => { expect(result.isDisabled).toBe(false); expect(result.reason).toBe(DisableReason.None); }); + + it("should return request-edit workflow message when workflow denies update and requestEditAccess.canRequest", () => { + 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, + }, + }, + requestEditAccess: { + canRequest: true, + hasPending: false, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + resolvedVariantPermissions, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + expect(result.reason).toBe( + DisableReason.WorkflowStageRequestEdit({ + stageName: WORKFLOW_STAGES.REVIEW, + }) + ); + expect(result.workflowRequestUi).toBe("request"); + }); + + it("should return pending workflow message when requestEditAccess.hasPending", () => { + 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, + }, + }, + requestEditAccess: { + canRequest: false, + hasPending: true, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + resolvedVariantPermissions, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + expect(result.reason).toBe( + DisableReason.WorkflowStageRequestPending({ + stageName: WORKFLOW_STAGES.REVIEW, + }) + ); + expect(result.workflowRequestUi).toBe("pending"); + }); + + it("should fall back to workflow stage message when requestEditAccess is locked for all", () => { + 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, + }, + }, + requestEditAccess: { + canRequest: false, + hasPending: false, + }, + }; + + const result = isFieldDisabled( + fieldSchemaMap, + eventFieldDetails, + resolvedVariantPermissions, + entryPermissions, + workflowStageDetails + ); + expect(result.isDisabled).toBe(true); + expect(result.reason).toBe( + DisableReason.WorkflowStagePermission({ + stageName: WORKFLOW_STAGES.REVIEW, + }) + ); + expect(result.workflowRequestUi).toBeUndefined(); + }); }); }); diff --git a/src/visualBuilder/utils/getWorkflowStageDetails.ts b/src/visualBuilder/utils/getWorkflowStageDetails.ts index 98b63ac9..2bc744bb 100644 --- a/src/visualBuilder/utils/getWorkflowStageDetails.ts +++ b/src/visualBuilder/utils/getWorkflowStageDetails.ts @@ -42,9 +42,19 @@ export async function getWorkflowStageDetails({ update: true, }, }, + requestEditAccess: { + canRequest: false, + hasPending: false, + }, }; } +/** Mirrors visual-editor GET_WORKFLOW_STAGE_DETAILS payload (QuickForm / canvas alignment). */ +export interface WorkflowStageRequestEditAccess { + canRequest: boolean; + hasPending: boolean; +} + export interface WorkflowStageDetails { stage: | { @@ -56,4 +66,6 @@ export interface WorkflowStageDetails { update: boolean; }; }; + /** Present when returned by visual-editor; omitted in legacy SDK-only fallbacks. */ + requestEditAccess?: WorkflowStageRequestEditAccess; } diff --git a/src/visualBuilder/utils/isFieldDisabled.ts b/src/visualBuilder/utils/isFieldDisabled.ts index 86c61f27..c2683ecd 100644 --- a/src/visualBuilder/utils/isFieldDisabled.ts +++ b/src/visualBuilder/utils/isFieldDisabled.ts @@ -30,16 +30,26 @@ export const DisableReason = { stageName: string; }) => `Editing is restricted for your role or by the rules for the '${stageName}' stage. Contact your admin for edit access.`, + WorkflowStageRequestEdit: ({ stageName }: { stageName: string }) => + `You do not have the edit access to this entry on the '${stageName}' workflow stage.`, + WorkflowStageRequestPending: ({ stageName }: { stageName: string }) => + `You do not have the edit access to this entry on the '${stageName}' workflow stage. Your request has been sent and is awaiting approval.`, } as const; -interface FieldDisableState { +export interface FieldDisableState { isDisabled: boolean; reason: string; + /** Canvas: workflow stage lock with request-edit UX (see fieldLabelWrapper). */ + workflowRequestUi?: "request" | "pending"; } const getDisableReason = ( flags: Record, - params?: Record + params?: { + stageName?: string; + entryWorkflowStageDetails?: WorkflowStageDetails; + entryPermissions?: EntryPermissions; + }, ) => { if (flags.updateRestrictDueToRole) return DisableReason.ReadOnly; if (flags.updateRestrictDueToNonLocalizableFields) @@ -67,8 +77,29 @@ const getDisableReason = ( return DisableReason.EntryUpdateRestricted; } if (flags.updateRestrictDueToWorkflowStagePermission) { + const stageName = params?.stageName ? params.stageName : "Unknown"; + const req = params?.entryWorkflowStageDetails?.requestEditAccess; + const entryAllowsUpdate = + params?.entryPermissions == null || + params.entryPermissions.update === true; + if ( + entryAllowsUpdate && + !flags.updateRestrictDueToEntryUpdateRestriction && + req + ) { + if (req.hasPending) { + return DisableReason.WorkflowStageRequestPending({ + stageName, + }); + } + if (req.canRequest) { + return DisableReason.WorkflowStageRequestEdit({ + stageName, + }); + } + } return DisableReason.WorkflowStagePermission({ - stageName: params?.stageName ? params.stageName : "Unknown", + stageName, }); } if(flags.updateRestrictDueToResolvedVariantPermissions) { @@ -148,7 +179,24 @@ export const isFieldDisabled = ( const isDisabled = Object.values(flags).some(Boolean); const reason = getDisableReason(flags, { stageName: entryWorkflowStageDetails?.stage?.name, + entryWorkflowStageDetails, + entryPermissions, }); - return { isDisabled, reason }; + let workflowRequestUi: "request" | "pending" | undefined; + if ( + flags.updateRestrictDueToWorkflowStagePermission && + !flags.updateRestrictDueToEntryUpdateRestriction && + (entryPermissions == null || entryPermissions.update === true) && + entryWorkflowStageDetails?.requestEditAccess + ) { + const req = entryWorkflowStageDetails.requestEditAccess; + if (req.hasPending) { + workflowRequestUi = "pending"; + } else if (req.canRequest) { + workflowRequestUi = "request"; + } + } + + return { isDisabled, reason, workflowRequestUi }; }; diff --git a/src/visualBuilder/utils/types/postMessage.types.ts b/src/visualBuilder/utils/types/postMessage.types.ts index 54bda7d0..e26e7df2 100644 --- a/src/visualBuilder/utils/types/postMessage.types.ts +++ b/src/visualBuilder/utils/types/postMessage.types.ts @@ -32,6 +32,7 @@ export enum VisualBuilderPostMessageEvents { GET_PERMISSIONS = "get-permissions", GET_WORKFLOW_STAGE_DETAILS = "get-workflow-stage-details", GET_RESOLVED_VARIANT_PERMISSIONS = "get-resolved-variant-permissions", + OPEN_REQUEST_EDIT_ACCESS = "open-request-edit-access", // FROM visual builder GET_ALL_ENTRIES_IN_CURRENT_PAGE = "get-entries-in-current-page", diff --git a/src/visualBuilder/visualBuilder.style.ts b/src/visualBuilder/visualBuilder.style.ts index bd6c9e11..96a5ce7f 100644 --- a/src/visualBuilder/visualBuilder.style.ts +++ b/src/visualBuilder/visualBuilder.style.ts @@ -587,6 +587,22 @@ export function visualBuilderStyles() { display: none; } `, + /** When the field label is near the top of the viewport, show the tooltip below the icon. */ + "visual-builder__tooltip--persistent--below": css` + &:before { + bottom: -66px; + margin-bottom: 0; + margin-top: 0; + top: auto; + } + + &:after { + bottom: -13px; + margin-top: 0; + top: auto; + transform: rotate(180deg); + } + `, "visual-builder__custom-tooltip": css` position: absolute; bottom: 20px; @@ -613,6 +629,20 @@ export function visualBuilderStyles() { border-color: #767676 transparent transparent transparent; } `, + "visual-builder__custom-tooltip--below": css` + bottom: auto; + top: 100%; + margin-bottom: 0; + margin-top: 8px; + + &:after { + content: none; + } + `, + /** Wider cap for workflow request / pending copy — must follow base `.visual-builder__custom-tooltip` so max-width wins over 200px. */ + "visual-builder__custom-tooltip--workflow-access": css` + max-width: 325px; + `, "visual-builder__empty-block": css` width: 100%; height: 100%;