diff --git a/packages/visual-editor/THIRD-PARTY-NOTICES b/packages/visual-editor/THIRD-PARTY-NOTICES index a53516a52..fecdfc96a 100644 --- a/packages/visual-editor/THIRD-PARTY-NOTICES +++ b/packages/visual-editor/THIRD-PARTY-NOTICES @@ -491,7 +491,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. The following npm packages may be included in this product: - - @puckeditor/core@0.22.2 + - @puckeditor/core@0.24.0-canary.c1ec9773 - next-themes@0.3.0 These packages each contain the following license: diff --git a/packages/visual-editor/package.json b/packages/visual-editor/package.json index f55d448f2..ee7b7a4e2 100644 --- a/packages/visual-editor/package.json +++ b/packages/visual-editor/package.json @@ -74,7 +74,7 @@ }, "dependencies": { "@mapbox/maki": "^8.2.0", - "@puckeditor/core": "0.22.2", + "@puckeditor/core": "0.24.0-canary.c1ec9773", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.13", diff --git a/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx b/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx index c5b869cce..6a66d33ee 100644 --- a/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx +++ b/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx @@ -1,25 +1,21 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { useDocument } from "../../hooks/useDocument.tsx"; +import { resolveComprehensiveCTAValue } from "../../fields/fieldTransforms/index.ts"; import { type CTAProps, CTA } from "../atoms/cta.tsx"; import { themeManagerCn } from "../../utils/cn.ts"; -import { useDocument } from "../../hooks/useDocument.tsx"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; -import { getCTAType } from "../../internal/utils/ctaFieldUtils.ts"; import { type ComprehensiveCTAValue, defaultButtonStyleValue, defaultLinkStyleValue, - normalizeComprehensiveCTAValue, } from "../../fields/styledFields/ComprehensiveCTAField.tsx"; -import { - FOOD_DELIVERY_SERVICES, - type EnhancedTranslatableCTA, -} from "../../types/types.ts"; +import { FOOD_DELIVERY_SERVICES } from "../../types/types.ts"; +import type { ResolvedComprehensiveCTAValue } from "../../fields/fields.ts"; import { type StyledButtonValue } from "../../fields/styledFields/StyledButtonField.tsx"; import { type StyledLinkValue } from "../../fields/styledFields/StyledLinkField.tsx"; export type ComprehensiveCTARenderProps = { - value?: Partial; + value?: Partial | ResolvedComprehensiveCTAValue; label?: React.ReactNode; ariaLabel?: string; className?: string; @@ -34,12 +30,12 @@ const resolveTextStyleValue = (value: string | undefined) => value && value !== "default" ? value : undefined; const getComprehensiveCTAStyle = ( - value: ComprehensiveCTAValue + value: ResolvedComprehensiveCTAValue ): React.CSSProperties | undefined => { const ctaType = value.data.actionType === "button" ? "textAndLink" - : getCTAType(value.data.cta).ctaType; + : value.data.cta?.ctaType; if (ctaType === "presetImage") { return value.sx; @@ -123,6 +119,18 @@ const toDataAttributes = ( ); }; +/** + * Supports both authored props from fields without `transform: true` and resolved + * props from fields with transforms enabled. Both paths retain styles, + * accessibility, and interaction behavior. + * + * 1. Temporarily resolve authored bindings and translated text through the same + * resolver used by field transforms, including defaults for partial values. + * 2. Use transformed values directly without resolving or interpolating them again. + * 3. Render the resolved data using the shared CTA presentation. + * + * Remove the authored-value compatibility path once all libraries use transforms. + */ export const ComprehensiveCTA = ({ value, label, @@ -135,39 +143,29 @@ export const ComprehensiveCTA = ({ onClick, }: ComprehensiveCTARenderProps) => { const streamDocument = useDocument(); - const { t, i18n } = useTranslation(); - const locale = i18n.language; - const currentValue = normalizeComprehensiveCTAValue(value); - + const { i18n } = useTranslation(); + // Authored values retain field/constant bindings or localized text objects. + // Partial authored values also need defaults before they can be rendered. + const currentValue = + !value?.data || + !value.styles || + (value.data.cta && + ("field" in value.data.cta || "constantValue" in value.data.cta)) || + typeof value.data.buttonText === "object" || + typeof value.data.ariaLabel === "object" + ? // Without transforms, resolve the authored value using the page and locale. + resolveComprehensiveCTAValue(value as Partial, { + streamDocument, + locale: i18n.language, + }) + : // With transforms, the value already contains resolved data; keep it as is. + (value as ResolvedComprehensiveCTAValue); const actionType = currentValue.data.actionType; - const { ctaType } = - actionType === "link" - ? getCTAType(currentValue.data.cta) - : { ctaType: "textAndLink" as const }; - const resolvedCta = - actionType === "link" - ? (resolveComponentData(currentValue.data.cta, locale, streamDocument) as - EnhancedTranslatableCTA | undefined) - : undefined; - - const resolvedButtonLabel = currentValue.data.buttonText - ? resolveComponentData(currentValue.data.buttonText, locale, streamDocument) - : ""; - const resolvedFieldAriaLabel = currentValue.data.ariaLabel - ? resolveComponentData(currentValue.data.ariaLabel, locale, streamDocument) - : ""; - - let resolvedLinkLabel = - resolvedCta && - resolveComponentData(resolvedCta.label, locale, streamDocument); - - if ( - actionType === "link" && - !currentValue.data.cta.constantValueEnabled && - ctaType === "getDirections" - ) { - resolvedLinkLabel = t("getDirections", "Get Directions"); - } + const ctaType = + actionType === "button" ? "textAndLink" : currentValue.data.cta?.ctaType; + const resolvedCta = currentValue.data.cta; + const resolvedButtonLabel = currentValue.data.buttonText; + const resolvedLinkLabel = resolvedCta?.label; const effectiveLabel = label !== undefined @@ -211,7 +209,7 @@ export const ComprehensiveCTA = ({ const resolvedAriaLabel = ariaLabel ?? (actionType === "button" - ? resolvedFieldAriaLabel || undefined + ? currentValue.data.ariaLabel || undefined : typeof effectiveLabel === "string" ? effectiveLabel : undefined); @@ -223,7 +221,8 @@ export const ComprehensiveCTA = ({ alwaysHideCaret={alwaysHideCaret} className={resolvedClassName} color={currentValue.styles.color} - ctaType={ctaType} + // Directions are already resolved and use the ordinary link presentation. + ctaType={ctaType === "presetImage" ? "presetImage" : "textAndLink"} dataAttributes={ actionType === "button" ? toDataAttributes(currentValue.data.dataAttributes) @@ -232,11 +231,7 @@ export const ComprehensiveCTA = ({ eventName={eventName ?? currentValue.eventName} id={actionType === "button" ? currentValue.data.customId : undefined} label={effectiveLabel} - link={ - actionType === "link" && ctaType !== "getDirections" && resolvedCta - ? resolveComponentData(resolvedCta.link, locale, streamDocument) - : undefined - } + link={actionType === "link" && resolvedCta ? resolvedCta.link : undefined} linkType={ actionType === "link" && resolvedCta ? resolvedCta.linkType : undefined } diff --git a/packages/visual-editor/src/editor/README.md b/packages/visual-editor/src/editor/README.md index 1846578be..601db8b6d 100644 --- a/packages/visual-editor/src/editor/README.md +++ b/packages/visual-editor/src/editor/README.md @@ -125,6 +125,84 @@ const Example = ({ myField }: ExampleProps) => { }; ``` +## Opt-in field transforms + +Add `transform: true` to the props of a supported field type and the transform behavior will be enabled, making props come through +resolved in the render function. The following field types are supported for transforms and the behavior is disabled by default. + +| Field definition | Render value | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `translatableString` | Localized, interpolated string | +| Ordinary `entityField` | Resolved entity or constant value with nested localization and interpolation | +| `entityField` including `type.rich_text_v2` | Resolved rich-text object or plain string | +| Rich-text list with `includeListsOnly: true` | Array of resolved rich-text objects or strings | +| `image` | Localized asset image with resolved alt text and preserved asset metadata | +| `multiSelector` | Array of selected values, preserving zero, false, and empty strings | +| `optionalNumber` | Number or `undefined` when hidden or unset | +| `ctaSelector` | Resolved CTA object with localized label and link; Get Directions includes a generated URL | +| `comprehensiveCTA` | Resolved nested CTA, button text, and aria label; styles and interaction settings are preserved | +| Repeated `entityField` | Array of item props resolved against each selected item or manual item | +| `video` | Asset video data with interpolated text and preserved metadata | + +```` + +Title, description, and image still allow entity bindings and constant values. +The transform returns resolved data, including translated and interpolated rich-text +HTML and JSON. `MaybeRTF` renders that data, with optional `className`, `style`, +`bodyVariant`, and `richTextStyleOverrides`. CTAs keep their existing field and +renderer API. + +Opted-in fields appear in the editor's component-level entity tooltip when +entity tooltips are enabled. The tooltip reads authored bindings and does not +add source metadata to rendered props or require `EntityField` wrappers. Fields +without `transform: true` retain their existing tooltip behavior. + +### Adding field transforms + +`fields/fieldTransforms/createYextFieldTransforms.ts` registers the transforms, +while `fields/fieldTransforms/fieldToTransform.ts` dispatches by authored field +type. Puck registration and adapted `custom` fields use that same map. +Each handler receives the authored definition, its value, and the page document +and locale. `resolveValue.ts` handles localization and interpolation across +objects and arrays while retaining rich-text and asset structures. `cta.ts` +contains CTA-specific resolution. + +To support another field, add its `transform?: boolean` option, a handler in +`fieldToTransform`, and its output type in `TransformedFieldValues` in `fields.ts`. +The handler map requires an entry for each transformable field definition. +Keep field-specific source selection in its handler and return resolved data. +For composite bindings, also describe their authored sources in the component +tooltip. Defaults, editor fields, and saved data keep their authored shapes. + +Ordinary entity lists, repeated item sources, and nested Puck arrays support +transforms. For repeated sources, opt in on the parent field; its mappings are +resolved against each selected linked item, including translations and embedded +fields. Manual items resolve against the page document. Missing lists return `[]`. + +```tsx +const fields = { + cards: { ...cardSource.field, transform: true }, + primaryCta: { type: "comprehensiveCTA", transform: true }, + video: { type: "video", transform: true }, +} satisfies YextFieldMap; + +// In the component's render function: + + +{cards.map((card, index) => ( +
+

{card.title}

+ +
+))} +```` + +The library no longer calls `resolveItems` or performs text resolution in its +render function. Video asset structure and comprehensive CTA styles are retained. + ## Linked Entity Item Sources Use `createItemSource(...)` when a component needs to render repeated content diff --git a/packages/visual-editor/src/editor/VisualEditorRender.test.tsx b/packages/visual-editor/src/editor/VisualEditorRender.test.tsx new file mode 100644 index 000000000..2d979fcfa --- /dev/null +++ b/packages/visual-editor/src/editor/VisualEditorRender.test.tsx @@ -0,0 +1,276 @@ +import React from "react"; +import { Puck, type Config, type Data } from "@puckeditor/core"; +import { Render as ServerRender } from "@puckeditor/core/rsc"; +import { render, screen, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorProvider } from "../contexts/ErrorContext.tsx"; +import { TemplatePropsContext } from "../hooks/useDocument.tsx"; +import { createItemSource } from "../utils/itemSource/createItemSource.ts"; +import { ComprehensiveCTA } from "../components/helpers/ComprehensiveCTA.tsx"; +import { MaybeRTF } from "../components/helpers/maybeRTF.tsx"; +import { createYextFieldTransforms } from "../fields/fieldTransforms/index.ts"; +import { toPuckFields } from "../fields/fields.ts"; +import { VisualEditorRender } from "./VisualEditorRender.tsx"; + +vi.mock("react-i18next", async () => ({ + ...(await vi.importActual("react-i18next")), + useTranslation: () => ({ + i18n: { language: "es" }, + t: (_key: string, defaultValue: string): string => defaultValue, + }), +})); + +beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("native Puck field transforms", () => { + it.each(["live page", "server render", "editor preview"])( + "when fields opt in then the %s receives resolved props and keeps authored data", + async (mode) => { + const cardsSource = createItemSource({ + label: "Cards", + mappingFields: { + title: { type: "entityField", filter: { types: ["type.string"] } }, + }, + }); + const data: Data = { + root: { props: {} }, + content: [ + { + type: "Hero", + props: { + id: "hero", + title: { field: "name", constantValue: "" }, + description: { field: "description", constantValue: "" }, + image: { field: "photo", constantValue: {} }, + asset: { + defaultValue: { + url: "/asset.jpg", + alternateText: { defaultValue: "Asset", es: "Foto [[name]]" }, + }, + }, + categories: { + selections: [ + { value: "menu" }, + { value: 0 }, + { value: undefined }, + ], + }, + seats: 0, + video: { + id: "asset", + name: "Video", + video: { + id: "abc", + title: "Video [[name]]", + embeddedUrl: "/embed", + thumbnail: "/thumb.jpg", + url: "/watch", + duration: "PT30S", + }, + }, + cards: { + field: "articles", + constantValue: [], + mappings: { title: { field: "name", constantValue: "" } }, + }, + action: { + data: { + actionType: "link", + cta: { + field: "orderCta", + constantValueEnabled: false, + constantValue: undefined, + }, + openInNewTab: true, + }, + }, + primaryCta: { + field: "orderCta", + selectedType: "textAndLink", + constantValue: { label: "", link: "" }, + }, + nested: { + label: { defaultValue: "Welcome", es: "Hola [[name]]" }, + }, + rows: [{ label: { defaultValue: "List", es: "Fila" } }], + untouched: { field: "name", constantValue: "Original" }, + }, + }, + ], + }; + const authoredData = structuredClone(data); + const config: Config = { + components: { + Hero: { + fields: toPuckFields({ + title: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + description: { + type: "entityField", + transform: true, + filter: { types: ["type.rich_text_v2"] }, + }, + image: { + type: "entityField", + transform: true, + filter: { types: ["type.image"] }, + }, + asset: { type: "image", transform: true }, + categories: { + type: "multiSelector", + transform: true, + label: "Categories", + dropdownLabel: "Category", + options: [], + }, + seats: { + type: "optionalNumber", + transform: true, + showNumberFieldRadioLabel: "Show", + hideNumberFieldRadioLabel: "Hide", + defaultCustomValue: 1, + }, + primaryCta: { type: "ctaSelector", transform: true }, + action: { type: "comprehensiveCTA", transform: true }, + video: { type: "video", transform: true }, + cards: { ...cardsSource.field, transform: true }, + nested: { + type: "object", + objectFields: { + label: { type: "translatableString", transform: true }, + }, + }, + rows: { + type: "array", + arrayFields: { + label: { type: "translatableString", transform: true }, + }, + }, + untouched: { + type: "entityField", + filter: { types: ["type.string"] }, + }, + }), + render: ({ + title, + description, + image, + nested, + rows, + untouched, + asset, + categories, + seats, + primaryCta, + action, + video, + cards, + }) => ( +
+

