From 1eedefe1e97d8cd2ea95a53e84a1a0bc820fb8e5 Mon Sep 17 00:00:00 2001 From: Jacob Wartofsky Date: Tue, 6 Oct 2026 15:55:24 -0400 Subject: [PATCH 1/9] WIP: fieldTransforms --- packages/visual-editor/package.json | 2 +- .../src/components/helpers/index.ts | 6 + packages/visual-editor/src/editor/README.md | 106 ++++++ .../src/editor/VisualEditorRender.test.tsx | 164 ++++++++ .../src/editor/VisualEditorRender.tsx | 18 +- .../src/fields/EntityFieldSelectorField.tsx | 2 + .../src/fields/TranslatableStringField.tsx | 2 + .../src/fields/fieldTransforms.test.tsx | 355 ++++++++++++++++++ .../src/fields/fieldTransforms.tsx | 103 +++++ packages/visual-editor/src/fields/fields.ts | 39 +- packages/visual-editor/src/fields/index.ts | 2 + .../src/fields/yextFieldAdapter.ts | 5 +- .../components/InternalLayoutEditor.tsx | 10 + .../components/InternalThemeEditor.tsx | 12 + .../TransformedFieldTooltip.test.tsx | 55 +++ .../components/TransformedFieldTooltip.tsx | 105 ++++++ .../src/sectionLibrarySupport.ts | 3 + .../templates/section-library-render.tsx | 5 +- pnpm-lock.yaml | 10 +- 19 files changed, 993 insertions(+), 11 deletions(-) create mode 100644 packages/visual-editor/src/editor/VisualEditorRender.test.tsx create mode 100644 packages/visual-editor/src/fields/fieldTransforms.test.tsx create mode 100644 packages/visual-editor/src/fields/fieldTransforms.tsx create mode 100644 packages/visual-editor/src/internal/puck/components/TransformedFieldTooltip.test.tsx create mode 100644 packages/visual-editor/src/internal/puck/components/TransformedFieldTooltip.tsx diff --git a/packages/visual-editor/package.json b/packages/visual-editor/package.json index ee0a5cacba..cad8593ba6 100644 --- a/packages/visual-editor/package.json +++ b/packages/visual-editor/package.json @@ -74,7 +74,7 @@ }, "dependencies": { "@mapbox/maki": "^8.2.0", - "@puckeditor/core": "0.22.2", + "@puckeditor/core": "0.24.0-canary.c1ec9773", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.13", diff --git a/packages/visual-editor/src/components/helpers/index.ts b/packages/visual-editor/src/components/helpers/index.ts index cd5c821988..874288a73b 100644 --- a/packages/visual-editor/src/components/helpers/index.ts +++ b/packages/visual-editor/src/components/helpers/index.ts @@ -31,3 +31,9 @@ export { type MaybeRTFProps, MaybeRTF, } from "./maybeRTF.tsx"; + +/** + * A shared rich-text renderer that accepts resolved content through `data`. + * Renders resolved content without exposing field resolution to consumers. + */ +export { MaybeRTF as RichTextRenderer } from "./maybeRTF.tsx"; diff --git a/packages/visual-editor/src/editor/README.md b/packages/visual-editor/src/editor/README.md index 1846578be3..f6ef8b404e 100644 --- a/packages/visual-editor/src/editor/README.md +++ b/packages/visual-editor/src/editor/README.md @@ -125,6 +125,112 @@ const Example = ({ myField }: ExampleProps) => { }; ``` +## Opt-in field transforms + +Add `transform: true` to an `entityField` or `translatableString` to resolve its +value before component rendering. Omitted or false flags keep the authored value +unchanged. Defaults, field editors, `resolveData`, and saved layouts continue to +use authored props. + +| Field definition | Render value | +| -------------------------------------------- | ---------------------------------------------------------------------------- | +| `translatableString` | Localized, interpolated string | +| Ordinary `entityField` | Resolved entity or constant value with nested localization and interpolation | +| `entityField` including `type.rich_text_v2` | Resolved rich-text object or plain string | +| Rich-text list with `includeListsOnly: true` | Array of resolved rich-text objects or strings | + +Images retain their simple or complex image shape, dimensions, and asset metadata; +nested alt text becomes a string. Repeated item sources and CTA field types are +outside this first pass. + +Both editor previews and `VisualEditorRender` pass transforms to Puck's official +`fieldTransforms` API. This requires `@puckeditor/core@0.24.0-canary.c1ec9773`, +which includes native support in both client and server `Render`. The generated +section-library page template uses `VisualEditorRender` inside the existing +provider. Existing libraries are not migrated automatically. + +For direct Puck usage, pass +`fieldTransforms={createYextFieldTransforms(streamDocument, locale)}` to `Puck` +or `Render`. Transform resolution belongs to rendering; do not store the +returned values in layout data. + +### Hero without slots + +Keep the precise field definitions with `satisfies`, then pass `typeof fields` as +the second type argument to `YextComponentConfig`. This derives clean render +props while keeping authored defaults typed separately. + +```tsx +import { type ComplexImageType } from "@yext/pages-components"; +import { + ComprehensiveCTA, + Image, + RichTextRenderer, + type ComprehensiveCTAValue, + type TranslatableAssetImage, + type TranslatableRichText, + type TranslatableString, + type YextComponentConfig, + type YextEntityField, + type YextFieldMap, +} from "@yext/visual-editor"; + +type HeroProps = { + title: YextEntityField; + description: YextEntityField; + image: YextEntityField; + primaryCta: ComprehensiveCTAValue; + secondaryCta: ComprehensiveCTAValue; +}; + +const fields = { + title: { + type: "entityField", + label: "Title", + transform: true, + filter: { types: ["type.string"] }, + }, + description: { + type: "entityField", + label: "Description", + transform: true, + filter: { types: ["type.rich_text_v2", "type.string"] }, + }, + image: { + type: "entityField", + label: "Image", + transform: true, + filter: { types: ["type.image"] }, + }, + primaryCta: { type: "comprehensiveCTA", label: "Primary CTA" }, + secondaryCta: { type: "comprehensiveCTA", label: "Secondary CTA" }, +} satisfies YextFieldMap; + +export const Hero: YextComponentConfig = { + fields, + render: ({ title, description, image, primaryCta, secondaryCta }) => ( +
+

{title}

+ + {image && } + + +
+ ), +}; +``` + +Title, description, and image still allow entity bindings and constant values. +The transform returns resolved data, including translated and interpolated rich-text +HTML and JSON. `RichTextRenderer` accepts that data and uses the shared `MaybeRTF` +rendering internally, with optional `className`, `style`, `bodyVariant`, and +`richTextStyleOverrides`. CTAs keep their existing field and renderer API. + +Opted-in fields appear in the editor's component-level entity tooltip when +entity tooltips are enabled. The tooltip reads authored bindings and does not +add source metadata to rendered props or require `EntityField` wrappers. Fields +without `transform: true` retain their existing tooltip behavior. + ## Linked Entity Item Sources Use `createItemSource(...)` when a component needs to render repeated content diff --git a/packages/visual-editor/src/editor/VisualEditorRender.test.tsx b/packages/visual-editor/src/editor/VisualEditorRender.test.tsx new file mode 100644 index 0000000000..00e473e5d6 --- /dev/null +++ b/packages/visual-editor/src/editor/VisualEditorRender.test.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { Puck, type Config, type Data } from "@puckeditor/core"; +import { Render as ServerRender } from "@puckeditor/core/rsc"; +import { render, screen, cleanup } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorProvider } from "../contexts/ErrorContext.tsx"; +import { TemplatePropsContext } from "../hooks/useDocument.tsx"; +import { MaybeRTF as RichTextRenderer } from "../components/helpers/maybeRTF.tsx"; +import { createYextFieldTransforms } from "../fields/fieldTransforms.tsx"; +import { toPuckFields } from "../fields/fields.ts"; +import { VisualEditorRender } from "./VisualEditorRender.tsx"; + +vi.mock("react-i18next", async () => ({ + ...(await vi.importActual("react-i18next")), + useTranslation: () => ({ i18n: { language: "es" } }), +})); + +beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("native Puck field transforms", () => { + it.each(["live page", "server render", "editor preview"])( + "when fields opt in then the %s receives resolved props and keeps authored data", + async (mode) => { + const data: Data = { + root: { props: {} }, + content: [ + { + type: "Hero", + props: { + id: "hero", + title: { field: "name", constantValue: "" }, + description: { field: "description", constantValue: "" }, + image: { field: "photo", constantValue: {} }, + nested: { + label: { defaultValue: "Welcome", es: "Hola [[name]]" }, + }, + rows: [{ label: { defaultValue: "List", es: "Fila" } }], + untouched: { field: "name", constantValue: "Original" }, + }, + }, + ], + }; + const authoredData = structuredClone(data); + const config: Config = { + components: { + Hero: { + fields: toPuckFields({ + title: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + description: { + type: "entityField", + transform: true, + filter: { types: ["type.rich_text_v2"] }, + }, + image: { + type: "entityField", + transform: true, + filter: { types: ["type.image"] }, + }, + nested: { + type: "object", + objectFields: { + label: { type: "translatableString", transform: true }, + }, + }, + rows: { + type: "array", + arrayFields: { + label: { type: "translatableString", transform: true }, + }, + }, + untouched: { + type: "entityField", + filter: { types: ["type.string"] }, + }, + }), + render: ({ + title, + description, + image, + nested, + rows, + untouched, + }) => ( +
+

{title}

+ + {image.alternateText} + {nested.label} + {rows[0].label} + {untouched.constantValue} +
+ ), + }, + }, + }; + const streamDocument = { + locale: "es", + name: "Restaurant", + description: { html: "

Shared rich text

", json: "{}" }, + photo: { + url: "/hero.jpg", + alternateText: { defaultValue: "Image", es: "Restaurant image" }, + }, + }; + const fieldTransforms = createYextFieldTransforms(streamDocument, "es"); + render( + + + {mode === "live page" ? ( + + ) : mode === "server render" ? ( + + ) : ( + + + + )} + + + ); + // JSDOM does not measure Puck's layout, so query the preview's content directly. + expect((await screen.findByText("Restaurant")).tagName).toBe("H1"); + expect(screen.getByText("Shared rich text")).toBeTruthy(); + expect( + screen.getByRole("img", { hidden: true }).getAttribute("src") + ).toBe("/hero.jpg"); + expect( + screen.getByRole("img", { hidden: true }).getAttribute("alt") + ).toBe("Restaurant image"); + expect(screen.getByText("Hola Restaurant")).toBeTruthy(); + expect(screen.getByText("Fila")).toBeTruthy(); + expect(screen.getByText("Original")).toBeTruthy(); + expect(data).toEqual(authoredData); + } + ); +}); diff --git a/packages/visual-editor/src/editor/VisualEditorRender.tsx b/packages/visual-editor/src/editor/VisualEditorRender.tsx index 97d3c625cb..f2d3cfd19b 100644 --- a/packages/visual-editor/src/editor/VisualEditorRender.tsx +++ b/packages/visual-editor/src/editor/VisualEditorRender.tsx @@ -1,5 +1,8 @@ import { Config, Data, Metadata, Render } from "@puckeditor/core"; import React from "react"; +import { useTranslation } from "react-i18next"; +import { useDocument } from "../hooks/useDocument.tsx"; +import { createYextFieldTransforms } from "../fields/fieldTransforms.tsx"; import { wrapConfigWithComponentErrorBoundary } from "../internal/utils/wrapConfigWithComponentErrorBoundary.tsx"; export type VisualEditorRenderProps = { @@ -13,9 +16,22 @@ export const VisualEditorRender = ({ data, metadata, }: VisualEditorRenderProps) => { + const streamDocument = useDocument(); + const { i18n } = useTranslation(); + const fieldTransforms = React.useMemo( + () => createYextFieldTransforms(streamDocument, i18n.language), + [streamDocument, i18n.language] + ); const wrappedConfig = React.useMemo(() => { return wrapConfigWithComponentErrorBoundary(config); }, [config]); - return ; + return ( + + config={wrappedConfig} + data={data} + metadata={metadata} + fieldTransforms={fieldTransforms} + /> + ); }; diff --git a/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx b/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx index 78b5cb4c11..32163b6a80 100644 --- a/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx +++ b/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx @@ -69,6 +69,8 @@ export type EntityFieldSelectorField< T extends Record = Record, > = BaseField & { type: "entityField"; + /** Resolves this field before rendering. Repeated sources are not supported. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; filter: MappedSourceFieldFilter; diff --git a/packages/visual-editor/src/fields/TranslatableStringField.tsx b/packages/visual-editor/src/fields/TranslatableStringField.tsx index d9ef085481..382c815f84 100644 --- a/packages/visual-editor/src/fields/TranslatableStringField.tsx +++ b/packages/visual-editor/src/fields/TranslatableStringField.tsx @@ -16,6 +16,8 @@ import { resolveComponentData } from "../utils/resolveComponentData.tsx"; export type TranslatableStringField = BaseField & { type: "translatableString"; + /** Localizes and interpolates this field before rendering. */ + transform?: boolean; label?: string | MsgString; visible?: boolean; filter?: RenderEntityFieldFilter; diff --git a/packages/visual-editor/src/fields/fieldTransforms.test.tsx b/packages/visual-editor/src/fields/fieldTransforms.test.tsx new file mode 100644 index 0000000000..edafe4be4b --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms.test.tsx @@ -0,0 +1,355 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { TranslatableAssetImage } from "../types/images.ts"; +import type { + RichText, + TranslatableRichText, + TranslatableString, +} from "../types/types.ts"; +import type { YextEntityField } from "../editor/YextEntityFieldSelector.tsx"; +import { createYextFieldTransforms } from "./fieldTransforms.tsx"; +import { RichTextRenderer } from "../components/helpers/index.ts"; +import { + toPuckFields, + type YextComponentConfig, + type YextFieldMap, +} from "./fields.ts"; + +describe("field transforms", () => { + it.each([ + { + name: "when a KG field is selected then its value is resolved", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { field: "name", constantValue: "Unused" }, + expected: "Restaurant", + }, + { + name: "when a linked field is selected then its value is resolved", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { field: "linked.name", constantValue: "" }, + expected: "Linked Restaurant", + }, + { + name: "when a constant is selected then it is localized and interpolated", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { + field: "name", + constantValueEnabled: true, + constantValue: { defaultValue: "Welcome", es: "Hola [[name]]" }, + }, + expected: "Hola Restaurant", + }, + { + name: "when a string is translatable then its locale is selected", + field: { type: "translatableString", transform: true }, + value: { defaultValue: "Welcome", es: "Hola [[name]]" }, + expected: "Hola Restaurant", + }, + { + name: "when a locale is absent then the default text is used", + field: { type: "translatableString", transform: true }, + value: { defaultValue: "Welcome [[name]]" }, + expected: "Welcome Restaurant", + }, + { + name: "when localized text is empty then it stays empty", + field: { type: "translatableString", transform: true }, + value: { defaultValue: "Welcome", es: "" }, + expected: "", + }, + { + name: "when a field is missing then its value is undefined", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + value: { field: "missing", constantValue: "Unused" }, + expected: undefined, + }, + { + name: "when a numeric field is zero then zero is preserved", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.number"] }, + }, + value: { field: "count", constantValue: 99 }, + expected: 0, + }, + { + name: "when rich text is localized then resolved HTML and JSON are returned", + field: { + type: "entityField", + transform: true, + filter: { types: ["type.rich_text_v2"] }, + }, + value: { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: { html: "

Welcome

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

Hola [[name]]

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

Hola Restaurant

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

Hello

" }, + es: { html: "

Hola [[name]]

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

Hola Restaurant

" }], + }, + ])("$name", ({ field, value, expected }) => { + const authoredValue = structuredClone(value); + expect( + createYextFieldTransforms( + { + name: "Restaurant", + linked: [{ name: "Linked Restaurant" }], + count: 0, + }, + "es" + ).custom({ + field, + value, + componentId: "hero", + propName: "title", + propPath: "title", + isReadOnly: false, + }) + ).toEqual(expected); + expect(value).toEqual(authoredValue); + }); + + it.each([undefined, false])( + "when transform is %s then authored values retain their identity", + (transform) => { + const value = { field: "name", constantValue: { defaultValue: "Title" } }; + expect( + createYextFieldTransforms({ name: "Restaurant" }, "en").entityField({ + field: toPuckFields({ + title: { + type: "entityField", + transform, + filter: { types: ["type.string"] }, + }, + }).title!, + value, + componentId: "hero", + propName: "title", + propPath: "title", + isReadOnly: false, + }) + ).toBe(value); + } + ); + + it.each([ + { + name: "rich text", + value: { defaultValue: { html: "

Hello [[name]]

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

Hello Restaurant

", json: "{}" }, + expectedText: "Hello Restaurant", + }, + { + name: "a plain string", + value: { defaultValue: "Hello [[name]]" }, + expected: "Hello Restaurant", + expectedText: "Hello Restaurant", + }, + { + name: "empty content", + value: undefined, + expected: undefined, + expectedText: "", + }, + ])( + "when a rich-text field contains $name then it returns resolved data for the shared renderer", + ({ value, expected, expectedText }) => { + const data = createYextFieldTransforms( + { name: "Restaurant" }, + "en" + ).entityField({ + field: toPuckFields({ + description: { + type: "entityField", + transform: true, + filter: { types: ["type.string", "type.rich_text_v2"] }, + }, + }).description!, + value: { field: "", constantValue: value, constantValueEnabled: true }, + componentId: "hero", + propName: "description", + propPath: "description", + isReadOnly: true, + }); + expect(data).toEqual(expected); + const html = renderToStaticMarkup( + + ); + expect(html).toContain(expectedText); + if (expectedText) { + expect(html).toContain("hero-description"); + } else { + expect(html).toBe(""); + } + } + ); + + it("when a localized image is selected then complex shape and asset metadata are retained", () => { + const value = { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: { image: { url: "/default.jpg" } }, + es: { + image: { + url: "/hero.jpg", + width: 640, + height: 360, + alternateText: { defaultValue: "Image", es: "[[name]]" }, + assetImage: { + sourceUrl: "/original.jpg", + transformations: { ROTATION: { degree: 90 } }, + }, + }, + }, + }, + }; + const authoredValue = structuredClone(value); + expect( + createYextFieldTransforms({ name: "Restaurant" }, "es").entityField({ + field: toPuckFields({ + image: { + type: "entityField", + transform: true, + filter: { types: ["type.image"] }, + }, + }).image!, + value, + componentId: "hero", + propName: "image", + propPath: "image", + isReadOnly: true, + }) + ).toEqual({ + image: { ...value.constantValue.es.image, alternateText: "Restaurant" }, + }); + expect(value).toEqual(authoredValue); + }); + + it("when a repeated source opts in then it reports the unsupported configuration", () => { + expect(() => + createYextFieldTransforms({}, "en").entityField({ + field: { + type: "custom", + metadata: { + yextField: { + type: "entityField", + transform: true, + repeated: {}, + filter: {}, + }, + }, + }, + value: { field: "items", constantValue: [] }, + componentId: "hero", + propName: "items", + propPath: "items", + isReadOnly: true, + }) + ).toThrow("Field transforms do not support repeated entity sources."); + }); + + it("when fields opt in then their config derives render types while retaining authored defaults", () => { + const fields = { + title: { + type: "entityField", + transform: true, + filter: { types: ["type.string"] }, + }, + description: { + type: "entityField", + transform: true, + filter: { types: ["type.string", "type.rich_text_v2"] }, + }, + image: { + type: "entityField", + transform: true, + filter: { types: ["type.image"] }, + }, + label: { type: "translatableString", transform: true }, + untouched: { type: "entityField", filter: { types: ["type.string"] } }, + } satisfies YextFieldMap<{ + title: YextEntityField; + description: YextEntityField; + image: YextEntityField; + label: TranslatableString; + untouched: YextEntityField; + }>; + const config: YextComponentConfig< + { + title: YextEntityField; + description: YextEntityField; + image: YextEntityField; + label: TranslatableString; + untouched: YextEntityField; + }, + typeof fields + > = { + fields, + render: ({ title, description, image, label, untouched }) => { + expectTypeOf(title).toEqualTypeOf(); + expectTypeOf(description).toEqualTypeOf< + RichText | string | undefined + >(); + expectTypeOf(image?.alternateText).toEqualTypeOf(); + expectTypeOf(label).toEqualTypeOf(); + expectTypeOf(untouched).toMatchTypeOf< + YextEntityField + >(); + return ; + }, + }; + expect(toPuckFields(config.fields!).description.metadata?.yextField).toBe( + fields.description + ); + }); +}); diff --git a/packages/visual-editor/src/fields/fieldTransforms.tsx b/packages/visual-editor/src/fields/fieldTransforms.tsx new file mode 100644 index 0000000000..ba2bb9ebc4 --- /dev/null +++ b/packages/visual-editor/src/fields/fieldTransforms.tsx @@ -0,0 +1,103 @@ +import type { BaseField, FieldTransformFn } from "@puckeditor/core"; +import type { EntityFieldSelectorField } from "./EntityFieldSelectorField.tsx"; +import type { TranslatableStringField } from "./TranslatableStringField.tsx"; +import type { YextEntityField } from "../editor/YextEntityFieldSelector.tsx"; +import type { StreamDocument } from "../utils/types/StreamDocument.ts"; +import { + resolveEmbeddedFieldsRecursively, + resolveYextEntityField, +} from "../utils/resolveYextEntityField.ts"; + +/** Retrieves the authored field definition retained by the Puck field adapter. */ +export function getTransformField( + field: BaseField & { type: string } +): EntityFieldSelectorField | TranslatableStringField | undefined { + const authoredField = + field.type === "custom" ? field.metadata?.yextField : field; + if ( + authoredField?.transform === true && + (authoredField.type === "entityField" || + authoredField.type === "translatableString") + ) { + return authoredField; + } + return undefined; +} + +/** Localizes nested values while retaining rich-text and image structure. */ +function localizeValue(value: any, locale: string): any { + if (!value || typeof value !== "object") { + return value; + } + if (value.hasLocalizedValue === "true" || "defaultValue" in value) { + return localizeValue(value[locale] ?? value.defaultValue, locale); + } + if (Array.isArray(value)) { + return value.map((item) => localizeValue(item, locale)); + } + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + localizeValue(nestedValue, locale), + ]) + ); +} + +/** + * Creates opt-in render transforms for one page and locale. + * + * 1. Identify opted-in authored fields, including fields adapted to `custom`. + * 2. Resolve entity bindings, constant values, localization, and embedded fields. + * 3. Preserve resolved rich-text data and other value shapes for their renderers. + * Authored values are never mutated or replaced in saved Puck data. + */ +export function createYextFieldTransforms( + streamDocument: StreamDocument, + locale: string +): Record< + "entityField" | "translatableString" | "custom", + FieldTransformFn +> { + const transform = ({ + field, + value, + }: { + field: BaseField & { type: string }; + value: any; + }): any => { + const authoredField = getTransformField(field); + if (!authoredField) { + return value; + } + if (authoredField.type === "entityField" && authoredField.repeated) { + throw new Error( + "Field transforms do not support repeated entity sources." + ); + } + const resolvedValue = resolveEmbeddedFieldsRecursively( + localizeValue( + authoredField.type === "entityField" + ? value?.constantValueEnabled && value.constantValue !== undefined + ? value.constantValue + : resolveYextEntityField( + streamDocument, + value as YextEntityField, + locale + ) + : value, + locale + ), + streamDocument, + locale + ); + if (authoredField.type === "translatableString") { + return resolvedValue ?? ""; + } + return resolvedValue; + }; + return { + entityField: transform, + translatableString: transform, + custom: transform, + }; +} diff --git a/packages/visual-editor/src/fields/fields.ts b/packages/visual-editor/src/fields/fields.ts index c14bcb2f56..61f3e67b32 100644 --- a/packages/visual-editor/src/fields/fields.ts +++ b/packages/visual-editor/src/fields/fields.ts @@ -28,6 +28,38 @@ import type { ComprehensiveCTAField } from "./styledFields/ComprehensiveCTAField import { YextAutoField } from "./YextAutoField.tsx"; import { adaptYextFieldMap } from "./yextFieldAdapter.ts"; +type LocalizedRenderValue = Value extends readonly (infer Item)[] + ? LocalizedRenderValue[] + : Value extends object + ? "defaultValue" extends keyof Value + ? LocalizedRenderValue + : { [Key in keyof Value]: LocalizedRenderValue } + : Value; + +type TransformedFieldValue = Definition extends { + transform: true; + type: "translatableString"; +} + ? string + : Definition extends { transform: true; type: "entityField" } + ? Value extends { constantValue: infer Constant } + ? LocalizedRenderValue | undefined + : never + : Definition extends { type: "object"; objectFields: infer Nested } + ? YextTransformedProps + : Definition extends { type: "array"; arrayFields: infer Nested } + ? Value extends (infer Item)[] + ? YextTransformedProps[] + : Value + : Value; + +/** Derives render values from authored props and explicitly opted-in field definitions. */ +export type YextTransformedProps = { + [Key in keyof Props]: Key extends keyof Definitions + ? TransformedFieldValue + : Props[Key]; +}; + export type YextPuckFields = { basicSelector: BasicSelectorField; ctaSelector: CTASelectorField; @@ -79,13 +111,18 @@ export type YextFieldDefinition = export type YextComponentConfig< Props extends DefaultComponentProps = DefaultComponentProps, + TransformFields = {}, > = Omit< ComponentConfig<{ props: Props; fields: YextPuckFields; }>, - "fields" | "resolveFields" + "fields" | "resolveFields" | "render" > & { + render: ComponentConfig<{ + props: YextTransformedProps; + fields: YextPuckFields; + }>["render"]; fields?: YextFields; resolveFields?: ComponentConfig<{ props: Props; diff --git a/packages/visual-editor/src/fields/index.ts b/packages/visual-editor/src/fields/index.ts index 388fe6f350..f979cae916 100644 --- a/packages/visual-editor/src/fields/index.ts +++ b/packages/visual-editor/src/fields/index.ts @@ -53,6 +53,7 @@ export { VideoFieldOverride, type VideoField } from "./VideoField.tsx"; export { toPuckFields, type YextComponentConfig, + type YextTransformedProps, type YextArrayField, type YextFields, type YextFieldMap, @@ -65,3 +66,4 @@ export { export { YextPuckFieldOverrides } from "./fieldOverrides.ts"; export { YextAutoField } from "./YextAutoField.tsx"; +export { createYextFieldTransforms } from "./fieldTransforms.tsx"; diff --git a/packages/visual-editor/src/fields/yextFieldAdapter.ts b/packages/visual-editor/src/fields/yextFieldAdapter.ts index a059ff793a..276da46fbb 100644 --- a/packages/visual-editor/src/fields/yextFieldAdapter.ts +++ b/packages/visual-editor/src/fields/yextFieldAdapter.ts @@ -9,7 +9,10 @@ export const adaptYextField = ( renderYextField: YextFieldRender ): Field => { if (isYextOverrideType(field.type)) { - return renderYextField(field as YextPuckField); + return { + ...renderYextField(field as YextPuckField), + metadata: { ...field.metadata, yextField: field }, + }; } if (field.type === "object" && "objectFields" in field) { diff --git a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx index dc2ea34980..c81fc4b02f 100644 --- a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx +++ b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx @@ -39,6 +39,9 @@ import { useErrorContext } from "../../contexts/ErrorContext.tsx"; import { clonePuckResolveData } from "../utils/clonePuckResolveData.ts"; import { YextPuckFieldOverrides } from "../../fields/fieldOverrides.ts"; import type { MigrationRegistry } from "../../utils/migrate.ts"; +import { useTranslation } from "react-i18next"; +import { createYextFieldTransforms } from "../../fields/fieldTransforms.tsx"; +import { TransformedFieldTooltip } from "../puck/components/TransformedFieldTooltip.tsx"; const devLogger = new DevLogger(); const usePuck = createUsePuck(); @@ -113,6 +116,11 @@ export const InternalLayoutEditor = ({ const historyIndex = useRef(0); const { i18n } = usePlatformTranslation(); const streamDocument = useDocument(); + const { i18n: pageI18n } = useTranslation(); + const fieldTransforms = React.useMemo( + () => createYextFieldTransforms(streamDocument, pageI18n.language), + [streamDocument, pageI18n.language] + ); const { errorCount, errorSources, errorDetails } = useErrorContext(); /** @@ -397,10 +405,12 @@ export const InternalLayoutEditor = ({ (