diff --git a/field-transforms-plan.md b/field-transforms-plan.md new file mode 100644 index 0000000000..edca12e953 --- /dev/null +++ b/field-transforms-plan.md @@ -0,0 +1,66 @@ +# Field transforms: minimal implementation + +## Contract + +VisualEditor resolves supported Yext content fields automatically at the render boundary, based on field type. There is no resolution opt-in flag. The existing StyledTextComponent and ComprehensiveCTA consume transformed content directly. No raw-or-resolved guessing is needed. + +Defaults, editor controls, `resolveData`, layouts, and migrations continue to use authored values. Component render props receive plain resolved data. The existing `YextComponentConfig` derives render props from the field schema. Define fields with `satisfies YextFields` to preserve their concrete field types. Standalone render functions use `typeof ComponentConfig.render`; no manual render-prop overrides are needed. There are no new `Resolved*` types, component-definition helpers, source wrappers, or renderer APIs. + +The editor uses Puck fieldTransforms. Published pages use the same registry through `VisualEditorRender`, since Puck 0.22.2's `Render` does not accept fieldTransforms. The section-library render template uses `VisualEditorRender` too. Both use the page's StreamDocument and content locale. + +## Fields with transforms + +| Yext field | Resolution | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `entityField` | Resolve mapped or constant content, translations, and embedded references. Structured data remains structured, including addresses, hours, images, rich text, numbers, and booleans. | +| `entityField.repeated` | Resolve manual or linked items using the existing mapping definitions and correct item document. Components read the resulting array instead of calling `resolveItems`. | +| `translatableString` | Select the locale and resolve embedded references. | +| `image` | Resolve localized image content and alternate text while retaining its existing image shape. | +| `ctaSelector` | Resolve CTA content and retain the selected CTA type. | +| `comprehensiveCTA` | Resolve the internal CTA, button text, and aria label. Preserve action, presentation, and other settings. | +| `code` | Interpolate embedded references in code fields. Custom Code HTML keeps its existing additional Handlebars processing. | + +No transforms for Puck/native fields or Yext style selectors. The `custom` bridge dispatches using the original `yextFieldType` marker. Native objects and arrays are traversed structurally; slots retain Puck's lifecycle. Root settings remain authored. + +## Component adoption + +VisualEditor consumes automatically resolved content in HeadingText, HoursStatus, HoursTable, Phone, Address, Image, MapboxStaticMap, Breadcrumbs, Locator headings/filter labels, and Custom Code. Custom result-card controls retain their per-result document context. + +Casual Dining adopts transforms for all direct `resolveComponentData` calls in its custom sections: + +- Header: navigation/utility text and links, logo and utility images, logo URL. +- Hero, Promo, Story: images. +- Footer: brand image, social links/images, column labels and links, legal links. +- Details: address, hours, phone numbers, dining list. +- Locations: heading text. +- FAQ and Featured: ordinary repeated item sources. + +Styled text fields created by `createStyledTextConfig` expose their concrete field schema for inference. Hero, Promo, Story, Featured, FAQ, Details, Reviews, Header, and Footer consume plain text and rich-text data through the existing StyledTextComponent. Details also receives resolved values for its separately declared subheadings. ComprehensiveCTA fields resolve automatically and pass their transformed values directly to the existing renderer. + +The shared text and CTA renderers no longer call resolveComponentData. Repeated-item text and review content pass directly into StyledTextComponent without constructing synthetic entity bindings. Rich text stays data until the existing renderer applies typography and HTML rendering; it no longer inspects or clones pre-rendered React elements. Mapped directions labels are localized in the CTA transform while authored binding metadata is available. CTA actions, URL formatting, preset images, styles, and analytics remain in the existing presentation path. + +Existing StyledPlainTextProps and StyledRichTextProps accept a content generic for their render inputs. ComprehensiveCTAProps describes plain CTA content. No additional helper, renderer, or Resolved-type families are introduced. + +Analytics, formatting, fetching, visibility, theme styling, and rich-text rendering remain in their current presentation paths. No tooltip/source-wrapper work is included; transformed fields no longer supply authored binding metadata to EntityField. + +Slotted item sources retain authored references in resolveData/populateSlots. Field transforms run at render time, after slot population; native slot values retain Puck’s lifecycle. + +## Saved layouts and migration ownership + +This implementation changes render-time values only. Every existing saved field shape remains valid: bindings, localized constants, CTA settings, images, and item mappings are unchanged. Therefore it needs no saved-layout conversion or migration-version increase. Do not add no-op migrations or rewrite default layouts merely to enable transforms. + +If a subsequent change alters saved shapes, append built-in migrations in VisualEditor and Casual Dining-specific migrations in casual-dining's registry. VisualEditor must contain no Casual Dining-specific migration code or fixtures. Casual Dining's existing local copies receive only the approved compatibility updates for automatic field resolution. + +## Verification + +- Focused transform tests cover localization, mapped/constants, embedded references, empty values, false/zero, rich-text data, images, CTA fields, code, and manual/linked item sources. +- Editor/published parity tests use actual Puck 0.22.2 and nested object/array fields inside a slot, with two document locales, and verify authored data remains unchanged. +- Renderer integration tests cover localized links, mapped and constant directions labels, preset images, button attributes, missing CTAs, and rich-text styles. +- Run the VisualEditor TypeScript check and normal editor test suite. No image-matching or screenshot tests. +- Use `updateVE` in casual-dining to pack/install the library, then run its typecheck, validation, and build. + +The external website-generation skill is not changed in this repository implementation. New generated components define their content fields and consume inferred plain render props; shared text and CTA renderer examples should pass transformed data directly, while defaults and editor fields keep authored input shapes. + +Source-dependent built-in UI (address directions selection and the image asset picker) reads original entity-field bindings from Puck render metadata. Component values remain plain data; metadata is not persisted in layouts. + +Casual Dining’s seven existing local content-block/Breadcrumbs copies now consume transformed values and infer their render types from their field schemas. These targeted compatibility updates preserve their local behavior and saved defaults. diff --git a/packages/visual-editor/src/components/atoms/image.tsx b/packages/visual-editor/src/components/atoms/image.tsx index 7588ec9ae7..5695a47c14 100644 --- a/packages/visual-editor/src/components/atoms/image.tsx +++ b/packages/visual-editor/src/components/atoms/image.tsx @@ -1,26 +1,12 @@ import * as React from "react"; -import { - ComplexImageType, - Image as ImageComponent, - ImageType, -} from "@yext/pages-components"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; +import { Image as ImageComponent, ImageType } from "@yext/pages-components"; import { themeManagerCn } from "../../utils/cn.ts"; import { useDocument } from "../../hooks/useDocument.tsx"; -import { - AssetImageType, - isLocalizedAssetImage, - resolveLocalizedAssetImage, - TranslatableAssetImage, - ImageFillType, -} from "../../types/images.ts"; -import { TranslatableString } from "../../types/types.ts"; -import { useTranslation } from "react-i18next"; -import { StreamDocument } from "../../utils/types/StreamDocument.ts"; +import { ImageFillType } from "../../types/images.ts"; import { getThemeValue } from "../../utils/getThemeValue.ts"; export interface ImageProps { - image: ImageType | ComplexImageType | TranslatableAssetImage; + image: ImageType; aspectRatio?: number; width?: number; imageFillType?: ImageFillType; @@ -28,61 +14,19 @@ export interface ImageProps { /** sizes attribute of the underlying img tag */ sizes?: string; loading?: "lazy" | "eager"; - /** - * Entity data used to resolve embedded fields. - * Defaults to the stream document if not provided. - */ - streamDocumentOverride?: Record; style?: React.CSSProperties; } -export const getImageAltText = ( - image: ImageType | ComplexImageType | AssetImageType | undefined, - locale: string, - streamDocument: StreamDocument | Record -): string | undefined => { - if (!image) { - return undefined; - } - - let altTextField: string | TranslatableString | undefined = undefined; - if (isComplexImageType(image)) { - altTextField = image.image.alternateText; - } else if (image?.alternateText) { - altTextField = image.alternateText; - } - - return typeof altTextField === "object" - ? resolveComponentData(altTextField, locale, streamDocument) - : altTextField; -}; - export const Image: React.FC = ({ - image: rawImage, + image, aspectRatio, width, imageFillType, className, sizes, loading = "lazy", - streamDocumentOverride, style, }) => { - const { i18n } = useTranslation(); - const streamDocument: StreamDocument | Record = - streamDocumentOverride ?? useDocument(); - - const image = React.useMemo(() => { - if (rawImage && isLocalizedAssetImage(rawImage)) { - return resolveLocalizedAssetImage(rawImage, i18n.language); - } - return rawImage as ImageType | ComplexImageType | AssetImageType; - }, [rawImage, i18n.language]); - - if (!image) { - return null; - } - // Calculate height based on width and aspect ratio if width is provided const calculatedHeight = width && aspectRatio ? width / aspectRatio : undefined; @@ -92,7 +36,6 @@ export const Image: React.FC = ({ ? `overflow-hidden` // No w-full when width is specified : `overflow-hidden w-full`; // Use w-full when no width specified - const altText = getImageAltText(image, i18n.language, streamDocument); const imageStyle: React.CSSProperties = { objectFit: imageFillType === "fit" ? "contain" : "cover", ...style, @@ -108,7 +51,7 @@ export const Image: React.FC = ({ > {aspectRatio ? ( = ({ /> ) : !!width && !!calculatedHeight ? ( = ({ /> ) : ( {altText} = ({ ); }; -function isComplexImageType( - image: ImageType | ComplexImageType | AssetImageType -): image is ComplexImageType { - return "image" in image; -} - export type ImgSizesByBreakpoint = { base: string; sm?: string; diff --git a/packages/visual-editor/src/components/contentBlocks/Address.tsx b/packages/visual-editor/src/components/contentBlocks/Address.tsx index b34f52292f..1efdb50f6f 100644 --- a/packages/visual-editor/src/components/contentBlocks/Address.tsx +++ b/packages/visual-editor/src/components/contentBlocks/Address.tsx @@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next"; import { ComponentData, DefaultComponentProps, - PuckComponent, setDeep, } from "@puckeditor/core"; import { @@ -15,7 +14,7 @@ import { EntityField } from "../../editor/EntityField.tsx"; import { YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; import { CTA, CTAVariant, isCtaVariantWithColor } from "../atoms/cta.tsx"; import { pt, msg } from "../../utils/i18n/platform.ts"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; + import { ThemeColor, ThemeOptions, @@ -61,16 +60,16 @@ export interface AddressProps { } // Address field definition used in Address and CoreInfoSection -export const AddressDataField: YextFields = { +export const AddressDataField = { address: { type: "entityField", label: msg("fields.address", "Address"), filter: { types: ["type.address"] }, }, -}; +} satisfies YextFields; // Address style fields used in Address and CoreInfoSection -export const AddressStyleFields: YextFields = { +export const AddressStyleFields = { showRegion: { label: msg("fields.showRegion", "Show Region"), type: "radio", @@ -105,9 +104,9 @@ export const AddressStyleFields: YextFields = { label: msg("fields.linkColor", "Link Color"), options: "SITE_COLOR", }, -}; +} satisfies YextFields; -export const addressFields: YextFields = { +export const addressFields = { data: { type: "object", label: msg("fields.data", "Data"), @@ -118,21 +117,17 @@ export const addressFields: YextFields = { label: msg("fields.styles", "Styles"), objectFields: AddressStyleFields, }, -}; +} satisfies YextFields; -const AddressComponent: PuckComponent = (props) => { +const AddressComponent: typeof Address.render = (props) => { const { data, styles, puck, parentData } = props; - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const streamDocument = useDocument(); const resolvedColor = styles.color; - const address = - parentData?.address ?? - (resolveComponentData( - data.address, - i18n.language, - streamDocument - ) as unknown as AddressType | undefined); + const address = parentData?.address ?? data.address; + const source = puck.metadata.fieldSources?.get(`${props.id}:data.address`) as + AddressProps["data"]["address"] | undefined; const listings = streamDocument.ref_listings ?? []; const listingsLink = getDirections( @@ -151,7 +146,7 @@ const AddressComponent: PuckComponent = (props) => { // If ref_listings doesn't exist or the address field selected isn't just address, use the address link. const useAddressLink: boolean = - data.address.field !== "address" || !streamDocument.ref_listings?.length; + source?.field !== "address" || !streamDocument.ref_listings?.length; // Only show the address component if there's at least one line of the address const showAddress = !!( @@ -166,8 +161,8 @@ const AddressComponent: PuckComponent = (props) => {
= { - label: msg("components.address", "Address"), - fields: addressFields, - defaultProps: { - data: { - address: { - field: "address", - constantValue: { - line1: "", - city: "", - region: "", - postalCode: "", - countryCode: "", +export const Address: YextComponentConfig = + { + label: msg("components.address", "Address"), + fields: addressFields, + defaultProps: { + data: { + address: { + field: "address", + constantValue: { + line1: "", + city: "", + region: "", + postalCode: "", + countryCode: "", + }, }, }, + styles: { + showRegion: true, + showCountry: false, + showGetDirectionsLink: true, + ctaVariant: "link", + color: backgroundColors.color1.value, + }, }, - styles: { - showRegion: true, - showCountry: false, - showGetDirectionsLink: true, - ctaVariant: "link", - color: backgroundColors.color1.value, - }, - }, - resolveFields: resolveAddressFields, - render: (props) => , -}; + resolveFields: resolveAddressFields, + render: AddressComponent, + }; diff --git a/packages/visual-editor/src/components/contentBlocks/HeadingText.tsx b/packages/visual-editor/src/components/contentBlocks/HeadingText.tsx index 278a1b77d8..6a0caec3d8 100644 --- a/packages/visual-editor/src/components/contentBlocks/HeadingText.tsx +++ b/packages/visual-editor/src/components/contentBlocks/HeadingText.tsx @@ -1,11 +1,8 @@ import * as React from "react"; -import { PuckComponent } from "@puckeditor/core"; -import { useDocument } from "../../hooks/useDocument.tsx"; import { EntityField } from "../../editor/EntityField.tsx"; import { YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; import { Heading, HeadingProps } from "../atoms/heading.tsx"; import { TranslatableString } from "../../types/types.ts"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; import { pt, msg } from "../../utils/i18n/platform.ts"; import { ThemeOptions, @@ -13,7 +10,6 @@ import { ThemeColor, } from "../../utils/themeConfigOptions.ts"; import { resolveDataFromParent } from "../../editor/ParentData.tsx"; -import { useTranslation } from "react-i18next"; import { YextComponentConfig, YextFields } from "../../fields/fields.ts"; export type HeadingTextProps = { @@ -40,10 +36,8 @@ export type HeadingTextProps = { }; }; -const HeadingTextWrapper: PuckComponent = (props) => { +const HeadingTextWrapper: typeof HeadingText.render = (props) => { const { data, styles, puck, parentData } = props; - const streamDocument = useDocument(); - const { i18n } = useTranslation(); const justifyClass = styles?.align ? { @@ -61,17 +55,11 @@ const HeadingTextWrapper: PuckComponent = (props) => { }[styles.align] : "text-left"; - const resolvedHeadingText = - parentData?.text ?? - resolveComponentData(data.text, i18n.language, streamDocument); + const resolvedHeadingText = parentData?.text ?? data.text; return resolvedHeadingText ? (
- + = (props) => { ); }; -const headingTextFields: YextFields = { +const headingTextFields = { data: { label: msg("fields.data", "Data"), type: "object", objectFields: { text: { type: "entityField", + label: msg("fields.text", "Text"), filter: { types: ["type.string"], @@ -124,9 +113,12 @@ const headingTextFields: YextFields = { }, }, }, -}; +} satisfies YextFields; -export const HeadingText: YextComponentConfig = { +export const HeadingText: YextComponentConfig< + HeadingTextProps, + typeof headingTextFields +> = { label: msg("components.headingText", "Heading Text"), fields: headingTextFields, resolveFields: (data) => resolveDataFromParent(headingTextFields, data), @@ -143,5 +135,5 @@ export const HeadingText: YextComponentConfig = { align: "left", }, }, - render: (props) => , + render: HeadingTextWrapper, }; diff --git a/packages/visual-editor/src/components/contentBlocks/HoursStatus.tsx b/packages/visual-editor/src/components/contentBlocks/HoursStatus.tsx index 811e9c3479..b3842de7fd 100644 --- a/packages/visual-editor/src/components/contentBlocks/HoursStatus.tsx +++ b/packages/visual-editor/src/components/contentBlocks/HoursStatus.tsx @@ -1,8 +1,5 @@ -import { useTranslation } from "react-i18next"; -import { PuckComponent } from "@puckeditor/core"; import { HoursType } from "@yext/pages-components"; import { useDocument } from "../../hooks/useDocument.tsx"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; import { EntityField } from "../../editor/EntityField.tsx"; import { YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; import { msg, pt } from "../../utils/i18n/platform.ts"; @@ -40,13 +37,14 @@ export interface HoursStatusProps { }; } -export const hoursStatusWrapperFields: YextFields = { +export const hoursStatusWrapperFields = { data: { type: "object", label: msg("fields.data", "Data"), objectFields: { hours: { type: "entityField", + label: msg("fields.hours", "Hours"), filter: { types: ["type.hours"], @@ -92,27 +90,22 @@ export const hoursStatusWrapperFields: YextFields = { }, }, }, -}; +} satisfies YextFields; -const HoursStatusWrapper: PuckComponent = ({ +const HoursStatusWrapper: typeof HoursStatus.render = ({ data, styles, puck, parentData, }) => { const streamDocument = useDocument(); - const { i18n } = useTranslation(); const comingSoon = parentData?.comingSoon ?? !!streamDocument.comingSoon; - const hours = - parentData?.hours ?? - resolveComponentData(data.hours, i18n.language, streamDocument); + const hours = parentData?.hours ?? data.hours; const timezone = parentData?.timezone ?? streamDocument.timezone; return hours || comingSoon ? ( = ({ ); }; -export const HoursStatus: YextComponentConfig = { +export const HoursStatus: YextComponentConfig< + HoursStatusProps, + typeof hoursStatusWrapperFields +> = { label: msg("components.hoursStatus", "Hours Status"), fields: hoursStatusWrapperFields, defaultProps: { @@ -153,5 +149,5 @@ export const HoursStatus: YextComponentConfig = { }, resolveFields: (data) => resolveDataFromParent(hoursStatusWrapperFields, data), - render: (props) => , + render: HoursStatusWrapper, }; diff --git a/packages/visual-editor/src/components/contentBlocks/HoursTable.tsx b/packages/visual-editor/src/components/contentBlocks/HoursTable.tsx index 17326a3482..86988b3344 100644 --- a/packages/visual-editor/src/components/contentBlocks/HoursTable.tsx +++ b/packages/visual-editor/src/components/contentBlocks/HoursTable.tsx @@ -1,15 +1,16 @@ -import { useTranslation } from "react-i18next"; -import { PuckComponent } from "@puckeditor/core"; import { DayOfWeekNames, HoursType } from "@yext/pages-components"; import "@yext/pages-components/style.css"; import { EntityField } from "../../editor/EntityField.tsx"; import { HoursTableAtom } from "../atoms/hoursTable.tsx"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; import { useDocument } from "../../hooks/useDocument.tsx"; import { YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; import { msg, pt } from "../../utils/i18n/platform.ts"; import { Body } from "../atoms/body.tsx"; -import { YextComponentConfig, YextFields } from "../../fields/fields.ts"; +import { + YextComponentConfig, + YextFields, + YextFieldDefinition, +} from "../../fields/fields.ts"; /** Props for the HoursTable component. */ export interface HoursTableProps { @@ -33,19 +34,19 @@ export interface HoursTableProps { } // HoursTable data field used in HoursTable and CoreInfoSection -export const HoursTableDataField: YextFields["hours"] = - { - type: "entityField", - label: msg("fields.hours", "Hours"), - filter: { - types: ["type.hours"], - }, - }; +export const HoursTableDataField = { + type: "entityField", + + label: msg("fields.hours", "Hours"), + filter: { + types: ["type.hours"], + }, +} satisfies YextFieldDefinition; type HoursTableStyleFieldProps = Omit; // HoursTable style fields used in HoursTable and CoreInfoSection -export const HoursTableStyleFields: YextFields = { +export const HoursTableStyleFields = { startOfWeek: { type: "basicSelector", label: msg("fields.startOfTheWeek", "Start of the Week"), @@ -70,9 +71,9 @@ export const HoursTableStyleFields: YextFields = { { label: msg("fields.options.no", "No"), value: false }, ], }, -}; +} satisfies YextFields; -export const hoursTableFields: YextFields = { +export const hoursTableFields = { data: { type: "object", label: msg("fields.data", "Data"), @@ -98,14 +99,13 @@ export const hoursTableFields: YextFields = { }, }, }, -}; +} satisfies YextFields; -const VisualEditorHoursTable: PuckComponent = (props) => { +const VisualEditorHoursTable: typeof HoursTable.render = (props) => { const { data, styles, puck } = props; - const { i18n } = useTranslation(); const streamDocument = useDocument(); const comingSoon = !!streamDocument.comingSoon; - const hours = resolveComponentData(data.hours, i18n.language, streamDocument); + const hours = data.hours; const { additionalHoursText } = streamDocument as { additionalHoursText: string; @@ -113,11 +113,7 @@ const VisualEditorHoursTable: PuckComponent = (props) => { return hours || comingSoon ? (
- + = (props) => { ); }; -export const HoursTable: YextComponentConfig = { +export const HoursTable: YextComponentConfig< + HoursTableProps, + typeof hoursTableFields +> = { fields: hoursTableFields, defaultProps: { data: { @@ -160,5 +159,5 @@ export const HoursTable: YextComponentConfig = { }, }, label: msg("components.hoursTable", "Hours Table"), - render: (props) => , + render: VisualEditorHoursTable, }; diff --git a/packages/visual-editor/src/components/contentBlocks/MapboxStaticMap.tsx b/packages/visual-editor/src/components/contentBlocks/MapboxStaticMap.tsx index 4d9f94f200..ca0fa51067 100644 --- a/packages/visual-editor/src/components/contentBlocks/MapboxStaticMap.tsx +++ b/packages/visual-editor/src/components/contentBlocks/MapboxStaticMap.tsx @@ -1,14 +1,14 @@ import { useTranslation } from "react-i18next"; import { Coordinate } from "@yext/pages-components"; import { EntityField } from "../../editor/EntityField.tsx"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; + import { useDocument } from "../../hooks/useDocument.tsx"; import { YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; import { type BasicSelectorField } from "../../fields/BasicSelectorField.tsx"; import { msg, pt } from "../../utils/i18n/platform.ts"; import { themeManagerCn } from "../../utils/cn.ts"; import { Body } from "../atoms/body.tsx"; -import { PuckComponent } from "@puckeditor/core"; + import { StreamDocument } from "../../utils/types/StreamDocument.ts"; import mapboxLogo from "../assets/mapbox-logo-black.svg"; import { Map } from "lucide-react"; @@ -46,14 +46,14 @@ export const mapStyleField: BasicSelectorField = { options: mapboxStaticMapStyleOptions, }; -const mapboxFields: YextFields = { +const mapboxFields = { coordinate: { type: "entityField", label: msg("fields.coordinates", "Coordinates"), filter: { types: ["type.coordinate"] }, }, mapStyle: mapStyleField, -}; +} satisfies YextFields; const getPrimaryColor = (streamDocument: StreamDocument) => { return ( @@ -63,22 +63,16 @@ const getPrimaryColor = (streamDocument: StreamDocument) => { ); }; -export const MapboxStaticMapComponent: PuckComponent = ({ - coordinate: coordinateField, +export const MapboxStaticMapComponent: typeof MapboxStaticMap.render = ({ + coordinate, height = "300px", zoom = 14, mapStyle = "light-v11", puck, }) => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const streamDocument = useDocument(); - const coordinate = resolveComponentData( - coordinateField, - i18n.language, - streamDocument - ); - // If we are in the layout editor, use the non-URL-restricted Mapbox API key const iframe = typeof document === "undefined" @@ -124,7 +118,7 @@ export const MapboxStaticMapComponent: PuckComponent = ({ } if (!coordinate) { - console.warn(`${coordinateField.field} is not present in the stream`); + console.warn("Map coordinates are not present in the stream"); return <>; } @@ -145,8 +139,6 @@ export const MapboxStaticMapComponent: PuckComponent = ({ return (
@@ -188,7 +180,10 @@ export const MapboxStaticMapComponent: PuckComponent = ({ ); }; -export const MapboxStaticMap: YextComponentConfig = { +export const MapboxStaticMap: YextComponentConfig< + MapboxStaticProps, + typeof mapboxFields +> = { label: msg("components.mapboxStaticMap", "Mapbox Static Map"), fields: mapboxFields, defaultProps: { @@ -201,5 +196,5 @@ export const MapboxStaticMap: YextComponentConfig = { }, mapStyle: "streets-v12", }, - render: (props) => , + render: MapboxStaticMapComponent, }; diff --git a/packages/visual-editor/src/components/contentBlocks/Phone.tsx b/packages/visual-editor/src/components/contentBlocks/Phone.tsx index 845a79f401..37507485b2 100644 --- a/packages/visual-editor/src/components/contentBlocks/Phone.tsx +++ b/packages/visual-editor/src/components/contentBlocks/Phone.tsx @@ -1,6 +1,3 @@ -import { useTranslation } from "react-i18next"; -import { useDocument } from "../../hooks/useDocument.tsx"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; import { EntityField } from "../../editor/EntityField.tsx"; import { YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; import { PhoneAtom } from "../atoms/phone.tsx"; @@ -42,7 +39,7 @@ export interface PhoneProps { } // Phone field definitions used in Phone and CoreInfoSection -export const PhoneDataFields: YextFields = { +export const PhoneDataFields = { number: { type: "entityField", label: msg("fields.phoneNumber", "Phone Number"), @@ -55,10 +52,10 @@ export const PhoneDataFields: YextFields = { label: msg("fields.label", "Label"), filter: { types: ["type.string"] }, }, -}; +} satisfies YextFields; // Phone style definitions used in Phone and CoreInfoSection -export const PhoneStyleFields: YextFields = { +export const PhoneStyleFields = { phoneFormat: { label: msg("fields.phoneFormat", "Phone Format"), type: "radio", @@ -83,7 +80,7 @@ export const PhoneStyleFields: YextFields = { label: msg("fields.color", "Color"), options: "SITE_COLOR", }, -}; +} satisfies YextFields; export const defaultPhoneDataProps: PhoneProps["data"] = { number: { @@ -93,7 +90,7 @@ export const defaultPhoneDataProps: PhoneProps["data"] = { label: { defaultValue: "Phone" }, }; -export const PhoneFields: YextFields = { +export const PhoneFields = { data: { type: "object", label: msg("fields.data", "Data"), @@ -104,17 +101,13 @@ export const PhoneFields: YextFields = { label: msg("fields.styles", "Styles"), objectFields: PhoneStyleFields, }, -}; +} satisfies YextFields; -const PhoneComponent = ({ data, styles, parentData }: PhoneProps) => { - const { i18n } = useTranslation(); - const streamDocument = useDocument(); - const resolvedPhone = - parentData?.phoneNumber ?? - resolveComponentData(data.number, i18n.language, streamDocument); +const PhoneComponent: typeof Phone.render = ({ data, styles, parentData }) => { + const resolvedPhone = parentData?.phoneNumber ?? data.number; if (!resolvedPhone) { - return; + return <>; } return ( @@ -122,14 +115,12 @@ const PhoneComponent = ({ data, styles, parentData }: PhoneProps) => { displayName={ parentData ? parentData.field : pt("fields.phoneNumber", "Phone Number") } - fieldId={data.number.field} - constantValueEnabled={!parentData && data.number.constantValueEnabled} > { ); }; -export const Phone: YextComponentConfig = { +export const Phone: YextComponentConfig = { label: msg("components.phone", "Phone"), fields: PhoneFields, defaultProps: { @@ -151,5 +142,5 @@ export const Phone: YextComponentConfig = { }, }, resolveFields: (data) => resolveDataFromParent(PhoneFields, data), - render: (props) => , + render: PhoneComponent, }; diff --git a/packages/visual-editor/src/components/contentBlocks/image/Image.tsx b/packages/visual-editor/src/components/contentBlocks/image/Image.tsx index 66a87d81ee..c95ee80f05 100644 --- a/packages/visual-editor/src/components/contentBlocks/image/Image.tsx +++ b/packages/visual-editor/src/components/contentBlocks/image/Image.tsx @@ -1,9 +1,7 @@ -import { PuckComponent, setDeep } from "@puckeditor/core"; -import { ComplexImageType, ImageType } from "@yext/pages-components"; +import { setDeep } from "@puckeditor/core"; +import { ImageType } from "@yext/pages-components"; import { AssetImageType, - isLocalizedAssetImage, - resolveLocalizedAssetImage, TranslatableAssetImage, } from "../../../types/images.ts"; import { EntityField } from "../../../editor/EntityField.tsx"; @@ -16,11 +14,10 @@ import { MaybeLink } from "../../atoms/maybeLink.tsx"; import { TranslatableString } from "../../../types/types.ts"; import { YextEntityField } from "../../../editor/YextEntityFieldSelector.tsx"; import { msg, pt } from "../../../utils/i18n/platform.ts"; -import { resolveComponentData } from "../../../utils/resolveComponentData.tsx"; + import { resolveDataFromParent } from "../../../editor/ParentData.tsx"; -import { useDocument } from "../../../hooks/useDocument.tsx"; + import * as React from "react"; -import { useTranslation } from "react-i18next"; import { EmptyImageState } from "./EmptyImageState.tsx"; import { ImageStylingFields, ImageStylingProps } from "./styling.ts"; import { YextComponentConfig, YextFields } from "../../../fields/fields.ts"; @@ -32,9 +29,7 @@ const LINK_REGEX_VALIDATION = /^(https?:\/\/[^\s]+|\/[^\s]*|#[^\s]*)$/; export interface ImageWrapperProps { data: { /** The image to display. */ - image: YextEntityField< - ImageType | ComplexImageType | TranslatableAssetImage - >; + image: YextEntityField; link?: TranslatableString; }; @@ -44,7 +39,7 @@ export interface ImageWrapperProps { /** @internal Controlled data from the parent section. */ parentData?: { field: string; - image: ImageType | ComplexImageType | TranslatableAssetImage | undefined; + image: ImageType | undefined; }; /** Additional CSS classes to apply to the image. */ @@ -58,13 +53,13 @@ export interface ImageWrapperProps { showImageConstrain?: boolean; } -export const ImageWrapperFields: YextFields = { +export const ImageWrapperFields = { data: { type: "object", label: msg("fields.data", "Data"), objectFields: { image: { - type: "entityField", + type: "image", label: msg("fields.options.image", "Image"), filter: { types: ["type.image"], @@ -92,28 +87,9 @@ export const ImageWrapperFields: YextFields = { ], visible: false, }, -}; - -export const getImageUrl = ( - image: ImageType | ComplexImageType | TranslatableAssetImage | undefined, - locale: string -): string | undefined => { - if (!image) { - return undefined; - } - - if (isLocalizedAssetImage(image)) { - return resolveLocalizedAssetImage(image, locale)?.url; - } +} satisfies YextFields; - if ("image" in image) { - return image.image?.url; - } - - return image.url; -}; - -const ImageWrapperComponent: PuckComponent = (props) => { +const ImageWrapperComponent: typeof ImageWrapper.render = (props) => { const { data, styles, @@ -129,25 +105,17 @@ const ImageWrapperComponent: PuckComponent = (props) => { hideWidthProp, showImageConstrain = false, } = props; - const { i18n } = useTranslation(); - const streamDocument = useDocument(); - const resolvedImage = React.useMemo(() => { - return parentData - ? parentData?.image - : resolveComponentData(data.image, i18n.language, streamDocument); - }, [parentData, data.image, i18n.language, streamDocument]); + const resolvedImage = parentData ? parentData.image : data.image; + const source = puck.metadata.fieldSources?.get(`${props.id}:data.image`) as + ImageWrapperProps["data"]["image"] | undefined; - const imageUrl = getImageUrl(resolvedImage, i18n.language); + const imageUrl = resolvedImage?.url; const isEmpty = !resolvedImage || !imageUrl || (typeof imageUrl === "string" && imageUrl.trim() === ""); - const inputLink = resolveComponentData( - data.link ?? { defaultValue: DEFAULT_LINK }, - i18n.language, - streamDocument - ); + const inputLink = data.link; const resolvedLink = typeof inputLink === "string" && @@ -161,9 +129,9 @@ const ImageWrapperComponent: PuckComponent = (props) => { = (props) => { return ( @@ -225,9 +193,11 @@ export const imageDefaultProps = { image: { field: "", constantValue: { - url: PLACEHOLDER_IMAGE_URL, - height: 360, - width: 640, + defaultValue: { + url: PLACEHOLDER_IMAGE_URL, + height: 360, + width: 640, + }, }, constantValueEnabled: true, }, @@ -241,7 +211,10 @@ export const imageDefaultProps = { allowWidthProp: true, }; -export const ImageWrapper: YextComponentConfig = { +export const ImageWrapper: YextComponentConfig< + ImageWrapperProps, + typeof ImageWrapperFields +> = { label: msg("components.image", "Image"), inline: true, fields: ImageWrapperFields, @@ -271,5 +244,5 @@ export const ImageWrapper: YextComponentConfig = { return fields; }, - render: (props) => , + render: ImageWrapperComponent, }; diff --git a/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx b/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx index c5b869ccea..09120e50ea 100644 --- a/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx +++ b/packages/visual-editor/src/components/helpers/ComprehensiveCTA.tsx @@ -1,15 +1,11 @@ import React from "react"; -import { useTranslation } from "react-i18next"; +import type { PuckComponent } from "@puckeditor/core"; 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, @@ -19,7 +15,23 @@ import { type StyledButtonValue } from "../../fields/styledFields/StyledButtonFi import { type StyledLinkValue } from "../../fields/styledFields/StyledLinkField.tsx"; export type ComprehensiveCTARenderProps = { - value?: Partial; + value?: Omit, "data" | "sx"> & { + /** Puck maps CSS string intersections as objects; both types carry the same CSS values. */ + sx?: + | React.CSSProperties + | Parameters>[0]["sx"]; + data?: Omit< + ComprehensiveCTAValue["data"], + "cta" | "buttonText" | "ariaLabel" + > & { + cta?: Omit & { + label?: string; + link?: string; + }; + buttonText?: string; + ariaLabel?: string; + }; + }; label?: React.ReactNode; ariaLabel?: string; className?: string; @@ -34,26 +46,26 @@ const resolveTextStyleValue = (value: string | undefined) => value && value !== "default" ? value : undefined; const getComprehensiveCTAStyle = ( - value: ComprehensiveCTAValue + value: NonNullable ): React.CSSProperties | undefined => { const ctaType = - value.data.actionType === "button" + value.data?.actionType === "button" ? "textAndLink" - : getCTAType(value.data.cta).ctaType; + : value.data?.cta?.ctaType; if (ctaType === "presetImage") { - return value.sx; + return value.sx as React.CSSProperties | undefined; } const typographyStyles: StyledButtonValue | StyledLinkValue = - value.styles.variant === "link" - ? (value.styles.link ?? value.styles.button ?? defaultLinkStyleValue) - : (value.styles.button ?? defaultButtonStyleValue); + value.styles?.variant === "link" + ? (value.styles?.link ?? value.styles?.button ?? defaultLinkStyleValue) + : (value.styles?.button ?? defaultButtonStyleValue); const style: React.CSSProperties & { "--display-link-caret"?: string; } = { - ...value.sx, + ...(value.sx as React.CSSProperties), }; const fontFamily = resolveTextStyleValue(typographyStyles.fontFamily); @@ -123,6 +135,7 @@ const toDataAttributes = ( ); }; +/** Render transformed CTA content while retaining action, styling, and analytics behavior. */ export const ComprehensiveCTA = ({ value, label, @@ -133,85 +146,53 @@ export const ComprehensiveCTA = ({ target, alwaysHideCaret, onClick, -}: ComprehensiveCTARenderProps) => { - const streamDocument = useDocument(); - const { t, i18n } = useTranslation(); - const locale = i18n.language; - const currentValue = normalizeComprehensiveCTAValue(value); - - 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"); - } +}: ComprehensiveCTARenderProps): React.ReactElement | null => { + const actionType = value?.data?.actionType ?? "link"; + const ctaType = + actionType === "button" ? "textAndLink" : value?.data?.cta?.ctaType; + const cta = value?.data?.cta; const effectiveLabel = label !== undefined ? label : actionType === "button" - ? resolvedButtonLabel - : resolvedLinkLabel; + ? value?.data?.buttonText + : cta?.label; const showCTA = label !== undefined ? label !== null && label !== false : actionType === "button" - ? Boolean(resolvedButtonLabel?.trim()) - : Boolean( - resolvedCta && (ctaType === "presetImage" || resolvedLinkLabel) - ); + ? Boolean(value?.data?.buttonText?.trim()) + : Boolean(cta && (ctaType === "presetImage" || cta?.label)); if (!showCTA) { return null; } const resolvedClassName = themeManagerCn( - currentValue.className, + value?.className, actionType === "link" && ctaType === "presetImage" && - currentValue.styles.presetImage && + value?.styles?.presetImage && (FOOD_DELIVERY_SERVICES as readonly string[]).includes( - currentValue.styles.presetImage + value?.styles?.presetImage ) ? "!justify-start" : undefined, - actionType === "button" ? currentValue.data.customClass : undefined, + actionType === "button" ? value?.data?.customClass : undefined, className ); const resolvedStyle = { - ...getComprehensiveCTAStyle(currentValue), + ...getComprehensiveCTAStyle(value ?? {}), ...style, }; const resolvedAriaLabel = ariaLabel ?? (actionType === "button" - ? resolvedFieldAriaLabel || undefined + ? value?.data?.ariaLabel || undefined : typeof effectiveLabel === "string" ? effectiveLabel : undefined); @@ -222,36 +203,34 @@ export const ComprehensiveCTA = ({ ariaLabel={resolvedAriaLabel} alwaysHideCaret={alwaysHideCaret} className={resolvedClassName} - color={currentValue.styles.color} + color={value?.styles?.color} ctaType={ctaType} dataAttributes={ actionType === "button" - ? toDataAttributes(currentValue.data.dataAttributes) + ? toDataAttributes(value?.data?.dataAttributes) : undefined } - eventName={eventName ?? currentValue.eventName} - id={actionType === "button" ? currentValue.data.customId : undefined} + eventName={eventName ?? value?.eventName} + id={actionType === "button" ? value?.data?.customId : undefined} label={effectiveLabel} link={ - actionType === "link" && ctaType !== "getDirections" && resolvedCta - ? resolveComponentData(resolvedCta.link, locale, streamDocument) + actionType === "link" && ctaType !== "getDirections" && cta + ? cta.link : undefined } - linkType={ - actionType === "link" && resolvedCta ? resolvedCta.linkType : undefined - } + linkType={actionType === "link" && cta ? cta.linkType : undefined} normalizeLink={true} onClick={onClick} openInNewTab={ - actionType === "link" ? currentValue.data.openInNewTab : undefined + actionType === "link" ? value?.data?.openInNewTab : undefined } presetImageType={ - actionType === "link" ? currentValue.styles.presetImage : undefined + actionType === "link" ? value?.styles?.presetImage : undefined } setPadding={true} style={resolvedStyle} target={target} - variant={currentValue.styles.variant} + variant={value?.styles?.variant ?? "primary"} /> ); }; diff --git a/packages/visual-editor/src/components/helpers/styledFields/StyledText.test.tsx b/packages/visual-editor/src/components/helpers/styledFields/StyledText.test.tsx index 8acad7ea28..de1ad14d26 100644 --- a/packages/visual-editor/src/components/helpers/styledFields/StyledText.test.tsx +++ b/packages/visual-editor/src/components/helpers/styledFields/StyledText.test.tsx @@ -64,11 +64,7 @@ describe("Styled text content blocks", () => { { { { Hello world

" }, - }, - constantValueEnabled: true, - }, + text: { html: "

Hello world

" }, }, fontOptions: { color: { @@ -207,13 +189,7 @@ describe("Styled text content blocks", () => { {...{ data: { text: { - field: "", - constantValue: { - defaultValue: { - html: "

Hero description

", - }, - }, - constantValueEnabled: true, + html: "

Hero description

", }, }, fontOptions: { @@ -240,15 +216,11 @@ describe("Styled text content blocks", () => { expect(wrapper.className).toContain("text-palette-secondary"); }); - it("applies cloned rich-text typography styles to the wrapper element", () => { + it("applies rich-text typography styles to the wrapper element", () => { const { container } = renderWithProviders( <> {renderStyledRichText({ - content: ( -
-
Wrapped rich text
-
- ), + content: { html: '
Wrapped rich text
' }, align: "center", text: { color: { @@ -302,11 +274,7 @@ describe("Styled text content blocks", () => { {...sharedTitleProps} kind="plain" data={{ - text: { - field: "cards.0.title", - constantValue: { defaultValue: "Repeated title" }, - constantValueEnabled: true, - }, + text: "Repeated title", }} /> ); @@ -340,13 +308,7 @@ describe("Styled text content blocks", () => { kind="richText" data={{ text: { - field: "cards.0.description", - constantValue: { - defaultValue: { - html: "

Repeated body copy

", - }, - }, - constantValueEnabled: true, + html: "

Repeated body copy

", }, }} /> @@ -461,15 +423,40 @@ describe("Styled text content blocks", () => { {...sharedDefaultProps} kind="plain" data={{ - text: { - field: "cards.1.title", - constantValue: { defaultValue: "Card title" }, - constantValueEnabled: true, - }, + text: "Card title", }} /> ); expect(screen.getByText("Card title").tagName).toBe("H3"); }); + + it.each([ + { content: "Literal [[name]]", expected: "Literal [[name]]" }, + { + content: { html: "

Literal [[name]]

" }, + expected: "Literal [[name]]", + }, + { content: { html: " " }, expected: "" }, + { content: undefined, expected: "" }, + ])( + "when resolved content is supplied directly then it renders without another interpolation pass", + ({ content, expected }) => { + const view = render( + + ); + expect(view.container.textContent).toBe(expected); + view.unmount(); + } + ); }); diff --git a/packages/visual-editor/src/components/helpers/styledFields/createStyledTextConfig.tsx b/packages/visual-editor/src/components/helpers/styledFields/createStyledTextConfig.tsx index ed2a09a4cb..b104f6c64c 100644 --- a/packages/visual-editor/src/components/helpers/styledFields/createStyledTextConfig.tsx +++ b/packages/visual-editor/src/components/helpers/styledFields/createStyledTextConfig.tsx @@ -1,20 +1,16 @@ import * as React from "react"; -import { useTranslation } from "react-i18next"; import { type YextEntityField } from "../../../editor/YextEntityFieldSelector.tsx"; -import { EntityField } from "../../../editor/EntityField.tsx"; import { type YextComponentConfig, type YextFields, } from "../../../fields/fields.ts"; import { type StyledTextValue } from "../../../fields/styledFields/StyledTextField.tsx"; -import { useBackground } from "../../../hooks/useBackground.tsx"; -import { useDocument } from "../../../hooks/useDocument.tsx"; import { + type RichText, type TranslatableRichText, type TranslatableString, } from "../../../types/types.ts"; -import { msg, pt } from "../../../utils/i18n/platform.ts"; -import { resolveComponentData } from "../../../utils/resolveComponentData.tsx"; +import { msg } from "../../../utils/i18n/platform.ts"; import { ThemeOptions } from "../../../utils/themeConfigOptions.ts"; import { renderStyledRichText, @@ -26,15 +22,17 @@ import { type StyledTextConfigProps = { data: { - text: YextEntityField; + text: TText; }; alignment?: StyledTextAlignment; fontOptions: StyledTextValue; tag?: StyledTextTag; }; -export type StyledPlainTextProps = StyledTextConfigProps; -export type StyledRichTextProps = StyledTextConfigProps; +export type StyledPlainTextProps> = + StyledTextConfigProps; +export type StyledRichTextProps> = + StyledTextConfigProps; type CreateStyledTextConfigOptions = { label: string; @@ -78,14 +76,15 @@ const buildFields = ({ colorLabelOverride, alignmentLabelOverride, tagLabelOverride, -}: CreateStyledTextConfigOptions): YextFields> => { - const fields: YextFields> = { +}: CreateStyledTextConfigOptions) => { + const fields = { data: { label: textLabelOverride ?? msg("fields.text", "Text"), type: "object", objectFields: { text: { type: "entityField", + label: msg("fields.text", "Text"), filter: { types: @@ -103,28 +102,32 @@ const buildFields = ({ includeColor, colorLabel: colorLabelOverride ?? msg("fields.fontColor", "Font Color"), }, - }; - - if (includeAlignment) { - fields.alignment = { - label: alignmentLabelOverride ?? msg("fields.align", "Alignment"), - type: "radio", - options: ThemeOptions.ALIGNMENT, - }; - } + } satisfies YextFields>>; - if (kind === "plain" && tagOptions?.length) { - fields.tag = { - label: tagLabelOverride ?? msg("fields.tag", "Tag"), - type: "select", - options: tagOptions.map((tag) => ({ - label: tag.toUpperCase(), - value: tag, - })), - }; - } - - return fields; + return { + ...fields, + ...(includeAlignment + ? { + alignment: { + label: alignmentLabelOverride ?? msg("fields.align", "Alignment"), + type: "radio" as const, + options: ThemeOptions.ALIGNMENT, + }, + } + : {}), + ...(kind === "plain" && tagOptions?.length + ? { + tag: { + label: tagLabelOverride ?? msg("fields.tag", "Tag"), + type: "select" as const, + options: tagOptions.map((tag) => ({ + label: tag.toUpperCase(), + value: tag, + })), + }, + } + : {}), + }; }; const getDefaultTag = ( @@ -137,76 +140,52 @@ const getDefaultTag = ( return tagOptions.includes("span") ? "span" : tagOptions[0]; }; -export const StyledTextComponent = < - TText extends TranslatableString | TranslatableRichText, ->( - props: StyledTextConfigProps & { - kind: CreateStyledTextConfigOptions["kind"]; - } -) => { - const { data, alignment, fontOptions, kind } = props; - const { i18n } = useTranslation(); - const streamDocument = useDocument(); - const background = useBackground(); - const sourceData = data.text; - - if (kind === "plain") { - const resolvedText = resolveComponentData( - sourceData, - i18n.language, - streamDocument - ); - - return resolvedText ? ( - & { kind: "plain" }) + | (StyledRichTextProps & { + kind: "richText"; + }) +): React.ReactElement => { + if (props.kind === "plain") { + return props.data.text ? ( + - - {resolvedText} - - + {props.data.text} + ) : ( <> ); } - const resolvedData = sourceData - ? resolveComponentData(sourceData, i18n.language, streamDocument, { - isDarkBackground: background?.isDarkColor, - }) - : undefined; - - return React.isValidElement(resolvedData) || - typeof resolvedData === "string" ? ( - + return ( + <> {renderStyledRichText({ - content: resolvedData, - align: alignment, - text: fontOptions, + content: props.data.text, + align: props.alignment, + text: props.fontOptions, })} - - ) : ( - <> + ); }; export function createStyledTextConfig( options: CreateStyledTextConfigOptions & { kind: "plain" } -): YextComponentConfig; +): YextComponentConfig< + StyledPlainTextProps, + ReturnType> +>; export function createStyledTextConfig( options: CreateStyledTextConfigOptions & { kind: "richText" } -): YextComponentConfig; +): YextComponentConfig< + StyledRichTextProps, + ReturnType> +>; /** * Creates a styled text component config for plain or rich text content. * @@ -219,8 +198,14 @@ export function createStyledTextConfig( export function createStyledTextConfig( options: CreateStyledTextConfigOptions ): - | YextComponentConfig - | YextComponentConfig { + | YextComponentConfig< + StyledPlainTextProps, + ReturnType> + > + | YextComponentConfig< + StyledRichTextProps, + ReturnType> + > { const defaultProps: Pick< StyledPlainTextProps, "data" | "fontOptions" | "alignment" @@ -242,7 +227,9 @@ export function createStyledTextConfig( fields: buildFields(options), defaultProps, render: ( - props: StyledRichTextProps & { puck: { isEditing: boolean } } + props: StyledRichTextProps & { + puck: { isEditing: boolean }; + } ) => { const { puck: _, ...styledTextProps } = props; return ; @@ -260,7 +247,9 @@ export function createStyledTextConfig( : {}), }, render: ( - props: StyledPlainTextProps & { puck: { isEditing: boolean } } + props: StyledPlainTextProps & { + puck: { isEditing: boolean }; + } ) => { const { puck: _, ...styledTextProps } = props; return ; diff --git a/packages/visual-editor/src/components/helpers/styledFields/styledText.tsx b/packages/visual-editor/src/components/helpers/styledFields/styledText.tsx index d60e427154..43c4eba5dc 100644 --- a/packages/visual-editor/src/components/helpers/styledFields/styledText.tsx +++ b/packages/visual-editor/src/components/helpers/styledFields/styledText.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import type { RichText } from "../../../types/types.ts"; import { MaybeRTF } from "../maybeRTF.tsx"; import { type StyledTextValue } from "../../../fields/styledFields/StyledTextField.tsx"; import { getTextColorClass, getTextColorStyle } from "../../../utils/colors.ts"; @@ -93,7 +94,7 @@ export const renderStyledRichText = ({ className, text, }: { - content: React.ReactNode; + content: string | RichText | undefined; align?: StyledTextAlignment; className?: string; text?: StyledTextValue; @@ -138,61 +139,14 @@ export const renderStyledRichText = ({ ); } - if (!React.isValidElement(content)) { - return content; - } - - if (content.type === MaybeRTF) { - return ( - - ); - } - - if ( - React.isValidElement(content.props.children) && - typeof content.props.children.props.className === "string" && - content.props.children.props.className.includes("rtf-wrapper") - ) { - return React.cloneElement(content, { - children: React.cloneElement(content.props.children, { - className: themeManagerCn( - content.props.children.props.className, - richTextClassName - ), - style: { - ...content.props.children.props.style, - ...richTextStyle, - }, - }), - }); - } - - const innerContent = React.isValidElement(content.props.children) - ? React.cloneElement(content.props.children, { - className: themeManagerCn(content.props.children.props.className), - style: content.props.children.props.style, - }) - : content.props.children; - - return React.cloneElement(content, { - className: themeManagerCn(content.props.className, richTextClassName), - style: { - ...content.props.style, - ...richTextStyle, - }, - children: innerContent, - }); + return content?.html?.trim() ? ( + + ) : null; }; type StyledTextElementProps = { diff --git a/packages/visual-editor/src/components/migrations/0083_image_field.ts b/packages/visual-editor/src/components/migrations/0083_image_field.ts new file mode 100644 index 0000000000..745820f940 --- /dev/null +++ b/packages/visual-editor/src/components/migrations/0083_image_field.ts @@ -0,0 +1,15 @@ +import { Migration } from "../../utils/migrate.ts"; +import { migrateImageField } from "../../utils/migrateImageField.ts"; + +export const imageFieldMigration = { + ImageWrapper: { + action: "updated", + propTransformation: (props, streamDocument) => ({ + ...props, + data: { + ...props.data, + image: migrateImageField(props.data?.image, streamDocument), + }, + }), + }, +} satisfies Migration; diff --git a/packages/visual-editor/src/components/migrations/migrationRegistry.ts b/packages/visual-editor/src/components/migrations/migrationRegistry.ts index bf6be857b0..cd92df1107 100644 --- a/packages/visual-editor/src/components/migrations/migrationRegistry.ts +++ b/packages/visual-editor/src/components/migrations/migrationRegistry.ts @@ -81,6 +81,7 @@ import { imageFillTypeMigration } from "./0079_image_fill_type.ts"; import { directoryCardDisplayProps } from "./0080_directory_card_display_props.ts"; import { headerFooterImageFillTypeMigration } from "./0081_header_footer_image_fill_type.ts"; import { heroPhoneSlotMigration } from "./0082_hero_phone_slot.ts"; +import { imageFieldMigration } from "./0083_image_field.ts"; // To add a migration: // Create a new file in this directory that exports a Migration @@ -170,4 +171,5 @@ export const migrationRegistry: MigrationRegistry = [ directoryCardDisplayProps, headerFooterImageFillTypeMigration, heroPhoneSlotMigration, + imageFieldMigration, ]; diff --git a/packages/visual-editor/src/components/sections/Breadcrumbs.test.tsx b/packages/visual-editor/src/components/sections/Breadcrumbs.test.tsx index 0d6b8ed4f1..900ec0eadf 100644 --- a/packages/visual-editor/src/components/sections/Breadcrumbs.test.tsx +++ b/packages/visual-editor/src/components/sections/Breadcrumbs.test.tsx @@ -1,3 +1,4 @@ +import { toPuckFields, type YextComponentConfig } from "../../fields/fields.ts"; import * as React from "react"; import { describe, it, expect } from "vitest"; import { @@ -126,7 +127,15 @@ const tests: ComponentTest[] = [ describe("BreadcrumbsSection", async () => { const puckConfig: Config = { - components: { BreadcrumbsSection, MainContent }, + components: Object.fromEntries( + Object.entries>({ + BreadcrumbsSection, + MainContent, + }).map(([name, component]) => [ + name, + { ...component, fields: toPuckFields(component.fields ?? {}) }, + ]) + ), root: { render: ({ children }: { children: React.ReactNode }) => { return <>{children}; diff --git a/packages/visual-editor/src/components/sections/Breadcrumbs.tsx b/packages/visual-editor/src/components/sections/Breadcrumbs.tsx index b9f22fd07e..9e662c1321 100644 --- a/packages/visual-editor/src/components/sections/Breadcrumbs.tsx +++ b/packages/visual-editor/src/components/sections/Breadcrumbs.tsx @@ -10,7 +10,7 @@ import { backgroundColors, ThemeOptions, } from "../../utils/themeConfigOptions.ts"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; + import { setDeep } from "@puckeditor/core"; import { AnalyticsScopeProvider } from "@yext/pages-components"; import { ComponentErrorBoundary } from "../../internal/components/ComponentErrorBoundary.tsx"; @@ -85,7 +85,7 @@ export interface BreadcrumbsSectionProps { liveVisibility: boolean; } -const breadcrumbsSectionFields: YextFields = { +const breadcrumbsSectionFields = { data: { type: "object", label: msg("fields.data", "Data"), @@ -148,7 +148,7 @@ const breadcrumbsSectionFields: YextFields = { { label: msg("fields.options.hide", "Hide"), value: false }, ], }, -}; +} satisfies YextFields; // BreadcrumbsComponent renders breadcrumbs for DM related pages. // If there are no dm_directoryParents nor dm_directoryChildren, @@ -158,21 +158,12 @@ const breadcrumbsSectionFields: YextFields = { export const BreadcrumbsComponent = ({ data, styles, -}: BreadcrumbsSectionProps) => { - const { t, i18n } = useTranslation(); +}: Parameters[0]) => { + const { t } = useTranslation(); const separator = "/"; const { document: streamDocument, relativePrefixToRoot } = useTemplateProps(); const breadcrumbs = resolveBreadcrumbs(streamDocument); - const directoryRoot = resolveComponentData( - data.directoryRoot, - i18n.language, - streamDocument - ); - const currentPage = resolveComponentData( - data.currentPage, - i18n.language, - streamDocument - ); + const { directoryRoot, currentPage } = data; const breadcrumbsToRender = breadcrumbs .map((breadcrumb, index) => ({ ...breadcrumb, index })) .filter( @@ -239,60 +230,60 @@ export const BreadcrumbsComponent = ({ * The Breadcrumbs component automatically generates and displays a navigational hierarchy based on a page's position within a Yext directory structure. It renders a list of links showing the path from the main directory root to the current page, helping users understand their location on the site. * Available on Location templates. */ -export const BreadcrumbsSection: YextComponentConfig = - { - label: msg("components.breadcrumbs", "Breadcrumbs"), - fields: breadcrumbsSectionFields, - resolveFields: (_data, params) => { - const streamDocument = params.metadata?.streamDocument; - if (!streamDocument) { - return toPuckFields(breadcrumbsSectionFields); - } +export const BreadcrumbsSection: YextComponentConfig< + BreadcrumbsSectionProps, + typeof breadcrumbsSectionFields +> = { + label: msg("components.breadcrumbs", "Breadcrumbs"), + fields: breadcrumbsSectionFields, + resolveFields: (_data, params) => { + const streamDocument = params.metadata?.streamDocument; + if (!streamDocument) { + return toPuckFields(breadcrumbsSectionFields); + } - // On root pages there is only one breadcrumb, so "currentPage" duplicates "directoryRoot". - const breadcrumbCount = resolveBreadcrumbs(streamDocument).length; - return setDeep( - toPuckFields(breadcrumbsSectionFields), - "data.objectFields.currentPage.visible", - breadcrumbCount !== 1 - ); - }, - defaultProps: { - data: { - directoryRoot: { defaultValue: "Directory Root" }, - currentPage: { - constantValue: { defaultValue: "[[name]]" }, - field: "name", - constantValueEnabled: false, - }, - }, - styles: { - backgroundColor: backgroundColors.background1.value, - showCurrentPage: true, - }, - analytics: { - scope: "breadcrumbs", + // On root pages there is only one breadcrumb, so "currentPage" duplicates "directoryRoot". + const breadcrumbCount = resolveBreadcrumbs(streamDocument).length; + return setDeep( + toPuckFields(breadcrumbsSectionFields), + "data.objectFields.currentPage.visible", + breadcrumbCount !== 1 + ); + }, + defaultProps: { + data: { + directoryRoot: { defaultValue: "Directory Root" }, + currentPage: { + constantValue: { defaultValue: "[[name]]" }, + field: "name", + constantValueEnabled: false, }, - liveVisibility: true, }, - render: (props) => { - return ( - - - - - - - - ); + styles: { + backgroundColor: backgroundColors.background1.value, + showCurrentPage: true, }, - }; + analytics: { + scope: "breadcrumbs", + }, + liveVisibility: true, + }, + render: (props) => { + return ( + + + + + + + + ); + }, +}; diff --git a/packages/visual-editor/src/components/sections/customCode/CustomCodeSection.tsx b/packages/visual-editor/src/components/sections/customCode/CustomCodeSection.tsx index 84d76369d0..2054d289e9 100644 --- a/packages/visual-editor/src/components/sections/customCode/CustomCodeSection.tsx +++ b/packages/visual-editor/src/components/sections/customCode/CustomCodeSection.tsx @@ -5,7 +5,6 @@ import { VisibilityWrapper } from "../../atoms/visibilityWrapper.tsx"; import { msg, pt } from "../../../utils/i18n/platform.ts"; import { useDocument } from "../../../hooks/useDocument.tsx"; import { WithId, WithPuckProps } from "@puckeditor/core"; -import { resolveEmbeddedFieldsInString } from "../../../utils/resolveYextEntityField.ts"; import { processHandlebarsTemplate } from "./customCodeHandlebars.ts"; import { YextComponentConfig, YextFields } from "../../../fields/fields.ts"; @@ -96,18 +95,12 @@ const CustomCodeSectionWrapper = ({ puck, }: WithId>) => { const streamDocument = useDocument(); - const locale = streamDocument?.locale; const containerRef = React.useRef(null); const scriptIdRef = React.useRef(Math.floor(Math.random() * 1e9)); const scriptTagId = `custom-code-section-script-${scriptIdRef.current}`; const processedHtml = processHandlebarsTemplate(html, streamDocument); - const processedJavascript = resolveEmbeddedFieldsInString( - javascript, - streamDocument, - locale - ); React.useEffect(() => { if (!containerRef.current) { @@ -119,14 +112,14 @@ const CustomCodeSectionWrapper = ({ prevScript.remove(); } - if (processedJavascript) { + if (javascript) { const script = document.createElement("script"); script.id = scriptTagId; script.type = "text/javascript"; - script.text = processedJavascript; + script.text = javascript; containerRef.current.appendChild(script); } - }, [processedJavascript]); + }, [javascript]); if (!processedHtml) { return puck.isEditing ? : null; diff --git a/packages/visual-editor/src/components/sections/directory/Directory.test.tsx b/packages/visual-editor/src/components/sections/directory/Directory.test.tsx index 32cc4cec4c..373c0f041d 100644 --- a/packages/visual-editor/src/components/sections/directory/Directory.test.tsx +++ b/packages/visual-editor/src/components/sections/directory/Directory.test.tsx @@ -1,3 +1,7 @@ +import { + toPuckFields, + type YextComponentConfig, +} from "../../../fields/fields.ts"; import * as React from "react"; import { describe, it, expect } from "vitest"; import { @@ -803,7 +807,16 @@ const tests: ComponentTest[] = [ describe("Directory", async () => { const puckConfig: Config = { - components: { Directory, MainContent, ...SlotsCategoryComponents }, + components: Object.fromEntries( + Object.entries>({ + Directory, + MainContent, + ...SlotsCategoryComponents, + }).map(([name, component]) => [ + name, + { ...component, fields: toPuckFields(component.fields ?? {}) }, + ]) + ), root: { render: ({ children }: { children: React.ReactNode }) => { return <>{children}; diff --git a/packages/visual-editor/src/components/sections/locator/Filters.test.tsx b/packages/visual-editor/src/components/sections/locator/Filters.test.tsx index d26ee3023a..b4bb832402 100644 --- a/packages/visual-editor/src/components/sections/locator/Filters.test.tsx +++ b/packages/visual-editor/src/components/sections/locator/Filters.test.tsx @@ -1,3 +1,5 @@ +import type { TranslatableString } from "../../../types/types.ts"; +import { createPuckFieldTransforms } from "../../../fields/fieldTransforms.ts"; import React from "react"; import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; @@ -94,11 +96,7 @@ vi.mock("@yext/search-ui-react", async () => { }; }); -const renderFilterModal = ( - keywordsDisplayName?: React.ComponentProps< - typeof FilterModal - >["keywordsDisplayName"] -) => +const renderFilterModal = (keywordsDisplayName?: TranslatableString) => render( ()} - keywordsDisplayName={keywordsDisplayName} + keywordsDisplayName={createPuckFieldTransforms("fr", {}).custom!({ + value: keywordsDisplayName, + field: { type: "custom", yextFieldType: "translatableString" } as any, + componentId: "Locator", + propName: "keywordsDisplayName", + propPath: "filters.keywordsDisplayName", + isReadOnly: true, + })} /> ); diff --git a/packages/visual-editor/src/components/sections/locator/Filters.tsx b/packages/visual-editor/src/components/sections/locator/Filters.tsx index 22c9ac4f4a..193e6fcf83 100644 --- a/packages/visual-editor/src/components/sections/locator/Filters.tsx +++ b/packages/visual-editor/src/components/sections/locator/Filters.tsx @@ -17,11 +17,11 @@ import { type MultiSelectorOption } from "../../../fields/MultiSelectorField.tsx import { useCollapse } from "react-collapsed"; import { useTranslation } from "react-i18next"; import { FaChevronUp, FaDotCircle, FaRegCircle, FaTimes } from "react-icons/fa"; -import { type TranslatableString } from "../../../types/types.ts"; + import { getPreferredDistanceUnit } from "../../../utils/i18n/distance.ts"; import { msg } from "../../../utils/i18n/platform.ts"; import { LocatorEntityType } from "../../../utils/locatorEntityTypes.ts"; -import { resolveComponentData } from "../../../utils/resolveComponentData.tsx"; + import { Body } from "../../atoms/body.tsx"; import { translateDistanceUnit } from "./Results.tsx"; @@ -45,7 +45,7 @@ interface FilterModalProps { handleClearFiltersClick: () => void; accentColorCssValue: string; closeButtonRef: React.Ref; - keywordsDisplayName?: TranslatableString; + keywordsDisplayName?: string; } export const FilterModal = ({ @@ -62,7 +62,7 @@ export const FilterModal = ({ closeButtonRef, keywordsDisplayName, }: FilterModalProps) => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const popupRef = React.useRef(null); const facets = useSearchState((state) => state.filters.facets); const facetOverrides = facets?.map((facet) => { @@ -76,7 +76,7 @@ export const FilterModal = ({ const FacetOverride = isNumericalFacet ? NumericalFacet : StandardFacet; const displayName = facet.fieldId === KEYWORDS_FIELD && keywordsDisplayName - ? resolveComponentData(keywordsDisplayName, i18n.language) || undefined + ? keywordsDisplayName || undefined : undefined; return ( diff --git a/packages/visual-editor/src/components/sections/locator/Locator.test.tsx b/packages/visual-editor/src/components/sections/locator/Locator.test.tsx index 97bf3d5bfb..cc0833bcae 100644 --- a/packages/visual-editor/src/components/sections/locator/Locator.test.tsx +++ b/packages/visual-editor/src/components/sections/locator/Locator.test.tsx @@ -1,3 +1,7 @@ +import { + toPuckFields, + type YextComponentConfig, +} from "../../../fields/fields.ts"; import * as React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -1320,7 +1324,15 @@ const screenshotThreshold = 30; describe("Locator", async () => { const puckConfig: Config = { - components: { Locator: LocatorComponent, MainContent }, + components: Object.fromEntries( + Object.entries>({ + Locator: LocatorComponent, + MainContent, + }).map(([name, component]) => [ + name, + { ...component, fields: toPuckFields(component.fields ?? {}) }, + ]) + ), root: { render: ({ children }: { children: React.ReactNode }) => { return <>{children}; diff --git a/packages/visual-editor/src/components/sections/locator/Locator.tsx b/packages/visual-editor/src/components/sections/locator/Locator.tsx index 24306f3093..7df9ae1da6 100644 --- a/packages/visual-editor/src/components/sections/locator/Locator.tsx +++ b/packages/visual-editor/src/components/sections/locator/Locator.tsx @@ -131,7 +131,7 @@ export interface LocatorProps { distanceDisplay?: DistanceDisplayOption; } -const locatorFields: YextFields = { +const locatorFields = { mapStyle: { type: "basicSelector", label: msg("fields.mapStyle", "Map Style"), @@ -408,12 +408,15 @@ const locatorFields: YextFields = { }, ], }, -}; +} satisfies YextFields; /** * Available on Locator templates. */ -export const LocatorComponent: YextComponentConfig = { +export const LocatorComponent: YextComponentConfig< + LocatorProps, + typeof locatorFields +> = { fields: locatorFields, /** * Locks array lengths for `locationStyles` and `resultCard` to the current @@ -429,7 +432,7 @@ export const LocatorComponent: YextComponentConfig = { ) as (keyof typeof entityTypeSourceMap)[]; const entityTypeCount = entityTypes.length; - let updatedFields: YextFields = { ...locatorFields }; + let updatedFields = { ...locatorFields } satisfies YextFields; updatedFields = setDeep( updatedFields, "locationStyles.min", @@ -537,5 +540,5 @@ export const LocatorComponent: YextComponentConfig = { return data; }, - render: (props) => , + render: LocatorWrapper, }; diff --git a/packages/visual-editor/src/components/sections/locator/LocatorResultCard.tsx b/packages/visual-editor/src/components/sections/locator/LocatorResultCard.tsx index fd9eaeb6b6..5c59b19d3e 100644 --- a/packages/visual-editor/src/components/sections/locator/LocatorResultCard.tsx +++ b/packages/visual-editor/src/components/sections/locator/LocatorResultCard.tsx @@ -1,3 +1,4 @@ +import type { ImageType } from "@yext/pages-components"; import React from "react"; import { Trans, useTranslation } from "react-i18next"; import { @@ -46,7 +47,6 @@ import { HoursTableProps, HoursTableStyleFields, } from "../../contentBlocks/HoursTable.tsx"; -import { getImageUrl } from "../../contentBlocks/image/Image.tsx"; import { Accordion, AccordionContent, @@ -1208,20 +1208,20 @@ const ImageSection = (props: { const { i18n } = useTranslation(); if (image.constantValueEnabled) { - const resolvedImage = image.constantValue - ? resolveComponentData(image.constantValue, i18n.language, location) - : undefined; - const imageUrl = getImageUrl(resolvedImage, i18n.language); - const showImageSection = - !!imageUrl && image.liveVisibility && !!resolvedImage; + const imageData = resolveComponentData( + image.constantValue, + i18n.language, + location, + { output: "data" } + ); + if (!imageData?.url || !image.liveVisibility) { + return null; + } return ( - showImageSection && ( - - ) + ); } @@ -1239,7 +1239,6 @@ const ImageSection = (props: { showImageSection && ( ) diff --git a/packages/visual-editor/src/components/sections/locator/LocatorWrapper.tsx b/packages/visual-editor/src/components/sections/locator/LocatorWrapper.tsx index 25e4b5c490..e8b6e1af5f 100644 --- a/packages/visual-editor/src/components/sections/locator/LocatorWrapper.tsx +++ b/packages/visual-editor/src/components/sections/locator/LocatorWrapper.tsx @@ -1,4 +1,3 @@ -import { WithPuckProps } from "@puckeditor/core"; import { FilterSearchResponse, Matcher, @@ -39,7 +38,7 @@ import { toMeters, toMiles, } from "../../../utils/i18n/distance.ts"; -import { resolveComponentData } from "../../../utils/resolveComponentData.tsx"; + import { createSearchAnalyticsConfig, createSearchHeadlessConfig, @@ -59,7 +58,7 @@ import { Location, LocatorResultCard, } from "./LocatorResultCard.tsx"; -import type { LocatorProps } from "./Locator.tsx"; +import type { LocatorComponent } from "./Locator.tsx"; import { COUNTRY_CODE_FIELD, FilterModal, @@ -120,7 +119,9 @@ const updateLocationQuery = (location: string) => { } }; -export const LocatorWrapper = (props: WithPuckProps) => { +export const LocatorWrapper = ( + props: Parameters[0] +) => { const streamDocument = useDocument(); const { searchAnalyticsConfig, searcher } = React.useMemo(() => { const searchHeadlessConfig = createSearchHeadlessConfig( @@ -171,7 +172,7 @@ const LocatorInternal = ({ resultCard: resultCardConfigs, distanceDisplay, pageHeading, -}: LocatorProps) => { +}: Parameters[0]) => { // Adds unified [enable|disable]YextAnalytics to the window for both Pages and Search // analytics. Typically used during consent banner implementation. const searchAnalytics = useSearchAnalytics(); @@ -944,9 +945,7 @@ const LocatorInternal = ({ const filterModalCloseButtonRef = React.useRef(null); const hasOpenedFilterModalRef = React.useRef(false); const resolvedHeading = - (pageHeading?.title && - resolveComponentData(pageHeading.title, i18n.language, streamDocument)) || - t("findALocation", "Find a Location"); + pageHeading?.title || t("findALocation", "Find a Location"); const requireMapOptIn: boolean = streamDocument.__?.visualEditorConfig ? JSON.parse(streamDocument.__?.visualEditorConfig)?.requireMapOptIn diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] default props with document data.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] default props with document data.png index 80a9168434..31ac40a7ab 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] default props with document data.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] default props with document data.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 4 props.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 4 props.png index 55608ccc64..31ac40a7ab 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 4 props.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 4 props.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 8 with non-default props with document data.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 8 with non-default props with document data.png index 602fbd5721..31ac40a7ab 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 8 with non-default props with document data.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[desktop] version 8 with non-default props with document data.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] default props with document data.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] default props with document data.png index 5a3adfbec3..212a4f36f4 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] default props with document data.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] default props with document data.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 4 props.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 4 props.png index 11c5ba290e..212a4f36f4 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 4 props.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 4 props.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 8 with non-default props with document data.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 8 with non-default props with document data.png index 5139e9b1e8..212a4f36f4 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 8 with non-default props with document data.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[mobile] version 8 with non-default props with document data.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] default props with document data.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] default props with document data.png index e5fc5cc31a..0aa8c0781d 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] default props with document data.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] default props with document data.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 4 props.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 4 props.png index a04a652d75..0aa8c0781d 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 4 props.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 4 props.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 8 with non-default props with document data.png b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 8 with non-default props with document data.png index fed14afefc..0aa8c0781d 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 8 with non-default props with document data.png and b/packages/visual-editor/src/components/testing/screenshots/BreadcrumbsSection/[tablet] version 8 with non-default props with document data.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[desktop] renders Handlebars template with document data (after interactions).png b/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[desktop] renders Handlebars template with document data (after interactions).png index 8dc21331bb..4d2ffcd834 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[desktop] renders Handlebars template with document data (after interactions).png and b/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[desktop] renders Handlebars template with document data (after interactions).png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[mobile] renders Handlebars template with document data (after interactions).png b/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[mobile] renders Handlebars template with document data (after interactions).png index d756da03d5..aa9b3b4858 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[mobile] renders Handlebars template with document data (after interactions).png and b/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[mobile] renders Handlebars template with document data (after interactions).png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[tablet] renders Handlebars template with document data (after interactions).png b/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[tablet] renders Handlebars template with document data (after interactions).png index 6f8ed6de06..c93436a36e 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[tablet] renders Handlebars template with document data (after interactions).png and b/packages/visual-editor/src/components/testing/screenshots/CustomCodeSection/[tablet] renders Handlebars template with document data (after interactions).png differ diff --git a/packages/visual-editor/src/components/testing/theme.test.tsx b/packages/visual-editor/src/components/testing/theme.test.tsx index 58f3316e8c..718670fe29 100644 --- a/packages/visual-editor/src/components/testing/theme.test.tsx +++ b/packages/visual-editor/src/components/testing/theme.test.tsx @@ -1,3 +1,4 @@ +import { toPuckFields, type YextComponentConfig } from "../../fields/fields.ts"; import * as React from "react"; import { describe, it, expect } from "vitest"; import { page } from "@vitest/browser/context"; @@ -82,11 +83,16 @@ type ThemeTest = { describe("ThemeTest", async () => { const puckConfig: Config = { - components: { - Directory, - MainContent, - ...SlotsCategoryComponents, - }, + components: Object.fromEntries( + Object.entries>({ + Directory, + MainContent, + ...SlotsCategoryComponents, + }).map(([name, component]) => [ + name, + { ...component, fields: toPuckFields(component.fields ?? {}) }, + ]) + ), root: { render: ({ children }: { children: React.ReactNode }) => { return <>{children}; diff --git a/packages/visual-editor/src/docs/components.md b/packages/visual-editor/src/docs/components.md index 8d440f292f..ed6db2de83 100644 --- a/packages/visual-editor/src/docs/components.md +++ b/packages/visual-editor/src/docs/components.md @@ -134,13 +134,13 @@ Props for the HoursTable component. Additional CSS classes to apply to the image. -| Prop | Type | Description | Default | -| :-------------- | :---------------------------------------------------------------------------------------------------------------- | :-------------------------------------------- | :------ | -| `className` | `string` | Additional CSS classes to apply to the image. | | -| `data` | `{ image: YextEntityField; link?: TranslatableString; }` | | | -| `hideWidthProp` | `boolean` | | | -| `sizes` | `ImgSizesByBreakpoint` | | | -| `styles` | `ImageStylingProps` | Size and aspect ratio of the image. | | +| Prop | Type | Description | Default | +| :-------------- | :------------------------------------------------------------------------------- | :-------------------------------------------- | :------ | +| `className` | `string` | Additional CSS classes to apply to the image. | | +| `data` | `{ image: YextEntityField; link?: TranslatableString; }` | | | +| `hideWidthProp` | `boolean` | | | +| `sizes` | `ImgSizesByBreakpoint` | | | +| `styles` | `ImageStylingProps` | Size and aspect ratio of the image. | | --- 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 0000000000..ec30ab218a --- /dev/null +++ b/packages/visual-editor/src/editor/VisualEditorRender.test.tsx @@ -0,0 +1,343 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { render, waitFor } from "@testing-library/react"; +import { + Puck, + type Config, + type Data, + type FieldTransforms, +} from "@puckeditor/core"; +import { ErrorProvider } from "../contexts/ErrorContext.tsx"; +import { VisualEditorRender } from "./VisualEditorRender.tsx"; +import { toPuckFields } from "../fields/fields.ts"; +import { VisualEditorProvider } from "../utils/VisualEditorProvider.tsx"; +import { ComprehensiveCTA } from "../components/helpers/ComprehensiveCTA.tsx"; +import { + createStyledTextConfig, + StyledTextComponent, +} from "../components/helpers/styledFields/createStyledTextConfig.tsx"; +import { createPuckFieldTransforms } from "../fields/fieldTransforms.ts"; + +describe("VisualEditorRender field transforms", () => { + afterEach((): void => { + vi.unstubAllGlobals(); + }); + it("when nested content is rendered in the editor and published page then both use the current locale and document", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + }) + ); + vi.stubGlobal( + "ResizeObserver", + class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + ); + const config: Config = { + components: { + Card: { + defaultProps: { + caption: { defaultValue: "Default [[name]]" }, + }, + fields: toPuckFields({ + caption: { type: "translatableString" }, + content: { + type: "object", + objectFields: { + title: { + type: "entityField", + + filter: { types: ["type.string"] }, + }, + links: { + type: "array", + arrayFields: { + label: { type: "translatableString" }, + }, + }, + }, + }, + }), + render: ({ content, caption }): React.ReactElement => ( +
+ {content.title}:{content.links[0].label}:{caption} +
+ ), + }, + Container: { + fields: { children: { type: "slot" } }, + render: ({ children: Children }): React.ReactElement => ( +
+ +
+ ), + }, + }, + }; + const data: Data = { + root: { props: {} }, + content: [ + { + type: "Container", + props: { + id: "container", + children: [ + { + type: "Card", + props: { + id: "card", + content: { + title: { + field: "name", + constantValue: "", + constantValueEnabled: false, + }, + links: [ + { + label: { + defaultValue: "Hello [[name]]", + fr: "Bonjour [[name]]", + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + }; + const before = structuredClone(data); + for (const streamDocument of [ + { locale: "en", name: "One" }, + { locale: "fr", name: "Deux" }, + ]) { + const expected = + streamDocument.locale === "en" + ? "One:Hello One:Default One" + : "Deux:Bonjour Deux:Default Deux"; + expect( + renderToStaticMarkup( + + + + ) + ).toContain(expected); + const view = render( + + } + iframe={{ enabled: false }} + > + + + ); + await waitFor(() => + expect(view.container.textContent).toContain(expected) + ); + view.unmount(); + } + expect(data).toEqual(before); + }); + + it.each([ + { + name: "localized link", + actionType: "link", + ctaType: "textAndLink", + mapped: false, + missing: false, + expected: 'href="/book"', + label: "Réserver Restaurant", + }, + { + name: "mapped directions", + actionType: "link", + ctaType: "getDirections", + mapped: true, + missing: false, + expected: "Get Directions", + label: "Get Directions", + }, + { + name: "constant directions", + actionType: "link", + ctaType: "getDirections", + mapped: false, + missing: false, + expected: "Réserver Restaurant", + label: "Réserver Restaurant", + }, + { + name: "preset image", + actionType: "link", + ctaType: "presetImage", + mapped: false, + missing: false, + expected: " { + const heading = createStyledTextConfig({ kind: "plain", label: "Title" }); + const body = createStyledTextConfig({ + kind: "richText", + label: "Description", + }); + const config: Config = { + components: { + Hero: { + fields: toPuckFields({ + heading: { type: "object", objectFields: heading.fields! }, + body: { type: "object", objectFields: body.fields! }, + cta: { type: "comprehensiveCTA" }, + }), + render: ({ heading, body, cta }): React.ReactElement => ( + <> + + + + + ), + }, + }, + }; + const data: Data = { + root: { props: {} }, + content: [ + { + type: "Hero", + props: { + id: "hero", + heading: { + ...heading.defaultProps, + data: { + text: { + field: "name", + constantValue: "", + constantValueEnabled: false, + }, + }, + }, + body: { + ...body.defaultProps, + data: { + text: { + field: "", + constantValue: { + defaultValue: { html: "

Visit [[name]]

" }, + }, + constantValueEnabled: true, + }, + }, + }, + cta: { + data: { + actionType, + cta: { + field: missing ? "missing" : "mappedCta", + constantValueEnabled: !mapped, + selectedType: ctaType, + constantValue: { + ctaType, + label: { defaultValue: "Book", fr: "Réserver [[name]]" }, + link: { defaultValue: "/[[slug]]" }, + linkType: "URL", + }, + }, + openInNewTab: true, + buttonText: { + defaultValue: "Choose", + fr: "Choisir [[name]]", + }, + ariaLabel: { defaultValue: "Reserve [[name]]" }, + customId: "reserve", + customClass: "reservation-action", + dataAttributes: [{ key: "action", value: "reserve" }], + }, + styles: { + variant: "primary", + presetImage: "app-store", + button: { fontFamily: "Georgia", fontSize: "18px" }, + }, + sx: { marginTop: "2px" }, + }, + }, + }, + ], + }; + const before = structuredClone(data); + const streamDocument = { + locale: "fr", + name: "Restaurant", + slug: "book", + mappedCta: { label: "Ignored directions label", link: "/ignored" }, + }; + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain("Visit Restaurant

"); + expect(html).toContain(expected); + if (label) { + expect(html).toContain(label); + expect(html).toContain("margin-top:2px"); + if (actionType === "button") { + expect(html).toContain('id="reserve"'); + expect(html).toContain('aria-label="Reserve Restaurant"'); + } else { + expect(html).toContain('target="_blank"'); + } + } else { + expect(html).not.toContain(" = { config: T; @@ -14,8 +22,95 @@ export const VisualEditorRender = ({ metadata, }: VisualEditorRenderProps) => { const wrappedConfig = React.useMemo(() => { - return wrapConfigWithComponentErrorBoundary(config); - }, [config]); + const streamDocument: StreamDocument = metadata?.streamDocument ?? {}; + const fieldSources = new Map(); + const transforms = createPuckFieldTransforms( + streamDocument.locale ?? "en", + streamDocument, + fieldSources + ); + return { + config: wrapConfigWithComponentErrorBoundary({ + ...config, + components: Object.fromEntries( + Object.entries(config.components).map(([name, component]) => [ + name, + { + ...component, + render: (props: Record): React.ReactElement => { + /** Traverse structural fields without replacing Puck's slot rendering. */ + const transformValue = ( + value: any, + field: any, + propPath: string + ): any => { + const transform = transforms[ + field.type as keyof typeof transforms + ] as FieldTransforms["custom"]; + if (transform) { + return transform({ + value, + field, + componentId: props.id, + propName: propPath.split(".").at(-1) ?? propPath, + propPath, + isReadOnly: true, + }); + } + if (field.type === "object" && value) { + return Object.fromEntries( + Object.entries(value).map( + ([key, item]): [string, unknown] => [ + key, + field.objectFields?.[key] + ? transformValue( + item, + field.objectFields[key], + `${propPath}.${key}` + ) + : item, + ] + ) + ); + } + if (field.type === "array" && Array.isArray(value)) { + return value.map((item: any, index: number): any => + transformValue( + item, + { type: "object", objectFields: field.arrayFields }, + `${propPath}[${index}]` + ) + ); + } + return value; + }; + return React.createElement( + component.render as React.ComponentType>, + Object.fromEntries( + Object.entries({ ...component.defaultProps, ...props }).map( + ([key, value]): [string, unknown] => [ + key, + component.fields?.[key] + ? transformValue(value, component.fields[key], key) + : value, + ] + ) + ) + ); + }, + }, + ]) + ) as T["components"], + }), + metadata: { ...metadata, fieldSources }, + }; + }, [config, metadata]); - return ; + return ( + + ); }; diff --git a/packages/visual-editor/src/fields/ImageField.test.tsx b/packages/visual-editor/src/fields/ImageField.test.tsx index 0114d55005..9485cb3748 100644 --- a/packages/visual-editor/src/fields/ImageField.test.tsx +++ b/packages/visual-editor/src/fields/ImageField.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TemplatePropsContext } from "../hooks/useDocument.tsx"; import { YextAutoField } from "./YextAutoField.tsx"; @@ -10,6 +10,19 @@ const { sendToParentMock, translatableStringFieldMock } = vi.hoisted(() => ({ translatableStringFieldMock: vi.fn(), })); +vi.mock("../hooks/useEntityFields.tsx", () => ({ + useEntityFields: () => ({ fields: [] }), +})); + +vi.mock("@puckeditor/core", async (importOriginal) => ({ + ...(await importOriginal()), + createUsePuck: () => (selector: (state: unknown) => unknown) => + selector({ + appState: { ui: { itemSelector: null } }, + getItemBySelector: () => undefined, + }), +})); + vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -29,7 +42,10 @@ vi.mock("../internal/hooks/useMessage.ts", () => ({ }), })); -vi.mock("../internal/hooks/useMessageReceivers.ts", () => ({ +vi.mock("../internal/hooks/useMessageReceivers.ts", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../internal/hooks/useMessageReceivers.ts") + >()), useTemplateMetadata: () => ({ locatorDisplayFields: { c_title: { @@ -84,6 +100,7 @@ const renderImageField = ( describe("ImageField", () => { afterEach(() => { + cleanup(); vi.restoreAllMocks(); translatableStringFieldMock.mockReset(); sendToParentMock.mockReset(); @@ -104,13 +121,17 @@ describe("ImageField", () => { getAltTextOptions, }, { - en: { - alternateText: "", - url: "https://example.com/image.jpg", - height: 1, - width: 1, + field: "", + constantValueEnabled: true, + constantValue: { + en: { + alternateText: "", + url: "https://example.com/image.jpg", + height: 1, + width: 1, + }, + hasLocalizedValue: "true", }, - hasLocalizedValue: "true", } ); @@ -128,4 +149,40 @@ describe("ImageField", () => { }); expect(screen.getByText("Alt Text (en)")).toBeDefined(); }); + + it.each([ + { + button: "Choose Image", + constantValue: { defaultValue: { url: "", height: 0, width: 0 } }, + }, + { + button: "Change", + constantValue: { defaultValue: { url: "/old.jpg", height: 1, width: 1 } }, + }, + ])( + "when localhost $button selects an image then the authored value updates without a parent message", + ({ button, constantValue }) => { + vi.spyOn(window, "prompt").mockReturnValue("/selected.jpg"); + const { onChange } = renderImageField( + { type: "image" }, + { + field: "", + constantValueEnabled: true, + constantValue, + } + ); + fireEvent.click(screen.getByRole("button", { name: button })); + expect(window.prompt).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + field: "", + constantValueEnabled: true, + constantValue: expect.objectContaining({ + en: expect.objectContaining({ url: "/selected.jpg" }), + }), + }) + ); + expect(sendToParentMock).not.toHaveBeenCalled(); + } + ); }); diff --git a/packages/visual-editor/src/fields/ImageField.tsx b/packages/visual-editor/src/fields/ImageField.tsx index d4d72e642b..d152e82441 100644 --- a/packages/visual-editor/src/fields/ImageField.tsx +++ b/packages/visual-editor/src/fields/ImageField.tsx @@ -27,6 +27,11 @@ import { import { useTemplateMetadata } from "../internal/hooks/useMessageReceivers.ts"; import { YextAutoField } from "./YextAutoField.tsx"; import { type EmbeddedStringOption } from "../editor/EmbeddedFieldStringInput.tsx"; +import { + EntityFieldSelectorFieldOverride, + type EntityFieldSelectorField, +} from "./EntityFieldSelectorField.tsx"; +import { type RenderEntityFieldFilter } from "../internal/utils/getFilteredEntityFields.ts"; export type ImagePayload = { id: string; @@ -43,6 +48,8 @@ export type ImageField = BaseField & { visible?: boolean; hideAltTextField?: boolean; maxFileSizeBytes?: number; + filter?: RenderEntityFieldFilter>; + constantValueEditor?: boolean; getAltTextOptions?: ( templateMetadata: TemplateMetadata ) => EmbeddedStringOption[]; @@ -70,6 +77,22 @@ export const ImageFieldOverride = ({ onChange, value, }: ImageFieldOverrideProps) => { + if (!field.constantValueEditor) { + return ( + + ); + } + const { i18n } = useTranslation(); const streamDocument = useDocument(); const templateMetadata: TemplateMetadata = useTemplateMetadata(); @@ -137,6 +160,22 @@ export const ImageFieldOverride = ({ } as TranslatableAssetImage); }, }; + if (window.location.hostname === "localhost") { + pendingImageSession = undefined; + const url = window.prompt("Enter Image URL:"); + if (!url) { + return; + } + const image = { alternateText: "", url, height: 1, width: 1 }; + onChange({ + ...localizedContainer, + defaultValue: localizedContainer?.defaultValue ?? image, + [locale]: image, + hasLocalizedValue: "true", + } as TranslatableAssetImage); + return; + } + openImageAssetSelector({ payload: { type: "ImageAsset", diff --git a/packages/visual-editor/src/fields/PriceField.tsx b/packages/visual-editor/src/fields/PriceField.tsx new file mode 100644 index 0000000000..e8fc4e64d9 --- /dev/null +++ b/packages/visual-editor/src/fields/PriceField.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import type { FieldProps } from "@puckeditor/core"; +import { + EntityFieldSelectorFieldOverride, + type EntityFieldSelectorField, +} from "./EntityFieldSelectorField.tsx"; + +export type PriceField = Omit< + EntityFieldSelectorField, + "type" | "filter" | "constantValueFilter" | "repeated" +> & { type: "price" }; + +/** Author a structured price with the existing mapped/static entity selector. */ +export const PriceFieldOverride = ({ + field, + ...props +}: FieldProps): React.ReactElement => ( + +); diff --git a/packages/visual-editor/src/fields/entityFieldConstantConfig.ts b/packages/visual-editor/src/fields/entityFieldConstantConfig.ts index 87b4f48ecd..ae789d62fb 100644 --- a/packages/visual-editor/src/fields/entityFieldConstantConfig.ts +++ b/packages/visual-editor/src/fields/entityFieldConstantConfig.ts @@ -36,6 +36,13 @@ export const TYPE_TO_CONSTANT_CONFIG: Record = { "type.string": TRANSLATABLE_STRING_CONSTANT_CONFIG, "type.rich_text_v2": TRANSLATABLE_RICH_TEXT_CONSTANT_CONFIG, "type.phone": PHONE_CONSTANT_CONFIG, + "type.price": { + type: "object", + objectFields: { + value: { type: "number", label: "Amount" }, + currencyCode: { type: "text", label: "Currency code" }, + }, + }, "type.image": IMAGE_CONSTANT_CONFIG, "type.cta": ENHANCED_CTA_CONSTANT_CONFIG, "type.datetime": DATE_TIME_CONSTANT_CONFIG, diff --git a/packages/visual-editor/src/fields/fieldOverrides.ts b/packages/visual-editor/src/fields/fieldOverrides.ts index a221d87849..eb7fdf180a 100644 --- a/packages/visual-editor/src/fields/fieldOverrides.ts +++ b/packages/visual-editor/src/fields/fieldOverrides.ts @@ -7,6 +7,7 @@ import { CTASelectorFieldOverride } from "./CTASelectorField.tsx"; import { MultiSelectorFieldOverride } from "./MultiSelectorField.tsx"; import { OptionalNumberFieldOverride } from "./OptionalNumberField.tsx"; import { ImageFieldOverride } from "./ImageField.tsx"; +import { PriceFieldOverride } from "./PriceField.tsx"; import { StyledButtonFieldOverride } from "./styledFields/StyledButtonField.tsx"; import { StyledImageFieldOverride } from "./styledFields/StyledImageField.tsx"; import { StyledLinkFieldOverride } from "./styledFields/StyledLinkField.tsx"; @@ -26,6 +27,7 @@ export const YextPuckFieldOverrides = { multiSelector: MultiSelectorFieldOverride, fontSizeSelector: FontSizeSelectorFieldOverride, image: ImageFieldOverride, + price: PriceFieldOverride, optionalNumber: OptionalNumberFieldOverride, styledButton: StyledButtonFieldOverride, styledImage: StyledImageFieldOverride, diff --git a/packages/visual-editor/src/fields/fieldTransforms.test.ts b/packages/visual-editor/src/fields/fieldTransforms.test.ts new file mode 100644 index 0000000000..dda54ea842 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms.test.ts @@ -0,0 +1,506 @@ +import { describe, expect, it } from "vitest"; +import { createPuckFieldTransforms } from "./fieldTransforms.ts"; +import { toPuckFields } from "./fields.ts"; +import { createItemSource } from "../utils/itemSource/createItemSource.ts"; + +describe("Yext content transforms", () => { + it.each([ + { + name: "mapped text", + field: { type: "entityField" }, + value: { field: "name", constantValue: "", constantValueEnabled: false }, + expected: "Restaurant", + }, + { + name: "localized embedded text", + field: { type: "translatableString" }, + value: { fr: "Bonjour [[name]]", defaultValue: "Hello" }, + expected: "Bonjour Restaurant", + }, + { + name: "empty localized text", + field: { type: "translatableString" }, + value: { fr: "", defaultValue: "Hello" }, + expected: "", + }, + { + name: "false", + field: { type: "entityField" }, + value: { field: "", constantValue: false, constantValueEnabled: true }, + expected: false, + }, + { + name: "zero", + field: { type: "entityField" }, + value: { field: "", constantValue: 0, constantValueEnabled: true }, + expected: 0, + }, + { + name: "rich text data", + field: { type: "entityField" }, + value: { + field: "", + constantValue: { defaultValue: { html: "

[[name]]

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

Restaurant

", json: "" }, + }, + { + name: "localized image", + field: { type: "image" }, + value: { + defaultValue: { url: "/default.jpg" }, + fr: { url: "/fr.jpg", alternateText: { defaultValue: "[[name]]" } }, + }, + expected: { url: "/fr.jpg", alternateText: "Restaurant" }, + }, + { + name: "CTA", + field: { type: "ctaSelector" }, + value: { + field: "", + constantValueEnabled: true, + constantValue: { + label: { fr: "Voir", hasLocalizedValue: "true" }, + link: "/[[slug]]", + ctaType: "textAndLink", + }, + }, + expected: { label: "Voir", link: "/restaurant", ctaType: "textAndLink" }, + }, + { + name: "code", + field: { type: "code" }, + value: "console.log('[[name]]')", + expected: "console.log('Restaurant')", + }, + { + name: "field without resolution options", + field: { type: "entityField" }, + value: { field: "name", constantValue: "" }, + expected: "Restaurant", + }, + { + name: "style control", + field: { type: "fontSizeSelector" }, + value: "heading", + expected: "heading", + }, + { + name: "root", + field: { type: "entityField" }, + value: { field: "name", constantValue: "" }, + componentId: "root", + expected: { field: "name", constantValue: "" }, + }, + ])( + "when resolving $name then authored data is preserved", + ({ field, value, expected, componentId }) => { + const before = structuredClone(value); + const transform = createPuckFieldTransforms("fr", { + name: "Restaurant", + slug: "restaurant", + }).custom!; + expect( + transform({ + value, + field: { ...field, type: "custom", yextFieldType: field.type } as any, + componentId: componentId ?? "Hero", + propName: "content", + propPath: "content", + isReadOnly: true, + }) + ).toEqual(expected); + expect(value).toEqual(before); + } + ); + + it("when item sources use manual or linked data then mappings resolve in the right context", () => { + const source = createItemSource<{ + title: { + field: string; + constantValue: string; + constantValueEnabled: boolean; + }; + }>({ + label: "Cards", + + mappingFields: { + title: { type: "entityField", filter: { types: ["type.string"] } }, + }, + defaultValues: [ + { + title: { + field: "", + constantValue: "[[name]]", + constantValueEnabled: true, + }, + }, + ], + }); + const transform = createPuckFieldTransforms("en", { + name: "Page", + cards: [{ name: "First" }, { name: "Second" }], + }).custom!; + const field = toPuckFields({ cards: source.field }).cards; + for (const { value, expected } of [ + { value: undefined, expected: [] }, + { value: source.defaultValue, expected: [{ title: "Page" }] }, + { + value: { + field: "cards", + constantValueEnabled: false, + constantValue: [], + mappings: { + title: { + field: "name", + constantValue: "", + constantValueEnabled: false, + }, + }, + }, + expected: [{ title: "First" }, { title: "Second" }], + }, + ]) { + expect( + transform({ + value, + field: field as any, + componentId: "Cards", + propName: "cards", + propPath: "cards", + isReadOnly: true, + }) + ).toEqual(expected); + } + }); + + it("when a composite CTA resolves then actions and styles remain intact", () => { + const value = { + data: { + actionType: "button", + cta: { + field: "cta", + constantValue: { label: "", link: "" }, + constantValueEnabled: false, + selectedType: "getDirections", + }, + buttonText: { fr: "Visiter [[name]]", hasLocalizedValue: "true" }, + ariaLabel: { defaultValue: "Visit [[name]]" }, + customId: "visit", + dataAttributes: [{ key: "action", value: "visit" }], + openInNewTab: true, + }, + styles: { variant: "link", presetImage: "app-store" }, + }; + const before = structuredClone(value); + const transform = createPuckFieldTransforms("fr", { + name: "Restaurant", + cta: { label: "Directions", link: "/directions" }, + }).custom!; + expect( + transform({ + value, + field: { + type: "custom", + yextFieldType: "comprehensiveCTA", + } as any, + componentId: "Hero", + propName: "cta", + propPath: "cta", + isReadOnly: true, + }) + ).toMatchObject({ + data: { + actionType: "button", + cta: { + label: "Get Directions", + link: "/directions", + ctaType: "getDirections", + }, + buttonText: "Visiter Restaurant", + ariaLabel: "Visit Restaurant", + customId: "visit", + dataAttributes: [{ key: "action", value: "visit" }], + openInNewTab: true, + }, + styles: value.styles, + }); + expect(value).toEqual(before); + }); +}); + +it("when an entity field renders then source-dependent UI can read its binding separately", () => { + const fieldSources = new Map(); + const value = { + field: "address", + constantValue: {}, + constantValueEnabled: false, + }; + const transform = createPuckFieldTransforms( + "en", + { + address: { line1: "123 Main Street" }, + }, + fieldSources + ).custom!; + const result = transform({ + value, + field: { type: "custom", yextFieldType: "entityField" } as any, + componentId: "Address-1", + propName: "address", + propPath: "data.address", + isReadOnly: true, + }); + expect(result).toEqual({ line1: "123 Main Street" }); + expect(fieldSources.get("Address-1:data.address")).toBe(value); +}); + +it.each([ + { + name: "mapped flat image", + value: { field: "photo", constantValue: {}, constantValueEnabled: false }, + expected: { url: "/photo.jpg", width: 2, height: 1, alternateText: "Page" }, + }, + { + name: "mapped complex image", + value: { + field: "complexPhoto", + constantValue: {}, + constantValueEnabled: false, + }, + expected: { + url: "/complex.jpg", + width: 2, + height: 1, + alternateText: "Page", + }, + }, + { + name: "localized static image", + value: { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: { url: "/default.jpg" }, + fr: { url: "/fr.jpg", alternateText: { defaultValue: "[[name]]" } }, + }, + }, + expected: { url: "/fr.jpg", alternateText: "Page" }, + }, + { + name: "default static image", + value: { + field: "", + constantValueEnabled: true, + constantValue: { defaultValue: { url: "/default.jpg" } }, + }, + expected: { url: "/default.jpg", alternateText: "" }, + }, + { + name: "missing mapped image", + value: { field: "missing", constantValue: {}, constantValueEnabled: false }, + expected: undefined, + }, +])( + "when resolving $name then the renderer receives a flat image", + ({ value, expected }) => { + const before = structuredClone(value); + expect( + createPuckFieldTransforms("fr", { + name: "Page", + photo: { + url: "/photo.jpg", + width: 2, + height: 1, + alternateText: "[[name]]", + }, + complexPhoto: { + image: { + url: "/complex.jpg", + width: 2, + height: 1, + alternateText: { defaultValue: "[[name]]" }, + }, + description: "Unused", + }, + }).custom!({ + value, + field: { type: "custom", yextFieldType: "image" } as any, + componentId: "Image", + propName: "image", + propPath: "image", + isReadOnly: true, + }) + ).toEqual(expected); + expect(value).toEqual(before); + } +); + +it("when repeated images resolve then alternate text uses each item's document", () => { + const source = createItemSource({ + label: "Cards", + mappingFields: { image: { type: "image" } }, + defaultValues: [], + }); + expect( + createPuckFieldTransforms("en", { + cards: [ + { + name: "First", + photo: { image: { url: "/first.jpg", alternateText: "[[name]]" } }, + }, + { + name: "Second", + photo: { url: "/second.jpg", alternateText: "[[name]]" }, + }, + ], + }).custom!({ + value: { + field: "cards", + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { + field: "photo", + constantValue: {}, + constantValueEnabled: false, + }, + }, + }, + field: toPuckFields({ cards: source.field }).cards as any, + componentId: "Cards", + propName: "cards", + propPath: "cards", + isReadOnly: true, + }) + ).toEqual([ + { image: { url: "/first.jpg", alternateText: "First" } }, + { image: { url: "/second.jpg", alternateText: "Second" } }, + ]); +}); + +it.each([ + { + name: "mapped USD price", + locale: "en-US", + value: { + field: "price", + constantValue: undefined, + constantValueEnabled: false, + }, + expected: "$12.50", + }, + { + name: "static zero price", + locale: "en-US", + value: { + field: "", + constantValue: { value: 0, currencyCode: "USD" }, + constantValueEnabled: true, + }, + expected: "$0.00", + }, + { + name: "localized euro price", + locale: "de-DE", + value: { + field: "", + constantValue: { value: "12.50", currencyCode: "EUR" }, + constantValueEnabled: true, + }, + expected: "12,50 €", + }, + { + name: "missing currency", + locale: "en-US", + value: { + field: "", + constantValue: { value: 12.5 }, + constantValueEnabled: true, + }, + expected: undefined, + }, + { + name: "invalid amount", + locale: "en-US", + value: { + field: "", + constantValue: { value: "abc", currencyCode: "USD" }, + constantValueEnabled: true, + }, + expected: undefined, + }, + { + name: "missing mapping", + locale: "en-US", + value: { + field: "missing", + constantValue: undefined, + constantValueEnabled: false, + }, + expected: undefined, + }, + { + name: "missing value", + locale: "en-US", + value: undefined, + expected: undefined, + }, +])( + "when resolving $name then the renderer receives a display price", + ({ locale, value, expected }) => { + const before = structuredClone(value); + const fieldSources = new Map(); + expect( + createPuckFieldTransforms( + locale, + { price: { value: 12.5, currencyCode: "USD" } }, + fieldSources + ).custom!({ + value, + field: { type: "custom", yextFieldType: "price" } as any, + componentId: "Price-1", + propName: "price", + propPath: "data.price", + isReadOnly: true, + }) + ).toEqual(expected); + expect(value).toEqual(before); + expect(fieldSources.get("Price-1:data.price")).toEqual(value); + } +); + +it("when repeated prices resolve then each item's amount and currency are formatted", () => { + const source = createItemSource({ + label: "Products", + mappingFields: { price: { type: "price" } }, + defaultValues: [], + }); + expect( + createPuckFieldTransforms("en-US", { + products: [ + { price: { value: 12.5, currencyCode: "USD" } }, + { price: { value: 0, currencyCode: "USD" } }, + ], + }).custom!({ + value: { + ...source.defaultValue, + field: "products", + constantValueEnabled: false, + mappings: { + price: { + field: "price", + constantValue: undefined, + constantValueEnabled: false, + }, + }, + }, + field: toPuckFields({ products: source.field }).products as any, + componentId: "Products-1", + propName: "products", + propPath: "products", + isReadOnly: true, + }) + ).toEqual([{ price: "$12.50" }, { price: "$0.00" }]); +}); diff --git a/packages/visual-editor/src/fields/fieldTransforms.ts b/packages/visual-editor/src/fields/fieldTransforms.ts new file mode 100644 index 0000000000..d6f049b4e3 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms.ts @@ -0,0 +1,234 @@ +import type { FieldTransforms } from "@puckeditor/core"; +import type { YextEntityField } from "../editor/YextEntityFieldSelector.tsx"; +import { resolveComponentData } from "../utils/resolveComponentData.tsx"; +import { + resolveEmbeddedFieldsInString, + resolveYextEntityField, +} from "../utils/resolveYextEntityField.ts"; +import { resolveItemValue } from "../utils/itemSource/itemSourceResolution.ts"; +import type { + RepeatedEntityFieldMetadata, + RepeatedEntityFieldValue, +} from "../utils/itemSource/itemSourceTypes.ts"; +import type { StreamDocument } from "../utils/types/StreamDocument.ts"; +import type { YextFieldDefinition } from "./fields.ts"; +import { i18nPageInstance } from "../utils/i18n/i18nInstances.ts"; +import { getCTAType } from "../internal/utils/ctaFieldUtils.ts"; +import type { ImageType } from "@yext/pages-components"; +import { formatCurrency } from "../utils/productPrice.ts"; +import type { ProductPrice } from "../types/types.ts"; + +/** + * Build render-time transforms for Yext content fields automatically by field type. + * + * 1. Keep authored editor values and unrelated fields unchanged. + * 2. Supply resolved content fields and plain item values to component renderers. + * Resolve content and repeated mappings using the active page document. + * 3. Return plain values without changing Puck's saved data or slot lifecycle. + */ +export const createPuckFieldTransforms = ( + locale: string, + streamDocument: StreamDocument, + fieldSources = new Map() +): FieldTransforms => { + const transform = ( + { + value, + field, + componentId, + propPath, + }: { + value: any; + field: { + type: string; + yextFieldType?: string; + repeated?: RepeatedEntityFieldMetadata>; + }; + componentId?: string; + propPath?: string; + }, + sourceDocument: StreamDocument = streamDocument + ): any => { + if (componentId === "root") { + return value; + } + // Preserve binding metadata for source-dependent UI without wrapping resolved values. + if ( + componentId && + propPath && + ["entityField", "image", "price"].includes( + field.yextFieldType ?? field.type + ) + ) { + fieldSources.set(`${componentId}:${propPath}`, value); + } + if (value == null) { + return field.repeated + ? [] + : ["image", "price"].includes(field.yextFieldType ?? field.type) + ? undefined + : value; + } + + switch (field.yextFieldType ?? field.type) { + case "entityField": { + if (field.repeated) { + const authored = value as RepeatedEntityFieldValue< + Record + >; + const manual = authored.constantValueEnabled || !authored.field; + const items = manual + ? authored.constantValue + : resolveYextEntityField( + sourceDocument, + { + field: authored.field, + constantValue: authored.constantValue, + constantValueEnabled: false, + }, + locale + ); + if (!Array.isArray(items)) { + return []; + } + return items.map((item: any): Record => + Object.fromEntries( + Object.entries( + manual + ? field.repeated!.manualItemFields + : field.repeated!.mappingFields + ).map(([key, itemField]): [string, unknown] => [ + key, + resolveItemValue( + itemField as YextFieldDefinition, + manual ? item?.[key] : authored.mappings?.[key], + sourceDocument, + manual ? undefined : item, + (childField, childValue, itemDocument): unknown => + transform( + { + value: childValue, + field: childField, + }, + itemDocument + ) + ), + ]) + ) + ); + } + return resolveComponentData( + value as YextEntityField, + locale, + sourceDocument, + { output: "data" } + ); + } + case "translatableString": + return resolveComponentData(value, locale, sourceDocument, { + output: "data", + }); + case "price": { + const price = resolveComponentData( + value, + locale, + sourceDocument, + { output: "data" } + ); + return formatCurrency(price?.value, price?.currencyCode, locale); + } + case "image": { + const resolved = resolveComponentData( + value, + locale, + sourceDocument, + { + output: "data", + } + ); + if (!resolved) { + return undefined; + } + const flattened = ( + "image" in resolved ? resolved.image : resolved + ) as ImageType; + return { + ...flattened, + alternateText: resolveComponentData( + flattened.alternateText ?? "", + locale, + sourceDocument, + { output: "data" } + ), + } as ImageType; + } + case "ctaSelector": { + const cta = resolveComponentData>( + value, + locale, + sourceDocument, + { output: "data" } + ); + const ctaType = getCTAType(value).ctaType ?? cta?.ctaType; + return ( + cta && { + ...cta, + ctaType, + ...(ctaType === "getDirections" && !value.constantValueEnabled + ? { + label: i18nPageInstance.t("getDirections", { + lng: locale, + defaultValue: "Get Directions", + }), + } + : {}), + } + ); + } + case "comprehensiveCTA": { + return { + ...value, + data: { + ...value.data, + cta: transform( + { + value: value.data?.cta, + field: { type: "ctaSelector" }, + }, + sourceDocument + ), + buttonText: resolveComponentData( + value.data?.buttonText, + locale, + sourceDocument, + { output: "data" } + ), + ariaLabel: resolveComponentData( + value.data?.ariaLabel, + locale, + sourceDocument, + { output: "data" } + ), + }, + }; + } + case "code": + return resolveEmbeddedFieldsInString(value, sourceDocument, locale); + default: + return value; + } + }; + + return Object.fromEntries( + [ + "custom", + "entityField", + "translatableString", + "image", + "price", + "ctaSelector", + "comprehensiveCTA", + "code", + ].map((type): [string, typeof transform] => [type, transform]) + ) as FieldTransforms; +}; diff --git a/packages/visual-editor/src/fields/fields.test.tsx b/packages/visual-editor/src/fields/fields.test.tsx index 96132c54fd..5436eca174 100644 --- a/packages/visual-editor/src/fields/fields.test.tsx +++ b/packages/visual-editor/src/fields/fields.test.tsx @@ -1,8 +1,14 @@ +import type { YextEntityField } from "../editor/yextEntityFieldUtils.ts"; +import type { TranslatableString } from "../types/types.ts"; import React from "react"; -import { describe, expect, it } from "vitest"; -import type { CustomField, Fields } from "@puckeditor/core"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { CustomField, Fields, Slot } from "@puckeditor/core"; import type { BasicSelectorField } from "./BasicSelectorField.tsx"; -import { toPuckFields } from "./fields.ts"; +import { + toPuckFields, + type YextComponentConfig, + type YextFields, +} from "./fields.ts"; import { YextAutoField } from "./YextAutoField.tsx"; type TestProps = { @@ -115,3 +121,84 @@ describe("toPuckFields", () => { ); }); }); + +it("when a schema contains content and native fields then only content render types change", () => { + const fields = { + title: { type: "entityField", filter: { types: ["type.string"] } }, + cards: { + type: "array", + arrayFields: { + label: { type: "translatableString" }, + content: { type: "slot" }, + }, + }, + control: { type: "custom", render: () => <> }, + } satisfies YextFields<{ + title: YextEntityField; + cards: { label: TranslatableString; content: Slot }[]; + control: YextEntityField; + }>; + const component: YextComponentConfig< + { + title: YextEntityField; + cards: { label: TranslatableString; content: Slot }[]; + control: YextEntityField; + }, + typeof fields + > = { + fields, + defaultProps: { + title: { field: "name", constantValue: { defaultValue: "Title" } }, + cards: [{ label: { defaultValue: "Card" }, content: [] }], + control: { field: "name", constantValue: "" }, + }, + resolveData: (data) => { + expectTypeOf(data.props.title).toMatchTypeOf<{ + field?: string; + constantValue?: TranslatableString; + }>(); + return data; + }, + render: ({ title, cards, control }) => { + expectTypeOf(title).toEqualTypeOf(); + expectTypeOf(cards[0].label).toEqualTypeOf(); + expectTypeOf(cards[0].content).toBeFunction(); + expectTypeOf(control).toEqualTypeOf>(); + return <>{title}; + }, + }; + expect(component.defaultProps?.title.constantValue).toEqual({ + defaultValue: "Title", + }); + // Authored defaults remain binding objects even though render props are plain strings. + expectTypeOf< + NonNullable["title"] + >().toEqualTypeOf>(); +}); + +it("when a schema declares price then render props contain a display string and defaults remain structured", () => { + const fields = { price: { type: "price" } } satisfies YextFields<{ + price: YextEntityField<{ value: number; currencyCode: string }>; + }>; + const component: YextComponentConfig< + { price: YextEntityField<{ value: number; currencyCode: string }> }, + typeof fields + > = { + fields, + defaultProps: { + price: { + field: "", + constantValue: { value: 0, currencyCode: "USD" }, + constantValueEnabled: true, + }, + }, + render: ({ price }) => { + expectTypeOf(price).toEqualTypeOf(); + return <>{price}; + }, + }; + expect(component.defaultProps?.price.constantValue).toEqual({ + value: 0, + currencyCode: "USD", + }); +}); diff --git a/packages/visual-editor/src/fields/fields.ts b/packages/visual-editor/src/fields/fields.ts index c14bcb2f56..a8d69ae329 100644 --- a/packages/visual-editor/src/fields/fields.ts +++ b/packages/visual-editor/src/fields/fields.ts @@ -1,3 +1,4 @@ +import type { YextEntityField } from "../editor/yextEntityFieldUtils.ts"; import { createElement } from "react"; import type { ArrayField, @@ -17,6 +18,8 @@ import type { CTASelectorField } from "./CTASelectorField.tsx"; import type { MultiSelectorField } from "./MultiSelectorField.tsx"; import type { OptionalNumberField } from "./OptionalNumberField.tsx"; import type { ImageField } from "./ImageField.tsx"; +import type { PriceField } from "./PriceField.tsx"; +import type { ImageType } from "@yext/pages-components"; import type { StyledButtonField } from "./styledFields/StyledButtonField.tsx"; import type { StyledImageField } from "./styledFields/StyledImageField.tsx"; import type { StyledLinkField } from "./styledFields/StyledLinkField.tsx"; @@ -38,6 +41,7 @@ export type YextPuckFields = { multiSelector: MultiSelectorField; fontSizeSelector: FontSizeSelectorField; image: ImageField; + price: PriceField; optionalNumber: OptionalNumberField; styledButton: StyledButtonField; styledImage: StyledImageField; @@ -77,16 +81,80 @@ export type YextFieldDefinition = ? YextObjectField : never); +/** Localize content data without changing native values or creating React elements. */ +type ContentValue = 0 extends 1 & Value + ? Value + : Value extends { field: string; constantValue: infer Constant } + ? ContentValue | undefined + : Value extends readonly unknown[] + ? { [Key in keyof Value]: ContentValue } + : Value extends object + ? "defaultValue" extends keyof Value + ? ContentValue> + : { [Key in keyof Value]: ContentValue } + : Value; + +/** Follow the field schema so custom controls, style objects, and slots stay intact. */ +type FieldValue = YextPuckField extends Definition + ? Value + : Definition extends { type: "entityField"; repeated: unknown } + ? Value extends YextEntityField + ? ContentValue[] + : never + : Definition extends { type: "price" | "image" } + ? { + price: string | undefined; + image: ImageType | undefined; + }[Definition["type"]] + : Definition extends { + type: "entityField" | "ctaSelector" | "translatableString"; + } + ? ContentValue + : Definition extends { type: "comprehensiveCTA" } + ? { + [Key in keyof Value]: Key extends "data" + ? ContentValue + : Value[Key]; + } + : Definition extends { + type: "object"; + objectFields: infer Children; + } + ? ComponentValues + : Definition extends { + type: "array"; + arrayFields: infer Children; + } + ? Value extends readonly unknown[] + ? { + [Key in keyof Value]: ComponentValues; + } + : Value + : Value; + +type ComponentValues = 0 extends 1 & Props + ? Props + : { + [Key in keyof Props]: Key extends keyof Definitions + ? FieldValue> + : Props[Key]; + }; + +/** Keep defaults and editor callbacks authored; derive render values from the field schema. */ export type YextComponentConfig< Props extends DefaultComponentProps = DefaultComponentProps, + Definitions extends YextFields = YextFields, > = Omit< ComponentConfig<{ props: Props; fields: YextPuckFields; }>, - "fields" | "resolveFields" + "fields" | "resolveFields" | "render" > & { - fields?: YextFields; + render: ComponentConfig<{ + props: ComponentValues; + }>["render"]; + fields?: Definitions; resolveFields?: ComponentConfig<{ props: Props; fields: YextPuckFields; @@ -129,6 +197,7 @@ export const toPuckFields = < (yextField) => ({ ...yextField, type: "custom", + yextFieldType: yextField.type, render: ({ field: _, ...props }) => createElement(YextAutoField, { ...(props as any), diff --git a/packages/visual-editor/src/fields/index.ts b/packages/visual-editor/src/fields/index.ts index 388fe6f350..93d4dad4cc 100644 --- a/packages/visual-editor/src/fields/index.ts +++ b/packages/visual-editor/src/fields/index.ts @@ -13,6 +13,7 @@ export { export { CodeFieldOverride, type CodeField } from "./CodeField.tsx"; export { ImageFieldOverride, type ImageField } from "./ImageField.tsx"; +export { type PriceField } from "./PriceField.tsx"; export { DateTimeSelectorFieldOverride, diff --git a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx index 23dac1eee3..31a40e7e72 100644 --- a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx +++ b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx @@ -1,3 +1,4 @@ +import { createPuckFieldTransforms } from "../../fields/fieldTransforms.ts"; import { Puck, Data, @@ -113,6 +114,19 @@ export const InternalLayoutEditor = ({ const historyIndex = useRef(0); const { i18n } = usePlatformTranslation(); const streamDocument = useDocument(); + const renderMetadata = React.useMemo( + () => ({ ...metadata, fieldSources: new Map() }), + [metadata, streamDocument] + ); + const fieldTransforms = React.useMemo( + () => + createPuckFieldTransforms( + streamDocument.locale ?? "en", + streamDocument, + renderMetadata.fieldSources + ), + [streamDocument, renderMetadata] + ); const { errorCount, errorSources, errorDetails } = useErrorContext(); /** @@ -395,6 +409,7 @@ export const InternalLayoutEditor = ({ return ( ); diff --git a/packages/visual-editor/src/internal/puck/constant-value-fields/Image.tsx b/packages/visual-editor/src/internal/puck/constant-value-fields/Image.tsx index 59327c1d6e..16bae95686 100644 --- a/packages/visual-editor/src/internal/puck/constant-value-fields/Image.tsx +++ b/packages/visual-editor/src/internal/puck/constant-value-fields/Image.tsx @@ -5,6 +5,7 @@ import { getRandomPlaceholderImageObject } from "../../../utils/imagePlaceholder export const IMAGE_CONSTANT_CONFIG: ImageField = { type: "image", + constantValueEditor: true, }; export const IMAGE_LIST_CONSTANT_CONFIG: YextFieldDefinition = { diff --git a/packages/visual-editor/src/sectionLibrarySupport.ts b/packages/visual-editor/src/sectionLibrarySupport.ts index dabc30624b..f8e0b55fad 100644 --- a/packages/visual-editor/src/sectionLibrarySupport.ts +++ b/packages/visual-editor/src/sectionLibrarySupport.ts @@ -21,7 +21,6 @@ export { type HeadingProps, } from "./components/atoms/heading.tsx"; export { - getImageAltText, Image, imgSizesHelper, type ImageProps, diff --git a/packages/visual-editor/src/utils/index.ts b/packages/visual-editor/src/utils/index.ts index ddc6a0619b..8245f91651 100644 --- a/packages/visual-editor/src/utils/index.ts +++ b/packages/visual-editor/src/utils/index.ts @@ -27,6 +27,7 @@ export { } from "./migrate.ts"; export { resolveComponentData } from "./resolveComponentData.tsx"; export { resolveYextEntityField } from "./resolveYextEntityField.ts"; +export { migrateImageField } from "./migrateImageField.ts"; export { createItemSource, createSlottedItemSource, @@ -69,3 +70,5 @@ export { resolveBreadcrumbs, type BreadcrumbLink, } from "./urls/resolveBreadcrumbs.ts"; + +export { richTextToPlainText } from "./plainText.ts"; diff --git a/packages/visual-editor/src/utils/itemSource/itemSourceFieldTransforms.ts b/packages/visual-editor/src/utils/itemSource/itemSourceFieldTransforms.ts index a5f4ba3ad9..e5b9a5c083 100644 --- a/packages/visual-editor/src/utils/itemSource/itemSourceFieldTransforms.ts +++ b/packages/visual-editor/src/utils/itemSource/itemSourceFieldTransforms.ts @@ -19,6 +19,9 @@ import { type EntityFieldTypes } from "../../internal/utils/getFilteredEntityFie export function getMappingItemField( field: YextFieldDefinition ): YextFieldDefinition { + if (field.type === "price") { + return { ...field, disableConstantValueToggle: true }; + } if ( isEntityFieldDefinition(field) && field.disableConstantValueToggle === undefined @@ -164,6 +167,15 @@ export function getDefaultValueForField( field: YextFieldDefinition, constantValueEnabled: boolean ): unknown { + if (field.type === "price") { + return { + field: "", + constantValueEnabled: field.disableConstantValueToggle + ? false + : constantValueEnabled, + constantValue: undefined, + }; + } if (isEntityFieldDefinition(field)) { return { field: "", @@ -196,6 +208,9 @@ export function getDefaultValueForField( function getNestedItemSourceTypes( field: YextFieldDefinition ): EntityFieldTypes[][] { + if (field.type === "price") { + return [["type.price"]]; + } if (isEntityFieldDefinition(field)) { return field.filter.types?.length ? [field.filter.types] : []; } diff --git a/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts b/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts index e6df799af8..5921c60377 100644 --- a/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts +++ b/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts @@ -20,7 +20,12 @@ export const resolveItemValue = ( field: YextFieldDefinition, value: unknown, streamDocument: StreamDocument, - itemDocument?: StreamDocument + itemDocument?: StreamDocument, + transform?: ( + field: YextFieldDefinition, + value: unknown, + streamDocument: StreamDocument + ) => unknown ): ResolvedItemField => { if (isEntityFieldDefinition(field)) { const entityField = value as Partial> | undefined; @@ -28,14 +33,24 @@ export const resolveItemValue = ( return undefined as ResolvedItemField; } - return resolveYextEntityField( - itemDocument ?? streamDocument, - { - field: entityField?.field ?? "", - constantValue: entityField?.constantValue, - constantValueEnabled: entityField?.constantValueEnabled, - }, - streamDocument.locale + if (!transform) { + return resolveYextEntityField( + itemDocument ?? streamDocument, + { + field: entityField?.field ?? "", + constantValue: entityField?.constantValue, + constantValueEnabled: entityField?.constantValueEnabled, + }, + streamDocument.locale + ) as ResolvedItemField; + } + } + + if (transform && field.type !== "object" && field.type !== "array") { + return transform( + field, + value, + itemDocument ?? streamDocument ) as ResolvedItemField; } @@ -52,7 +67,8 @@ export const resolveItemValue = ( nestedField as YextFieldDefinition, objectValue[key], streamDocument, - itemDocument + itemDocument, + transform ), ]) ) as ResolvedItemField; @@ -71,7 +87,8 @@ export const resolveItemValue = ( nestedField as YextFieldDefinition, item?.[key], streamDocument, - itemDocument + itemDocument, + transform ), ]) ) diff --git a/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts b/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts index c5387edf8a..0f5f07215d 100644 --- a/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts +++ b/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts @@ -58,7 +58,7 @@ export type ResolvedItemField = * Public contract returned by `createItemSource(...)`. */ export type ItemSourceInstance> = { - field: YextFieldDefinition>; + field: RepeatedEntityFieldDefinition; defaultValue: RepeatedEntityFieldValue; value: RepeatedEntityFieldValue; resolveItems: ( diff --git a/packages/visual-editor/src/utils/migrateImageField.test.ts b/packages/visual-editor/src/utils/migrateImageField.test.ts new file mode 100644 index 0000000000..13c740f920 --- /dev/null +++ b/packages/visual-editor/src/utils/migrateImageField.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { migrateImageField } from "./migrateImageField.ts"; +import { imageFieldMigration } from "../components/migrations/0083_image_field.ts"; + +describe("image field migration", () => { + it.each([ + { + name: "mapped binding", + value: { field: "photo", constantValue: { url: "/static.jpg" } }, + expected: { + field: "photo", + constantValueEnabled: false, + constantValue: { + defaultValue: { url: "/static.jpg" }, + hasLocalizedValue: "true", + }, + }, + }, + { + name: "static complex image", + value: { + field: "photo", + constantValueEnabled: true, + constantValue: { + image: { url: "/static.jpg" }, + description: "Caption", + }, + }, + expected: { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: { url: "/static.jpg" }, + hasLocalizedValue: "true", + }, + }, + }, + { + name: "localized static image", + value: { + en: { image: { url: "/en.jpg" } }, + fr: { url: "/fr.jpg" }, + hasLocalizedValue: "true", + }, + expected: { + field: "", + constantValueEnabled: true, + constantValue: { + en: { url: "/en.jpg" }, + fr: { url: "/fr.jpg" }, + defaultValue: { url: "/en.jpg" }, + hasLocalizedValue: "true", + }, + }, + }, + ])( + "when migrating $name then authored mappings and localized images are preserved", + ({ value, expected }) => { + const before = structuredClone(value); + expect(migrateImageField(value, { locale: "en" })).toEqual(expected); + const action = imageFieldMigration.ImageWrapper; + if (action.action !== "updated") + throw new Error("Expected updated migration"); + expect( + action.propTransformation( + { id: "image", data: { image: value, link: "#" } }, + { locale: "en" } + ) + ).toEqual({ id: "image", data: { image: expected, link: "#" } }); + expect(value).toEqual(before); + } + ); +}); diff --git a/packages/visual-editor/src/utils/migrateImageField.ts b/packages/visual-editor/src/utils/migrateImageField.ts new file mode 100644 index 0000000000..500027bf26 --- /dev/null +++ b/packages/visual-editor/src/utils/migrateImageField.ts @@ -0,0 +1,46 @@ +import type { StreamDocument } from "./types/StreamDocument.ts"; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +/** Converts one legacy image binding to the mapped/static image-field format. */ +export const migrateImageField = ( + value: unknown, + streamDocument: StreamDocument +): Record => { + const authored = isRecord(value) ? value : {}; + const mapped = + typeof authored.field === "string" && + authored.field !== "" && + !authored.constantValueEnabled; + const source = "constantValue" in authored ? authored.constantValue : value; + const image = + isRecord(source) && isRecord(source.image) ? source.image : source; + const locale = streamDocument.locale ?? "en"; + const localized = + isRecord(image) && + (image.hasLocalizedValue === "true" || "defaultValue" in image) + ? { + ...Object.fromEntries( + Object.entries(image).map(([key, entry]) => [ + key, + isRecord(entry) && isRecord(entry.image) ? entry.image : entry, + ]) + ), + defaultValue: (() => { + const entry = image.defaultValue ?? image[locale]; + return isRecord(entry) && isRecord(entry.image) + ? entry.image + : entry; + })(), + hasLocalizedValue: "true", + } + : { defaultValue: image, hasLocalizedValue: "true" }; + + return { + field: mapped ? authored.field : "", + constantValueEnabled: !mapped, + constantValue: localized, + }; +}; diff --git a/packages/visual-editor/src/utils/resolveComponentData.tsx b/packages/visual-editor/src/utils/resolveComponentData.tsx index 9818f73332..0939b306e2 100644 --- a/packages/visual-editor/src/utils/resolveComponentData.tsx +++ b/packages/visual-editor/src/utils/resolveComponentData.tsx @@ -25,7 +25,7 @@ type ResolveComponentDataOptions = { isDarkBackground?: boolean; className?: string; color?: ThemeColor; - output?: "render" | "plainText"; + output?: "render" | "plainText" | "data"; }; /** @@ -79,6 +79,19 @@ export function resolveComponentData( streamDocument?: Record ): TranslatableAssetImage | undefined; +/** Resolve localized data without creating rich-text elements. */ +export function resolveComponentData( + data: + | YextEntityField + | TranslatableString + | TranslatableRichText + | TranslatableAssetImage + | undefined, + locale: string, + streamDocument: Record | undefined, + options: { output: "data" } +): T | undefined; + // --- Implementation --- export function resolveComponentData( data: @@ -98,7 +111,11 @@ export function resolveComponentData( // Fully resolve the resulting value, converting any translatable // objects into their final string or React element form. - const resolved = resolveTranslatableType(rawValue, locale); + const resolved = resolveTranslatableType( + rawValue, + locale, + options?.output === "data" + ); // If the resolved value is a RTF react element, wrap it in a div with tailwind classes if (React.isValidElement(resolved)) { @@ -130,7 +147,8 @@ export function resolveComponentData( */ const resolveTranslatableType = ( value: any, - locale: string + locale: string, + dataOnly = false ): any | string | React.ReactElement => { // If the value is already a React element, return it immediately. if (React.isValidElement(value)) { @@ -143,7 +161,7 @@ const resolveTranslatableType = ( // Handle a direct RichText object that is not inside a Translatable object. if (isRichText(value)) { - return toStringOrElement(value); + return dataOnly ? value : toStringOrElement(value); } const localizedValue = value[locale] ?? value.defaultValue; @@ -152,7 +170,7 @@ const resolveTranslatableType = ( if (isTranslatableContainer) { if (isRichText(localizedValue)) { - return toStringOrElement(localizedValue); + return dataOnly ? localizedValue : toStringOrElement(localizedValue); } if ( @@ -163,17 +181,17 @@ const resolveTranslatableType = ( return localizedValue ?? ""; } - return resolveTranslatableType(localizedValue, locale); + return resolveTranslatableType(localizedValue, locale, dataOnly); } if (Array.isArray(value)) { - return value.map((item) => resolveTranslatableType(item, locale)); + return value.map((item) => resolveTranslatableType(item, locale, dataOnly)); } // If it's an object, recursively resolve each property. const newValue: { [key: string]: any } = {}; for (const key in value) { - newValue[key] = resolveTranslatableType(value[key], locale); + newValue[key] = resolveTranslatableType(value[key], locale, dataOnly); } return newValue; }; diff --git a/packages/visual-editor/src/vite-plugin/templates/section-library-render.tsx b/packages/visual-editor/src/vite-plugin/templates/section-library-render.tsx index c739f31cde..1a587abb90 100644 --- a/packages/visual-editor/src/vite-plugin/templates/section-library-render.tsx +++ b/packages/visual-editor/src/vite-plugin/templates/section-library-render.tsx @@ -1,7 +1,7 @@ /* SECTION_LIBRARY_GENERATED_FILE */ import "@yext/visual-editor/style.css"; import "../index.css"; -import { type Data, Render, resolveAllData } from "@puckeditor/core"; +import { type Data, resolveAllData } from "@puckeditor/core"; import { AnalyticsProvider, SchemaWrapper } from "@yext/pages-components"; import { type GetHeadConfig, @@ -14,6 +14,7 @@ import { type TransformProps, } from "@yext/pages"; import { + VisualEditorRender, applyAnalytics, applyCertifiedFacts, applyHeaderScript, @@ -166,7 +167,7 @@ const SectionLibraryLayout: Template = (props) => { translationLoaders={translationLoaders} > -