{title}

+ + {image.alternateText} + {asset.alternateText} + {categories.join(",")} + {seats} + {primaryCta.label} + + {video.video.title} + {cards[0].title} + {nested.label} + {rows[0].label} + {untouched.constantValue} +
+ ), + }, + }, + }; + const streamDocument = { + locale: "es", + orderCta: { + label: { defaultValue: "Order", es: "Pedir [[name]]" }, + link: { defaultValue: "/en", es: "/es" }, + }, + name: "Restaurant", + articles: [{ name: "Article" }], + description: { html: "

Shared rich text

", json: "{}" }, + photo: { + url: "/hero.jpg", + alternateText: { defaultValue: "Image", es: "Restaurant image" }, + }, + }; + const fieldTransforms = createYextFieldTransforms(streamDocument, "es"); + render( + + + {mode === "live page" ? ( + + ) : mode === "server render" ? ( + + ) : ( + + + + )} + + + ); + // JSDOM does not measure Puck's layout, so query the preview's content directly. + expect((await screen.findByText("Restaurant")).tagName).toBe("H1"); + expect(screen.getByText("Shared rich text")).toBeTruthy(); + expect(screen.getByAltText("Restaurant image").getAttribute("src")).toBe( + "/hero.jpg" + ); + expect(screen.getByAltText("Restaurant image").getAttribute("alt")).toBe( + "Restaurant image" + ); + expect(screen.getByText("Hola Restaurant")).toBeTruthy(); + expect(screen.getByText("Fila")).toBeTruthy(); + expect(screen.getByText("Original")).toBeTruthy(); + expect(screen.getByAltText("Foto Restaurant").getAttribute("src")).toBe( + "/asset.jpg" + ); + expect(screen.getByText("menu,0")).toBeTruthy(); + expect(screen.getByTestId("seats").textContent).toBe("0"); + expect(screen.getByText("Pedir Restaurant").getAttribute("href")).toBe( + "/es" + ); + expect(screen.getByText("Video Restaurant")).toBeTruthy(); + expect(screen.getByText("Article")).toBeTruthy(); + expect( + screen + .getByText("Comprehensive order") + .closest("a") + ?.getAttribute("href") + ).toBe("/es"); + expect(data).toEqual(authoredData); + } + ); +}); diff --git a/packages/visual-editor/src/editor/VisualEditorRender.tsx b/packages/visual-editor/src/editor/VisualEditorRender.tsx index 97d3c625c..3845603f7 100644 --- a/packages/visual-editor/src/editor/VisualEditorRender.tsx +++ b/packages/visual-editor/src/editor/VisualEditorRender.tsx @@ -1,5 +1,8 @@ import { Config, Data, Metadata, Render } from "@puckeditor/core"; import React from "react"; +import { useTranslation } from "react-i18next"; +import { useDocument } from "../hooks/useDocument.tsx"; +import { createYextFieldTransforms } from "../fields/fieldTransforms/index.ts"; import { wrapConfigWithComponentErrorBoundary } from "../internal/utils/wrapConfigWithComponentErrorBoundary.tsx"; export type VisualEditorRenderProps = { @@ -13,9 +16,22 @@ export const VisualEditorRender = ({ data, metadata, }: VisualEditorRenderProps) => { + const streamDocument = useDocument(); + const { i18n } = useTranslation(); + const fieldTransforms = React.useMemo( + () => createYextFieldTransforms(streamDocument, i18n.language), + [streamDocument, i18n.language] + ); const wrappedConfig = React.useMemo(() => { return wrapConfigWithComponentErrorBoundary(config); }, [config]); - return ; + return ( + + config={wrappedConfig} + data={data} + metadata={metadata} + fieldTransforms={fieldTransforms} + /> + ); }; diff --git a/packages/visual-editor/src/fields/CTASelectorField.tsx b/packages/visual-editor/src/fields/CTASelectorField.tsx index 5e7e4d6e7..b91abe309 100644 --- a/packages/visual-editor/src/fields/CTASelectorField.tsx +++ b/packages/visual-editor/src/fields/CTASelectorField.tsx @@ -21,6 +21,8 @@ export type YextCTAField = YextEntityField & { export type CTASelectorField = BaseField & { type: "ctaSelector"; + /** Resolves the CTA source, localized text, and directions URL before rendering. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; disableConstantValueToggle?: boolean; diff --git a/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx b/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx index 636f8d8d6..6901da831 100644 --- a/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx +++ b/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx @@ -70,6 +70,8 @@ export type EntityFieldSelectorField< T extends Record = Record, > = BaseField & { type: "entityField"; + /** Resolves this field before rendering. Repeated sources are not supported. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; filter: MappedSourceFieldFilter; diff --git a/packages/visual-editor/src/fields/ImageField.tsx b/packages/visual-editor/src/fields/ImageField.tsx index 63fbc89fe..c071b6b2d 100644 --- a/packages/visual-editor/src/fields/ImageField.tsx +++ b/packages/visual-editor/src/fields/ImageField.tsx @@ -39,6 +39,8 @@ let pendingImageSession: export type ImageField = BaseField & { type: "image"; + /** Localizes the asset image and resolves its alt text before rendering. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; hideAltTextField?: boolean; diff --git a/packages/visual-editor/src/fields/MultiSelectorField.tsx b/packages/visual-editor/src/fields/MultiSelectorField.tsx index 990d211c5..67941c09c 100644 --- a/packages/visual-editor/src/fields/MultiSelectorField.tsx +++ b/packages/visual-editor/src/fields/MultiSelectorField.tsx @@ -52,6 +52,8 @@ export type MultiSelectorOptions = export type MultiSelectorField = BaseField & { type: "multiSelector"; + /** Returns the selected values as an array before rendering. */ + transform?: boolean; label: string | MsgString; dropdownLabel: string | MsgString; options: MultiSelectorOptions; diff --git a/packages/visual-editor/src/fields/OptionalNumberField.tsx b/packages/visual-editor/src/fields/OptionalNumberField.tsx index 52cef8d3b..5f1feae4f 100644 --- a/packages/visual-editor/src/fields/OptionalNumberField.tsx +++ b/packages/visual-editor/src/fields/OptionalNumberField.tsx @@ -14,6 +14,8 @@ type OptionalNumberValue = number | string | null | undefined; export type OptionalNumberField = BaseField & { type: "optionalNumber"; + /** Returns a number or undefined before rendering. */ + transform?: boolean; label?: string | MsgString; hideNumberFieldRadioLabel: string | MsgString; showNumberFieldRadioLabel: string | MsgString; diff --git a/packages/visual-editor/src/fields/TranslatableStringField.tsx b/packages/visual-editor/src/fields/TranslatableStringField.tsx index d9ef08548..382c815f8 100644 --- a/packages/visual-editor/src/fields/TranslatableStringField.tsx +++ b/packages/visual-editor/src/fields/TranslatableStringField.tsx @@ -16,6 +16,8 @@ import { resolveComponentData } from "../utils/resolveComponentData.tsx"; export type TranslatableStringField = BaseField & { type: "translatableString"; + /** Localizes and interpolates this field before rendering. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; filter?: RenderEntityFieldFilter; diff --git a/packages/visual-editor/src/fields/VideoField.tsx b/packages/visual-editor/src/fields/VideoField.tsx index ff7d930a6..140aae771 100644 --- a/packages/visual-editor/src/fields/VideoField.tsx +++ b/packages/visual-editor/src/fields/VideoField.tsx @@ -11,6 +11,8 @@ import { pt, type MsgString } from "../utils/i18n/platform.ts"; export type VideoField = BaseField & { type: "video"; + /** Resolves embedded fields in the video asset before rendering. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; }; diff --git a/packages/visual-editor/src/fields/fieldTransforms.test.tsx b/packages/visual-editor/src/fields/fieldTransforms.test.tsx new file mode 100644 index 000000000..9fe53a849 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms.test.tsx @@ -0,0 +1,1067 @@ +import React from "react"; +import { I18nextProvider } from "react-i18next"; +import { + i18nPageInstance, + VISUAL_EDITOR_NAMESPACE, +} from "../utils/i18n/i18nInstances.ts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { YextCTAField } from "./CTASelectorField.tsx"; +import type { MultiSelectorValue } from "./MultiSelectorField.tsx"; +import type { TranslatableAssetImage } from "../types/images.ts"; +import type { + RichText, + TranslatableRichText, + TranslatableString, +} from "../types/types.ts"; +import type { YextEntityField } from "../editor/YextEntityFieldSelector.tsx"; +import { createYextFieldTransforms } from "./fieldTransforms/index.ts"; +import { ComprehensiveCTA } from "../components/helpers/ComprehensiveCTA.tsx"; +import { TemplatePropsContext } from "../hooks/useDocument.tsx"; +import { createItemSource } from "../utils/itemSource/createItemSource.ts"; +import type { ComprehensiveCTAValue } from "./styledFields/ComprehensiveCTAField.tsx"; +import type { AssetVideo } from "../types/videos.ts"; +import { MaybeRTF } from "../components/helpers/index.ts"; +import { + toPuckFields, + type YextComponentConfig, + type YextFieldMap, + type YextTransformedProps, + type ResolvedComprehensiveCTAValue, +} from "./fields.ts"; + +describe("field transforms", () => { + it.each([ + { + name: "when a KG field is selected then its value is resolved", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { field: "name", constantValue: "Unused" }, + expected: "Restaurant", + }, + { + name: "when a selected constant is unset then its value is undefined", + field: { type: "entityField", transform: true, filter: {} }, + value: { + field: "name", + constantValueEnabled: true, + constantValue: undefined, + }, + expected: undefined, + }, + { + name: "when no entity source is selected then its value is undefined", + field: { type: "entityField", transform: true, filter: {} }, + value: { + field: "", + constantValueEnabled: false, + constantValue: "Unused", + }, + expected: undefined, + }, + { + name: "when a linked field is selected then its value is resolved", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { field: "linked.name", constantValue: "" }, + expected: "Linked Restaurant", + }, + { + name: "when a constant is selected then it is localized and interpolated", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { + field: "name", + constantValueEnabled: true, + constantValue: { defaultValue: "Welcome", es: "Hola [[name]]" }, + }, + expected: "Hola Restaurant", + }, + { + name: "when a string is translatable then its locale is selected", + field: { type: "translatableString", transform: true }, + value: { defaultValue: "Welcome", es: "Hola [[name]]" }, + expected: "Hola Restaurant", + }, + { + name: "when a locale is absent then the default text is used", + field: { type: "translatableString", transform: true }, + value: { defaultValue: "Welcome [[name]]" }, + expected: "Welcome Restaurant", + }, + { + name: "when localized text is empty then it stays empty", + field: { type: "translatableString", transform: true }, + value: { defaultValue: "Welcome", es: "" }, + expected: "", + }, + { + name: "when a field is missing then its value is undefined", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { field: "missing", constantValue: "Unused" }, + expected: undefined, + }, + { + name: "when a numeric field is zero then zero is preserved", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.number"] }, + }, + value: { field: "count", constantValue: 99 }, + expected: 0, + }, + { + name: "when an asset image opts in then its image and alt text are localized", + field: { type: "image", transform: true }, + value: { + hasLocalizedValue: "true", + defaultValue: { url: "/default.jpg", alternateText: "Default" }, + es: { + url: "/es.jpg", + height: 200, + width: 400, + alternateText: { defaultValue: "Image", es: "Foto de [[name]]" }, + assetImage: { + sourceUrl: "/original.jpg", + transformations: { ROTATION: { degree: 90 } }, + }, + }, + }, + expected: { + url: "/es.jpg", + height: 200, + width: 400, + alternateText: "Foto de Restaurant", + assetImage: { + sourceUrl: "/original.jpg", + transformations: { ROTATION: { degree: 90 } }, + }, + }, + }, + { + name: "when a multi-selector opts in then selected values preserve zero and false", + field: { type: "multiSelector", transform: true }, + value: { + selections: [ + { value: "menu" }, + { value: undefined }, + { value: 0 }, + { value: false }, + ], + }, + expected: ["menu", 0, false], + }, + { + name: "when a multi-selector is empty then its resolved list is empty", + field: { type: "multiSelector", transform: true }, + value: undefined, + expected: [], + }, + { + name: "when an optional number is zero then zero is preserved", + field: { type: "optionalNumber", transform: true }, + value: 0, + expected: 0, + }, + { + name: "when an optional number is hidden then its resolved value is undefined", + field: { type: "optionalNumber", transform: true }, + value: "__ve_optionalNumber_hide__", + expected: undefined, + }, + { + name: "when a constant CTA opts in then its label and link are resolved", + field: { type: "ctaSelector", transform: true }, + value: { + constantValueEnabled: true, + constantValue: { + ctaType: "textAndLink", + label: { defaultValue: "Order", es: "Pedir [[name]]" }, + link: { defaultValue: "/en", es: "/es" }, + linkType: "URL", + openInNewTab: true, + }, + }, + expected: { + ctaType: "textAndLink", + label: "Pedir Restaurant", + link: "/es", + linkType: "URL", + openInNewTab: true, + }, + }, + { + name: "when a KG CTA opts in then its source and selected mode are resolved", + field: { type: "ctaSelector", transform: true }, + value: { + field: "orderCta", + selectedType: "presetImage", + constantValue: { label: "Unused", link: "/unused" }, + }, + expected: { + label: "Pedir Restaurant", + link: "/order", + ctaType: "presetImage", + }, + }, + { + name: "when a selected CTA source is missing then its value is undefined", + field: { type: "ctaSelector", transform: true }, + value: { + field: "missing", + constantValue: { label: "Unused", link: "/unused" }, + }, + expected: undefined, + }, + { + name: "when a text list opts in then each item is localized and interpolated", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"], includeListsOnly: true }, + }, + value: { + field: "", + constantValueEnabled: true, + constantValue: [ + { defaultValue: "Dine-in", es: "Mesa en [[name]]" }, + { defaultValue: "Delivery" }, + ], + }, + expected: ["Mesa en Restaurant", "Delivery"], + }, + { + name: "when rich text is localized then resolved HTML and JSON are returned", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.rich_text_v2"] }, + }, + value: { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: { html: "

Welcome

", json: '{"text":"Welcome"}' }, + es: { + html: "

Hola [[name]]

", + json: '{"text":"Hola [[name]]"}', + }, + }, + }, + expected: { + html: "

Hola Restaurant

", + json: '{"text":"Hola Restaurant"}', + }, + }, + { + name: "when a rich-text list is selected then its resolved objects are returned", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.rich_text_v2"], includeListsOnly: true }, + }, + value: { + field: "", + constantValueEnabled: true, + constantValue: [ + { + defaultValue: { html: "

Hello

" }, + es: { html: "

Hola [[name]]

" }, + }, + ], + }, + expected: [{ html: "

Hola Restaurant

" }], + }, + ])("$name", ({ field, value, expected }) => { + const authoredValue = structuredClone(value); + expect( + createYextFieldTransforms( + { + name: "Restaurant", + linked: [{ name: "Linked Restaurant" }], + count: 0, + orderCta: { + label: { defaultValue: "Order", es: "Pedir [[name]]" }, + link: "/order", + }, + }, + "es" + ).custom({ + field, + value, + componentId: "hero", + propName: "title", + propPath: "title", + isReadOnly: false, + }) + ).toEqual(expected); + expect(value).toEqual(authoredValue); + }); + + it("when Get Directions opts in then its URL and translated label are usable without a renderer", () => { + i18nPageInstance.addResourceBundle("es", VISUAL_EDITOR_NAMESPACE, { + getDirections: "Cómo llegar", + }); + const value = { field: "", selectedType: "getDirections" }; + const authoredValue = structuredClone(value); + try { + const resolved = createYextFieldTransforms( + { yextDisplayCoordinate: { latitude: 30.2672, longitude: -97.7431 } }, + "es" + ).custom({ + field: { + type: "custom", + metadata: { yextField: { type: "ctaSelector", transform: true } }, + }, + value, + componentId: "hero", + propName: "primaryCta", + propPath: "primaryCta", + isReadOnly: true, + }); + expect(resolved).toMatchObject({ + ctaType: "getDirections", + label: "Cómo llegar", + linkType: "DRIVING_DIRECTIONS", + }); + expect(resolved.link).toContain("30.2672"); + expect(resolved.link).toContain("-97.7431"); + expect(value).toEqual(authoredValue); + } finally { + i18nPageInstance.removeResourceBundle("es", VISUAL_EDITOR_NAMESPACE); + } + }); + + it.each([ + "image", + "multiSelector", + "optionalNumber", + "ctaSelector", + "comprehensiveCTA", + "video", + ])( + "when %s does not opt in then its authored value retains its identity", + (type) => { + for (const transform of [undefined, false]) { + const value = { + defaultValue: "Authored", + selections: [{ value: "menu" }], + constantValue: { label: "Order", link: "/order" }, + }; + expect( + createYextFieldTransforms({}, "en").custom({ + field: { + type: "custom", + metadata: { yextField: { type, transform } }, + }, + value, + componentId: "hero", + propName: "value", + propPath: "value", + isReadOnly: true, + }) + ).toBe(value); + } + } + ); + + it.each([undefined, false])( + "when transform is %s then authored values retain their identity", + (transform) => { + const value = { field: "name", constantValue: { defaultValue: "Title" } }; + expect( + createYextFieldTransforms({ name: "Restaurant" }, "en").entityField({ + field: toPuckFields({ + title: { + type: "entityField", + transform, + filter: { types: ["type.string"] }, + }, + }).title!, + value, + componentId: "hero", + propName: "title", + propPath: "title", + isReadOnly: false, + }) + ).toBe(value); + } + ); + + it.each([ + { + name: "rich text", + value: { defaultValue: { html: "

Hello [[name]]

", json: "{}" } }, + expected: { html: "

Hello Restaurant

", json: "{}" }, + expectedText: "Hello Restaurant", + }, + { + name: "a plain string", + value: { defaultValue: "Hello [[name]]" }, + expected: "Hello Restaurant", + expectedText: "Hello Restaurant", + }, + { + name: "empty content", + value: undefined, + expected: undefined, + expectedText: "", + }, + ])( + "when a rich-text field contains $name then it returns resolved data for the shared renderer", + ({ value, expected, expectedText }) => { + const data = createYextFieldTransforms( + { name: "Restaurant" }, + "en" + ).entityField({ + field: toPuckFields({ + description: { + type: "entityField", + transform: true, + filter: { types: ["type.string", "type.rich_text_v2"] }, + }, + }).description!, + value: { field: "", constantValue: value, constantValueEnabled: true }, + componentId: "hero", + propName: "description", + propPath: "description", + isReadOnly: true, + }); + expect(data).toEqual(expected); + const html = renderToStaticMarkup( + + ); + expect(html).toContain(expectedText); + if (expectedText) { + expect(html).toContain("hero-description"); + } else { + expect(html).toBe(""); + } + } + ); + + it("when a localized image is selected then complex shape and asset metadata are retained", () => { + const value = { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: { image: { url: "/default.jpg" } }, + es: { + image: { + url: "/hero.jpg", + width: 640, + height: 360, + alternateText: { defaultValue: "Image", es: "[[name]]" }, + assetImage: { + sourceUrl: "/original.jpg", + transformations: { ROTATION: { degree: 90 } }, + }, + }, + }, + }, + }; + const authoredValue = structuredClone(value); + expect( + createYextFieldTransforms({ name: "Restaurant" }, "es").entityField({ + field: toPuckFields({ + image: { + type: "entityField", + transform: true, + filter: { types: ["type.image"] }, + }, + }).image!, + value, + componentId: "hero", + propName: "image", + propPath: "image", + isReadOnly: true, + }) + ).toEqual({ + image: { ...value.constantValue.es.image, alternateText: "Restaurant" }, + }); + expect(value).toEqual(authoredValue); + }); + + it("when fields opt in then their config derives render types while retaining authored defaults", () => { + const fields = { + title: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + description: { + type: "entityField", + transform: true, + filter: { types: ["type.string", "type.rich_text_v2"] }, + }, + image: { + type: "entityField", + transform: true, + filter: { types: ["type.image"] }, + }, + label: { type: "translatableString", transform: true }, + untouched: { type: "entityField", filter: { types: ["type.string"] } }, + } satisfies YextFieldMap<{ + title: YextEntityField; + description: YextEntityField; + image: YextEntityField; + label: TranslatableString; + untouched: YextEntityField; + }>; + const config: YextComponentConfig< + { + title: YextEntityField; + description: YextEntityField; + image: YextEntityField; + label: TranslatableString; + untouched: YextEntityField; + }, + typeof fields + > = { + fields, + render: ({ title, description, image, label, untouched }) => { + expectTypeOf(title).toEqualTypeOf(); + expectTypeOf(description).toEqualTypeOf< + RichText | string | undefined + >(); + expectTypeOf(image?.alternateText).toEqualTypeOf(); + expectTypeOf(label).toEqualTypeOf(); + expectTypeOf(untouched).toMatchTypeOf< + YextEntityField + >(); + return ; + }, + }; + expect(toPuckFields(config.fields!).description.metadata?.yextField).toBe( + fields.description + ); + }); + it("when the new field types opt in then component render props use resolved data types", () => { + type Props = { + asset: TranslatableAssetImage; + selections: MultiSelectorValue; + amount: number | string | null | undefined; + cta: YextCTAField; + }; + const fields = { + asset: { type: "image", transform: true }, + selections: { + type: "multiSelector", + transform: true, + label: "Selections", + dropdownLabel: "Selection", + options: [], + }, + amount: { + type: "optionalNumber", + transform: true, + showNumberFieldRadioLabel: "Show", + hideNumberFieldRadioLabel: "Hide", + defaultCustomValue: 1, + }, + cta: { type: "ctaSelector", transform: true }, + } satisfies YextFieldMap; + const config: YextComponentConfig = { + fields, + render: ({ asset, selections, amount, cta }) => { + expectTypeOf(asset?.alternateText).toEqualTypeOf(); + expectTypeOf(selections).toEqualTypeOf<(string | number | boolean)[]>(); + expectTypeOf(amount).toEqualTypeOf(); + expectTypeOf(cta?.label).toEqualTypeOf(); + expectTypeOf(cta?.link).toEqualTypeOf(); + return <>; + }, + }; + expect(toPuckFields(config.fields!).cta.metadata?.yextField).toBe( + fields.cta + ); + }); + it.each([ + { + name: "linked items", + value: { + field: "articles", + constantValue: [], + mappings: { + title: { field: "name" }, + description: { field: "description", constantValue: "" }, + nested: { + label: { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: "Welcome [[name]]", + es: "Hola [[name]]", + }, + }, + }, + }, + }, + expected: [ + { + title: "Artículo", + description: { html: "

Leer Artículo

" }, + nested: { label: "Hola Artículo" }, + }, + ], + }, + { + name: "manual items", + value: { + field: "articles", + constantValueEnabled: true, + constantValue: [ + { + title: { + field: "", + constantValueEnabled: true, + constantValue: { defaultValue: "Manual", es: "Manual [[name]]" }, + }, + description: { + field: "", + constantValueEnabled: true, + constantValue: { defaultValue: { html: "

Read [[name]]

" } }, + }, + nested: { + label: { + field: "", + constantValueEnabled: true, + constantValue: "[[name]]", + }, + }, + }, + ], + }, + expected: [ + { + title: "Manual Restaurant", + description: { html: "

Read Restaurant

" }, + nested: { label: "Restaurant" }, + }, + ], + }, + { + name: "missing linked items", + value: { field: "missing", constantValue: [] }, + expected: [], + }, + { name: "unset items", value: undefined, expected: [] }, + ])( + "when a repeated source contains $name then its item props are resolved", + ({ value, expected }) => { + const source = createItemSource({ + label: "Articles", + mappingFields: { + title: { type: "entityField", filter: { types: ["type.string"] } }, + description: { + type: "entityField", + filter: { types: ["type.rich_text_v2"] }, + }, + nested: { + type: "object", + objectFields: { + label: { + type: "entityField", + filter: { types: ["type.string"] }, + }, + }, + }, + }, + }); + const authored = structuredClone(value); + expect( + createYextFieldTransforms( + { + name: "Restaurant", + articles: [ + { + name: "Artículo", + description: { html: "

Leer [[name]]

" }, + }, + ], + }, + "es" + ).custom({ + field: toPuckFields({ + articles: { ...source.field, transform: true }, + }).articles!, + value, + componentId: "cards", + propName: "articles", + propPath: "articles", + isReadOnly: true, + }) + ).toEqual(expected); + expect(value).toEqual(authored); + } + ); + + it.each(["link", "button"] as const)( + "when a comprehensive CTA is a %s then it returns resolved data for presentation", + (actionType) => { + const value = { + data: { + actionType, + cta: { + field: "order", + constantValue: undefined, + constantValueEnabled: false, + selectedType: "textAndLink", + }, + buttonText: { defaultValue: "Book", es: "Reservar [[name]]" }, + ariaLabel: { + defaultValue: "Book a table", + es: "Reservar en [[name]]", + }, + customId: "booking", + customClass: "booking-button", + dataAttributes: [{ key: "booking", value: "table" }], + openInNewTab: true, + }, + styles: { variant: "primary", button: { fontWeight: "700" } }, + eventName: "booking-click", + }; + const authored = structuredClone(value); + const resolved = createYextFieldTransforms( + { + name: "Restaurant", + order: { + label: { defaultValue: "Order", es: "Pedir [[name]]" }, + link: "/order", + linkType: "URL", + }, + }, + "es" + ).custom({ + field: toPuckFields({ + cta: { type: "comprehensiveCTA", transform: true }, + }).cta!, + value, + componentId: "hero", + propName: "cta", + propPath: "cta", + isReadOnly: true, + }); + expect(resolved.data).toEqual({ + ...value.data, + cta: + actionType === "link" + ? { + ctaType: "textAndLink", + label: "Pedir Restaurant", + link: "/order", + linkType: "URL", + } + : undefined, + buttonText: "Reservar Restaurant", + ariaLabel: "Reservar en Restaurant", + }); + expect(resolved.styles.button.fontWeight).toBe("700"); + expect(value).toEqual(authored); + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain( + actionType === "link" ? "Pedir Restaurant" : "Reservar Restaurant" + ); + expect(html).toContain("font-weight:700"); + if (actionType === "button") { + expect(html).toContain('id="booking"'); + expect(html).toContain('data-booking="table"'); + expect(html).toContain('aria-label="Reservar en Restaurant"'); + } else { + expect(html).toContain('href="/order"'); + expect(html).toContain('target="_blank"'); + } + } + ); + + it("when a video opts in then its text is interpolated and asset metadata stays intact", () => { + const value = { + id: "asset", + name: "Video [[name]]", + video: { + id: "youtube", + title: "Welcome [[name]]", + url: "/watch", + embeddedUrl: "/embed", + duration: "PT30S", + thumbnail: "/thumb.jpg", + }, + videoDescription: "Visit [[name]]", + }; + const authored = structuredClone(value); + expect( + createYextFieldTransforms({ name: "Restaurant" }, "en").custom({ + field: toPuckFields({ video: { type: "video", transform: true } }) + .video!, + value, + componentId: "hero", + propName: "video", + propPath: "video", + isReadOnly: true, + }) + ).toEqual({ + ...value, + name: "Video Restaurant", + video: { ...value.video, title: "Welcome Restaurant" }, + videoDescription: "Visit Restaurant", + }); + expect(value).toEqual(authored); + expectTypeOf< + YextTransformedProps< + { cta: ComprehensiveCTAValue; video: AssetVideo }, + { + cta: { type: "comprehensiveCTA"; transform: true }; + video: { type: "video"; transform: true }; + } + >["cta"] + >().toEqualTypeOf(); + }); + + it("when a repeated source opts in then inferred item props contain resolved values", () => { + const source = createItemSource<{ + title: YextEntityField; + nested: { description: YextEntityField }; + }>({ + label: "Cards", + mappingFields: { + title: { type: "entityField", filter: { types: ["type.string"] } }, + nested: { + type: "object", + objectFields: { + description: { + type: "entityField", + filter: { types: ["type.rich_text_v2"] }, + }, + }, + }, + }, + }); + type Items = YextTransformedProps< + { cards: typeof source.value }, + { cards: typeof source.field & { transform: true } } + >["cards"]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + RichText | string | undefined + >(); + expect(source.field.type).toBe("entityField"); + }); + it.each([ + { + name: "directions", + cta: { + field: "", + constantValueEnabled: false, + selectedType: "getDirections", + }, + expectedLabel: "Get Directions", + expectedLink: "30.2672", + }, + { + name: "preset image", + cta: { + field: "", + constantValueEnabled: true, + constantValue: { + ctaType: "presetImage", + label: { defaultValue: "" }, + link: { defaultValue: "/download" }, + }, + }, + expectedLabel: "", + expectedLink: "/download", + }, + ])( + "when a comprehensive CTA uses $name then the renderer consumes resolved data", + ({ cta, expectedLabel, expectedLink }) => { + const value = { + data: { actionType: "link", cta }, + styles: { presetImage: "app-store" }, + }; + const resolved = createYextFieldTransforms( + { yextDisplayCoordinate: { latitude: 30.2672, longitude: -97.7431 } }, + "en" + ).custom({ + field: toPuckFields({ + cta: { type: "comprehensiveCTA", transform: true }, + }).cta!, + value, + componentId: "hero", + propName: "cta", + propPath: "cta", + isReadOnly: true, + }); + expect(resolved.data.cta.label).toBe(expectedLabel); + expect(resolved.data.cta.link).toContain(expectedLink); + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain(expectedLink); + if (expectedLabel) expect(html).toContain(expectedLabel); + } + ); + it.each([ + { name: "unset value", value: undefined, expected: "Call to Action" }, + { name: "partial value", value: {}, expected: "Call to Action" }, + { + name: "preset image", + value: { + data: { + actionType: "link", + cta: { + field: "", + constantValueEnabled: true, + constantValue: { + ctaType: "presetImage", + label: "", + link: "/download", + }, + }, + openInNewTab: false, + }, + styles: { variant: "primary", presetImage: "app-store" }, + }, + expected: "/download", + }, + { + name: "KG link", + value: { + data: { + actionType: "link", + cta: { + field: "order", + constantValueEnabled: false, + constantValue: { label: "Unused", link: "/unused" }, + }, + openInNewTab: true, + }, + }, + expected: "Pedir Restaurant", + }, + { + name: "constant link", + value: { + data: { + actionType: "link", + cta: { + field: "", + constantValueEnabled: true, + constantValue: { + label: { defaultValue: "Order", es: "Pedir [[name]]" }, + link: "/order", + }, + }, + openInNewTab: false, + }, + }, + expected: "Pedir Restaurant", + }, + { + name: "button", + value: { + data: { + actionType: "button", + cta: { field: "", constantValue: undefined }, + buttonText: { defaultValue: "Book", es: "Reservar [[name]]" }, + ariaLabel: { defaultValue: "Book a table", es: "Mesa en [[name]]" }, + openInNewTab: false, + }, + }, + expected: "Reservar Restaurant", + }, + { + name: "directions", + value: { + data: { + actionType: "link", + cta: { + field: "", + constantValueEnabled: false, + selectedType: "getDirections", + }, + openInNewTab: false, + }, + }, + expected: "30.2672", + }, + ])( + "when ComprehensiveCTA receives an authored $name then it matches its transformed rendering", + ({ value, expected }) => { + const streamDocument = { + name: "Restaurant", + order: { + label: { defaultValue: "Order", es: "Pedir [[name]]" }, + link: "/order", + }, + yextDisplayCoordinate: { latitude: 30.2672, longitude: -97.7431 }, + }; + const authored = structuredClone(value); + const resolved = createYextFieldTransforms(streamDocument, "es").custom({ + field: toPuckFields({ + cta: { type: "comprehensiveCTA", transform: true }, + }).cta!, + value, + componentId: "hero", + propName: "cta", + propPath: "cta", + isReadOnly: true, + }); + const markup = [value as Partial, resolved].map( + (value) => + renderToStaticMarkup( + + + + + + ) + ); + expect(markup[0]).toBe(markup[1]); + expect(markup[0]).toContain(expected); + expect(value).toEqual(authored); + } + ); + + it("when ComprehensiveCTA receives resolved text then embedded syntax is not resolved again", () => { + const value = { + data: { + actionType: "link" as const, + cta: { label: "Literal [[name]]", link: "/order" }, + openInNewTab: false, + }, + styles: { variant: "primary" as const }, + }; + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain("Literal [[name]]"); + }); +}); diff --git a/packages/visual-editor/src/fields/fieldTransforms/createYextFieldTransforms.ts b/packages/visual-editor/src/fields/fieldTransforms/createYextFieldTransforms.ts new file mode 100644 index 000000000..00821c809 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms/createYextFieldTransforms.ts @@ -0,0 +1,53 @@ +import type { BaseField, FieldTransformFn } from "@puckeditor/core"; +import type { StreamDocument } from "../../utils/types/StreamDocument.ts"; +import { + fieldToTransform, + type TransformableField, +} from "./fieldToTransform.ts"; + +/** + * Returns the authored field as a TransformableField when it opts into a + * supported transform. For example, a Puck `custom` field wrapping an + * `entityField` with `transform: true` returns the unwrapped `entityField`; + * unsupported or unmarked fields return `undefined`. + */ +export function getTransformField( + field: BaseField & { type: string } +): TransformableField | undefined { + const authoredField = + field.type === "custom" ? field.metadata?.yextField : field; + return authoredField?.transform === true && + Object.hasOwn(fieldToTransform, authoredField.type) + ? authoredField + : undefined; +} + +/** + * Creates opt-in render transforms for one page and locale. + * + * 1. Identify opted-in authored fields, including fields adapted to `custom`. + * 2. Dispatch source resolution to the field handler, sharing localization and interpolation. + * 3. Register handlers with Puck while preserving resolved data shapes for their renderers. + * Authored values are never mutated or replaced in saved Puck data. + */ +export function createYextFieldTransforms( + streamDocument: StreamDocument, + locale: string +): Record> { + const context = { streamDocument, locale }; + const transform: FieldTransformFn = ({ + field, + value, + }) => { + const authoredField = getTransformField(field); + return authoredField + ? fieldToTransform[authoredField.type](authoredField, value, context) + : value; + }; + return Object.fromEntries( + [...Object.keys(fieldToTransform), "custom"].map((type) => [ + type, + transform, + ]) + ); +} diff --git a/packages/visual-editor/src/fields/fieldTransforms/cta.ts b/packages/visual-editor/src/fields/fieldTransforms/cta.ts new file mode 100644 index 000000000..9c1fa768b --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms/cta.ts @@ -0,0 +1,82 @@ +import { getDirections } from "@yext/pages-components"; +import { getCTAType } from "../../internal/utils/ctaFieldUtils.ts"; +import { i18nPageInstance } from "../../utils/i18n/i18nInstances.ts"; +import { + normalizeComprehensiveCTAValue, + type ComprehensiveCTAValue, +} from "../styledFields/ComprehensiveCTAField.tsx"; +import type { + ResolvedCTAValue, + ResolvedComprehensiveCTAValue, +} from "../fields.ts"; +import { + resolveEntityValue, + resolveValue, + type FieldTransformContext, +} from "./resolveValue.ts"; + +/** Resolves authored CTA data for both field transforms and the temporary renderer compatibility path. */ +export function resolveComprehensiveCTAValue( + value: Partial | undefined, + context: FieldTransformContext +): ResolvedComprehensiveCTAValue { + const normalized = normalizeComprehensiveCTAValue(value); + return { + ...normalized, + data: { + ...normalized.data, + cta: + normalized.data.actionType === "link" + ? resolveCTAValue(normalized.data.cta, context) + : undefined, + buttonText: resolveValue(normalized.data.buttonText, context), + ariaLabel: resolveValue(normalized.data.ariaLabel, context), + }, + }; +} + +/** Resolves a CTA source, including the URL and label for Get Directions. */ +export function resolveCTAValue( + value: any, + context: FieldTransformContext +): ResolvedCTAValue | undefined { + const { ctaType } = getCTAType(value); + const resolved = + ctaType === "getDirections" && !value?.constantValueEnabled + ? undefined + : resolveEntityValue(value, context); + if (ctaType === "getDirections") { + return { + ...resolved, + ctaType, + label: + resolved?.label || + i18nPageInstance.getFixedT(context.locale)( + "getDirections", + "Get Directions" + ), + // Directions use the page's listings first, then its display coordinate. + // User settable link props should not be used for get directions. + link: + getDirections( + undefined, + context.streamDocument.ref_listings, + undefined, + { provider: "google" }, + undefined + ) || + getDirections( + undefined, + undefined, + undefined, + { provider: "google" }, + context.streamDocument.yextDisplayCoordinate + ) || + "#", + linkType: "DRIVING_DIRECTIONS", + }; + } + return resolved === undefined + ? undefined + : { ...resolved, ...(ctaType ? { ctaType } : {}) }; +} diff --git a/packages/visual-editor/src/fields/fieldTransforms/fieldToTransform.ts b/packages/visual-editor/src/fields/fieldTransforms/fieldToTransform.ts new file mode 100644 index 000000000..5a9ef9054 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms/fieldToTransform.ts @@ -0,0 +1,91 @@ +import type { YextFieldMap, YextPuckField } from "../fields.ts"; +import type { StreamDocument } from "../../utils/types/StreamDocument.ts"; +import { resolveField } from "../../utils/resolveYextEntityField.ts"; +import { resolveCTAValue, resolveComprehensiveCTAValue } from "./cta.ts"; +import { + resolveEntityValue, + resolveValue, + type FieldTransformContext, +} from "./resolveValue.ts"; + +export type TransformableField = Extract< + YextPuckField, + { transform?: boolean } +>; + +/** Resolves repeated-item mappings recursively against the selected item document. */ +function resolveItemFields( + fields: YextFieldMap, + value: any, + context: FieldTransformContext +): any { + return Object.fromEntries( + Object.entries(fields).map(([key, field]) => { + const itemValue = value?.[key]; + if (field.type === "object") { + return [key, resolveItemFields(field.objectFields, itemValue, context)]; + } + if (field.type === "array") { + return [ + key, + (itemValue ?? []).map((item: any) => + resolveItemFields(field.arrayFields, item, context) + ), + ]; + } + return [ + key, + Object.hasOwn(fieldToTransform, field.type) + ? fieldToTransform[field.type as TransformableField["type"]]( + field as TransformableField, + itemValue, + context + ) + : resolveValue(itemValue, context), + ]; + }) + ); +} + +/** Each supported authored field owns its source selection and render-value contract. */ +export const fieldToTransform: Record< + TransformableField["type"], + (field: TransformableField, value: any, context: FieldTransformContext) => any +> = { + entityField: (field, value, context) => { + if ("repeated" in field && field.repeated) { + const { repeated } = field; + const manual = value?.constantValueEnabled === true; + const items = manual + ? value?.constantValue + : value?.field + ? resolveField(context.streamDocument, value.field).value + : undefined; + return Array.isArray(items) + ? items.map((item) => + resolveItemFields( + manual ? repeated.manualItemFields : repeated.mappingFields, + manual ? item : value.mappings, + manual + ? context + : { ...context, streamDocument: item as StreamDocument } + ) + ) + : []; + } + return resolveEntityValue(value, context); + }, + translatableString: (_field, value, context) => + resolveValue(value, context) ?? "", + video: (_field, value, context) => resolveValue(value, context), + comprehensiveCTA: (_field, value, context) => + resolveComprehensiveCTAValue(value, context), + image: (_field, value, context) => resolveValue(value, context), + multiSelector: (_field, value) => + (value?.selections ?? []).flatMap(({ value }: { value: unknown }) => + value === undefined ? [] : [value] + ), + ctaSelector: (_field, value, context) => resolveCTAValue(value, context), + optionalNumber: (_field, value) => + typeof value === "number" ? value : undefined, +}; diff --git a/packages/visual-editor/src/fields/fieldTransforms/index.ts b/packages/visual-editor/src/fields/fieldTransforms/index.ts new file mode 100644 index 000000000..5627e4087 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms/index.ts @@ -0,0 +1,5 @@ +export { + createYextFieldTransforms, + getTransformField, +} from "./createYextFieldTransforms.ts"; +export { resolveComprehensiveCTAValue } from "./cta.ts"; diff --git a/packages/visual-editor/src/fields/fieldTransforms/resolveValue.ts b/packages/visual-editor/src/fields/fieldTransforms/resolveValue.ts new file mode 100644 index 000000000..f7db40e18 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms/resolveValue.ts @@ -0,0 +1,54 @@ +import type { StreamDocument } from "../../utils/types/StreamDocument.ts"; +import { + resolveEmbeddedFieldsInString, + resolveField, +} from "../../utils/resolveYextEntityField.ts"; + +export type FieldTransformContext = { + streamDocument: StreamDocument; + locale: string; +}; + +/** + * Localizes nested values while retaining rich-text and image structure. + * Resolves embedded fields while recursively visiting strings, objects, and lists. + */ +export function resolveValue(value: any, context: FieldTransformContext): any { + if (typeof value === "string") { + return resolveEmbeddedFieldsInString( + value, + context.streamDocument, + context.locale + ); + } + if (!value || typeof value !== "object") { + return value; + } + if (value.hasLocalizedValue === "true" || "defaultValue" in value) { + return resolveValue(value[context.locale] ?? value.defaultValue, context); + } + if (Array.isArray(value)) { + return value.map((item) => resolveValue(item, context)); + } + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + resolveValue(nestedValue, context), + ]) + ); +} + +/** Resolves the selected entity or constant source before localizing its data. */ +export function resolveEntityValue( + value: any, + context: FieldTransformContext +): any { + return resolveValue( + value?.constantValueEnabled + ? value.constantValue + : value?.field + ? resolveField(context.streamDocument, value.field).value + : undefined, + context + ); +} diff --git a/packages/visual-editor/src/fields/fields.ts b/packages/visual-editor/src/fields/fields.ts index 2d72c0808..07c58a5ed 100644 --- a/packages/visual-editor/src/fields/fields.ts +++ b/packages/visual-editor/src/fields/fields.ts @@ -1,4 +1,5 @@ import { createElement, type ReactElement } from "react"; +import type { LinkType } from "@yext/pages-components"; import type { ArrayField, CustomField, @@ -25,10 +26,103 @@ import type { StyledPageSectionField } from "./styledFields/StyledPageSection.ts import type { StyledTextField } from "./styledFields/StyledTextField.tsx"; import type { TranslatableStringField } from "./TranslatableStringField.tsx"; import type { VideoField } from "./VideoField.tsx"; -import type { ComprehensiveCTAField } from "./styledFields/ComprehensiveCTAField.tsx"; +import type { + ComprehensiveCTAField, + ComprehensiveCTAValue, +} from "./styledFields/ComprehensiveCTAField.tsx"; +import type { EnhancedTranslatableCTA } from "../types/types.ts"; import { YextAutoField } from "./YextAutoField.tsx"; import { adaptYextFieldMap } from "./yextFieldAdapter.ts"; +type LocalizedRenderValue = Value extends readonly (infer Item)[] + ? LocalizedRenderValue[] + : Value extends object + ? "defaultValue" extends keyof Value + ? LocalizedRenderValue + : { [Key in keyof Value]: LocalizedRenderValue } + : Value; + +/** CTA data after localized text and links are resolved for rendering. */ +export type ResolvedCTAValue = { + link: string; + label?: string; + linkType?: LinkType; + normalizeLink?: boolean; + openInNewTab?: boolean; + ctaType?: "textAndLink" | "getDirections" | "presetImage"; +}; + +/** The presentation component consumes this data contract without resolving authored bindings. */ +export type ResolvedComprehensiveCTAValue = Omit< + ComprehensiveCTAValue, + "data" +> & { + data: Omit< + ComprehensiveCTAValue["data"], + "cta" | "buttonText" | "ariaLabel" + > & { + cta: ResolvedCTAValue | undefined; + buttonText?: string; + ariaLabel?: string; + }; +}; + +/** Repeated-item props retain their structure while authored value wrappers are resolved. */ +type ResolvedRepeatedItem = Value extends { + constantValue: infer Constant; +} + ? LocalizedRenderValue | undefined + : Value extends ComprehensiveCTAValue + ? ResolvedComprehensiveCTAValue + : Value extends { selections: { value: infer Selection }[] } + ? Exclude[] + : Value extends readonly (infer Item)[] + ? ResolvedRepeatedItem[] + : Value extends object + ? { [Key in keyof Value]: ResolvedRepeatedItem } + : Value; + +type TransformedFieldValues = { + translatableString: string; + image: LocalizedRenderValue; + video: LocalizedRenderValue; + comprehensiveCTA: ResolvedComprehensiveCTAValue; + multiSelector: Value extends { selections: { value: infer Selection }[] } + ? Exclude[] + : never; + optionalNumber: number | undefined; + ctaSelector: LocalizedRenderValue | undefined; + entityField: Definition extends { repeated: object } + ? Value extends { constantValue: infer Constant } + ? LocalizedRenderValue> + : never + : Value extends { constantValue: infer Constant } + ? LocalizedRenderValue | undefined + : never; +}; + +type TransformedFieldValue = Definition extends { + transform: true; + type: infer FieldType; +} + ? FieldType extends keyof TransformedFieldValues + ? TransformedFieldValues[FieldType] + : Value + : Definition extends { type: "object"; objectFields: infer Nested } + ? YextTransformedProps + : Definition extends { type: "array"; arrayFields: infer Nested } + ? Value extends (infer Item)[] + ? YextTransformedProps[] + : Value + : Value; + +/** Derives render values from authored props and explicitly opted-in field definitions. */ +export type YextTransformedProps = { + [Key in keyof Props]: Key extends keyof Definitions + ? TransformedFieldValue + : Props[Key]; +}; + export type YextPuckFields = { basicSelector: BasicSelectorField; ctaSelector: CTASelectorField; @@ -80,13 +174,18 @@ export type YextFieldDefinition = export type YextComponentConfig< Props extends DefaultComponentProps = DefaultComponentProps, + TransformFields = {}, > = Omit< ComponentConfig<{ props: Props; fields: YextPuckFields; }>, - "fields" | "resolveFields" + "fields" | "resolveFields" | "render" > & { + render: ComponentConfig<{ + props: YextTransformedProps; + fields: YextPuckFields; + }>["render"]; fields?: YextFields; resolveFields?: ComponentConfig<{ props: Props; diff --git a/packages/visual-editor/src/fields/index.ts b/packages/visual-editor/src/fields/index.ts index 388fe6f35..91e2dae41 100644 --- a/packages/visual-editor/src/fields/index.ts +++ b/packages/visual-editor/src/fields/index.ts @@ -53,6 +53,8 @@ export { VideoFieldOverride, type VideoField } from "./VideoField.tsx"; export { toPuckFields, type YextComponentConfig, + type YextTransformedProps, + type ResolvedComprehensiveCTAValue, type YextArrayField, type YextFields, type YextFieldMap, @@ -65,3 +67,4 @@ export { export { YextPuckFieldOverrides } from "./fieldOverrides.ts"; export { YextAutoField } from "./YextAutoField.tsx"; +export { createYextFieldTransforms } from "./fieldTransforms/index.ts"; diff --git a/packages/visual-editor/src/fields/styledFields/ComprehensiveCTAField.tsx b/packages/visual-editor/src/fields/styledFields/ComprehensiveCTAField.tsx index 0bde42b9b..b25328ea4 100644 --- a/packages/visual-editor/src/fields/styledFields/ComprehensiveCTAField.tsx +++ b/packages/visual-editor/src/fields/styledFields/ComprehensiveCTAField.tsx @@ -60,6 +60,8 @@ export type ComprehensiveCTAValue = { export type ComprehensiveCTAField = BaseField & { type: "comprehensiveCTA"; + /** Resolves CTA bindings and translated text before rendering. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; disableConstantValueToggle?: boolean; diff --git a/packages/visual-editor/src/fields/yextFieldAdapter.ts b/packages/visual-editor/src/fields/yextFieldAdapter.ts index a059ff793..276da46fb 100644 --- a/packages/visual-editor/src/fields/yextFieldAdapter.ts +++ b/packages/visual-editor/src/fields/yextFieldAdapter.ts @@ -9,7 +9,10 @@ export const adaptYextField = ( renderYextField: YextFieldRender ): Field => { if (isYextOverrideType(field.type)) { - return renderYextField(field as YextPuckField); + return { + ...renderYextField(field as YextPuckField), + metadata: { ...field.metadata, yextField: field }, + }; } if (field.type === "object" && "objectFields" in field) { diff --git a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx index 6d60203fb..481064b50 100644 --- a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx +++ b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx @@ -39,6 +39,9 @@ import { useErrorContext } from "../../contexts/ErrorContext.tsx"; import { clonePuckResolveData } from "../utils/clonePuckResolveData.ts"; import { YextPuckFieldOverrides } from "../../fields/fieldOverrides.ts"; import type { MigrationRegistry } from "../../utils/migrate.ts"; +import { useTranslation } from "react-i18next"; +import { createYextFieldTransforms } from "../../fields/fieldTransforms/index.ts"; +import { TransformedFieldTooltip } from "../puck/components/TransformedFieldTooltip.tsx"; const devLogger = new DevLogger(); const usePuck = createUsePuck(); @@ -118,6 +121,11 @@ export const InternalLayoutEditor = ({ const historyIndex = useRef(0); const { i18n } = usePlatformTranslation(); const streamDocument = useDocument(); + const { i18n: pageI18n } = useTranslation(); + const fieldTransforms = React.useMemo( + () => createYextFieldTransforms(streamDocument, pageI18n.language), + [streamDocument, pageI18n.language] + ); const { errorCount, errorSources, errorDetails } = useErrorContext(); // Keep plugin sidebar fields mounted when saved state changes. const plugins = React.useMemo( @@ -407,10 +415,12 @@ export const InternalLayoutEditor = ({ (