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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions field-transforms-plan.md
Original file line number Diff line number Diff line change
@@ -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<Props, typeof fields>` derives render props from the field schema. Define fields with `satisfies YextFields<Props>` 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.
79 changes: 8 additions & 71 deletions packages/visual-editor/src/components/atoms/image.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,32 @@
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;
className?: string;
/** 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<string, any>;
style?: React.CSSProperties;
}

export const getImageAltText = (
image: ImageType | ComplexImageType | AssetImageType | undefined,
locale: string,
streamDocument: StreamDocument | Record<string, any>
): 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<ImageProps> = ({
image: rawImage,
image,
aspectRatio,
width,
imageFillType,
className,
sizes,
loading = "lazy",
streamDocumentOverride,
style,
}) => {
const { i18n } = useTranslation();
const streamDocument: StreamDocument | Record<string, any> =
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;
Expand All @@ -92,7 +36,6 @@ export const Image: React.FC<ImageProps> = ({
? `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,
Expand All @@ -108,7 +51,7 @@ export const Image: React.FC<ImageProps> = ({
>
{aspectRatio ? (
<ImageComponent
image={{ ...image, alternateText: altText }}
image={image}
layout={"aspect"}
aspectRatio={aspectRatio}
className="object-cover w-full h-full"
Expand All @@ -119,7 +62,7 @@ export const Image: React.FC<ImageProps> = ({
/>
) : !!width && !!calculatedHeight ? (
<ImageComponent
image={{ ...image, alternateText: altText }}
image={image}
layout={"fixed"}
width={width}
height={calculatedHeight}
Expand All @@ -131,8 +74,8 @@ export const Image: React.FC<ImageProps> = ({
/>
) : (
<img
src={isComplexImageType(image) ? image.image.url : image.url}
alt={altText}
src={image.url}
alt={image.alternateText}
className="object-cover w-full h-full"
loading={loading}
style={imageStyle}
Expand All @@ -142,12 +85,6 @@ export const Image: React.FC<ImageProps> = ({
);
};

function isComplexImageType(
image: ImageType | ComplexImageType | AssetImageType
): image is ComplexImageType {
return "image" in image;
}

export type ImgSizesByBreakpoint = {
base: string;
sm?: string;
Expand Down
84 changes: 40 additions & 44 deletions packages/visual-editor/src/components/contentBlocks/Address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
import {
ComponentData,
DefaultComponentProps,
PuckComponent,
setDeep,
} from "@puckeditor/core";
import {
Expand All @@ -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,
Expand Down Expand Up @@ -61,16 +60,16 @@ export interface AddressProps {
}

// Address field definition used in Address and CoreInfoSection
export const AddressDataField: YextFields<AddressProps["data"]> = {
export const AddressDataField = {
address: {
type: "entityField",
label: msg("fields.address", "Address"),
filter: { types: ["type.address"] },
},
};
} satisfies YextFields<AddressProps["data"]>;

// Address style fields used in Address and CoreInfoSection
export const AddressStyleFields: YextFields<AddressProps["styles"]> = {
export const AddressStyleFields = {
showRegion: {
label: msg("fields.showRegion", "Show Region"),
type: "radio",
Expand Down Expand Up @@ -105,9 +104,9 @@ export const AddressStyleFields: YextFields<AddressProps["styles"]> = {
label: msg("fields.linkColor", "Link Color"),
options: "SITE_COLOR",
},
};
} satisfies YextFields<AddressProps["styles"]>;

export const addressFields: YextFields<AddressProps> = {
export const addressFields = {
data: {
type: "object",
label: msg("fields.data", "Data"),
Expand All @@ -118,21 +117,17 @@ export const addressFields: YextFields<AddressProps> = {
label: msg("fields.styles", "Styles"),
objectFields: AddressStyleFields,
},
};
} satisfies YextFields<AddressProps>;

const AddressComponent: PuckComponent<AddressProps> = (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(
Expand All @@ -151,7 +146,7 @@ const AddressComponent: PuckComponent<AddressProps> = (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 = !!(
Expand All @@ -166,8 +161,8 @@ const AddressComponent: PuckComponent<AddressProps> = (props) => {
<div className="flex flex-col gap-2 text-body-fontSize font-body-fontWeight font-body-fontFamily">
<EntityField
displayName={parentData ? parentData.field : pt("address", "Address")}
fieldId={data.address.field}
constantValueEnabled={!parentData && data.address.constantValueEnabled}
fieldId={source?.field}
constantValueEnabled={!parentData && source?.constantValueEnabled}
>
<RenderAddress
address={address}
Expand Down Expand Up @@ -222,30 +217,31 @@ export const resolveAddressFields = (
return updatedFields;
};

export const Address: YextComponentConfig<AddressProps> = {
label: msg("components.address", "Address"),
fields: addressFields,
defaultProps: {
data: {
address: {
field: "address",
constantValue: {
line1: "",
city: "",
region: "",
postalCode: "",
countryCode: "",
export const Address: YextComponentConfig<AddressProps, typeof addressFields> =
{
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) => <AddressComponent {...props} />,
};
resolveFields: resolveAddressFields,
render: AddressComponent,
};
Loading
Loading