diff --git a/packages/visual-editor/src/components/migrations/0083_photo_gallery_item_source.ts b/packages/visual-editor/src/components/migrations/0083_photo_gallery_item_source.ts new file mode 100644 index 0000000000..3162efad46 --- /dev/null +++ b/packages/visual-editor/src/components/migrations/0083_photo_gallery_item_source.ts @@ -0,0 +1,68 @@ +import { type Migration } from "../../utils/migrate.ts"; + +/** + * Gallery item-source migration. + * + * 1. Moves each saved manual image into an image and link item. + * 2. Keeps the linked list field and maps each image item to itself. + * 3. Leaves Gallery styles and section slots unchanged. + */ +export const photoGalleryItemSource: Migration = { + PhotoGalleryWrapper: { + action: "updated", + propTransformation: (props) => { + const images = props.data?.images; + if (!images || images.mappings) { + return props; + } + + return { + ...props, + data: { + ...props.data, + images: { + ...images, + constantValue: Array.isArray(images.constantValue) + ? images.constantValue.map((item: unknown) => { + const image = item as Record | null; + const imageValue = + image && "url" in image + ? image + : (image?.assetImage ?? image?.image ?? item); + return { + image: { + field: "", + constantValueEnabled: true, + constantValue: imageValue, + }, + link: { + field: "", + constantValueEnabled: true, + constantValue: { + defaultValue: + typeof image?.clickthroughUrl === "string" + ? image.clickthroughUrl + : "", + }, + }, + }; + }) + : [], + mappings: { + image: { + field: images.field ? "$item" : "", + constantValueEnabled: false, + constantValue: undefined, + }, + link: { + field: "", + constantValueEnabled: false, + constantValue: undefined, + }, + }, + }, + }, + }; + }, + }, +}; diff --git a/packages/visual-editor/src/components/migrations/migrationRegistry.ts b/packages/visual-editor/src/components/migrations/migrationRegistry.ts index bf6be857b0..d5321e4671 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 { photoGalleryItemSource } from "./0083_photo_gallery_item_source.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, + photoGalleryItemSource, ]; diff --git a/packages/visual-editor/src/components/pageSections/EntityMappedSectionEmptyState.test.tsx b/packages/visual-editor/src/components/pageSections/EntityMappedSectionEmptyState.test.tsx index 954bf78e97..8fb71e2795 100644 --- a/packages/visual-editor/src/components/pageSections/EntityMappedSectionEmptyState.test.tsx +++ b/packages/visual-editor/src/components/pageSections/EntityMappedSectionEmptyState.test.tsx @@ -131,6 +131,8 @@ const directSectionCases: DirectSectionCase[] = [ const props = cloneValue(PhotoGallerySection.defaultProps!); props.slots.PhotoGalleryWrapper[0].props.data.images.field = "photoGallery"; + props.slots.PhotoGalleryWrapper[0].props.data.images.mappings.image.field = + "$item"; props.slots.PhotoGalleryWrapper[0].props.data.images.constantValueEnabled = false; props.slots.PhotoGalleryWrapper[0].props.data.images.constantValue = []; return props; @@ -171,7 +173,11 @@ const directSectionCases: DirectSectionCase[] = [ HeadingSlot: createSectionHeadingSlot(), PhotoGalleryWrapper: createCardsSlot( isMappedContentEmpty ? ( - + isEditing ? ( + + ) : ( + + ) ) : ( "Gallery Images" ) @@ -255,7 +261,9 @@ describe.each(directSectionCases)( }); await waitFor(() => { - expect(result.container.childElementCount).toBe(0); + expect(result.queryByText("Section Heading")).toBeNull(); + expect(result.queryByText(visibleContentText)).toBeNull(); + expect(result.queryByText(/Section hidden for this/i)).toBeNull(); }); }); diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.test.tsx b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.test.tsx index bc42ff6a6c..1d6f20e013 100644 --- a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.test.tsx +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.test.tsx @@ -1,19 +1,35 @@ -import * as React from "react"; +import React from "react"; import { describe, it, expect } from "vitest"; import { axe, ComponentTest, transformTests, } from "../../testing/componentTests.setup.ts"; -import { render as reactRender, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render as reactRender, + waitFor, + within, +} from "@testing-library/react"; import { PhotoGallerySection } from "./PhotoGallerySection.tsx"; import { migrate } from "../../../utils/migrate.ts"; import { migrationRegistry } from "../../migrations/migrationRegistry.ts"; import { VisualEditorProvider } from "../../../utils/VisualEditorProvider.tsx"; import { SlotsCategoryComponents } from "../../categories/SlotsCategory.tsx"; -import { Render, Config } from "@puckeditor/core"; +import { + Render, + Config, + Puck, + resolveAllData, + useGetPuck, +} from "@puckeditor/core"; import { page } from "@vitest/browser/context"; import { MainContent } from "../../structure/MainContent.tsx"; +import { PhotoGalleryWrapper } from "./PhotoGalleryWrapper.tsx"; +import { photoGallerySource } from "./photoGallerySource.ts"; +import { toPuckFields } from "../../../fields/fields.ts"; +import { type StreamDocument } from "../../../utils/types/StreamDocument.ts"; const photoGalleryData = [ { @@ -945,4 +961,365 @@ describe("PhotoGallerySection", async () => { } } ); + + it("when a team headshot mapping changes then interactive mode uses the current images", async (): Promise => { + const streamDocument: StreamDocument = { + locale: "en", + c_team: { + people: [ + { + headshot: { + url: "https://example.com/jane.jpg", + width: 100, + height: 100, + alternateText: "Jane", + }, + cta: { label: "View Jane", link: "/team/jane", linkType: "URL" }, + }, + ], + }, + }; + const config: Config = { + ...puckConfig, + components: { + ...puckConfig.components, + PhotoGalleryWrapper: { + ...PhotoGalleryWrapper, + fields: toPuckFields(PhotoGalleryWrapper.fields!), + }, + }, + }; + const data = await resolveAllData( + { + root: {}, + content: [ + { + type: "PhotoGallerySection", + props: { + ...PhotoGallerySection.defaultProps, + id: "team-gallery", + styles: { variant: "gallery", showSectionHeading: true }, + slots: { + ...PhotoGallerySection.defaultProps!.slots, + HeadingSlot: [ + { + type: "HeadingTextSlot", + props: { + ...PhotoGallerySection.defaultProps!.slots!.HeadingSlot[0] + .props, + id: "team-gallery-heading", + }, + }, + ], + PhotoGalleryWrapper: [ + { + type: "PhotoGalleryWrapper", + props: { + ...PhotoGalleryWrapper.defaultProps, + id: "team-images", + data: { + images: { + ...photoGallerySource.defaultValue, + field: "c_team.people", + constantValueEnabled: false, + }, + }, + }, + }, + ], + }, + }, + }, + ], + }, + config, + { streamDocument } + ); + + const Controls = (): React.ReactElement => { + const getPuck = useGetPuck(); + return ( + <> + + + + ); + }; + + const { container } = reactRender( + + + + + + + ); + + // Let Puck finish its initial data resolution before changing a child slot. + await act(async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + fireEvent.click( + within(container).getByRole("button", { name: "Change headshot mapping" }) + ); + await waitFor(() => { + expect(within(container).getByAltText("Jane")).toBeDefined(); + }); + fireEvent.click( + within(container).getByRole("button", { + name: "Interactive mode", + }) + ); + await waitFor(() => { + expect( + within(container) + .getByRole("link", { name: "Jane" }) + .getAttribute("href") + ).toBe("/team/jane"); + }); + + fireEvent.click( + within(container).getByRole("button", { name: "Change headshot mapping" }) + ); + await waitFor(() => { + expect( + within(container).queryByRole("region", { + name: "Photo Gallery Section", + }) + ).toBeNull(); + }); + fireEvent.click( + within(container).getByRole("button", { name: "Change headshot mapping" }) + ); + await waitFor(() => { + expect( + within(container).getByRole("link", { name: "Jane" }) + ).toBeDefined(); + }); + }); + + it.each([ + { constantValueEnabled: false, initialIsMappedContentEmpty: false }, + { constantValueEnabled: false, initialIsMappedContentEmpty: true }, + { constantValueEnabled: true, initialIsMappedContentEmpty: false }, + { constantValueEnabled: true, initialIsMappedContentEmpty: true }, + ])( + "when an empty gallery has manual mode $constantValueEnabled and saved empty state $initialIsMappedContentEmpty then visibility follows its current source", + async ({ + constantValueEnabled, + initialIsMappedContentEmpty, + }): Promise => { + const { container } = reactRender( + + + + ); + + await waitFor(() => { + expect( + within(container).queryByRole("region", { + name: "Photo Gallery Section", + }) !== null + ).toBe(constantValueEnabled); + }); + } + ); + it.each([ + { version: 37, constantValueEnabled: true }, + { version: 37, constantValueEnabled: false }, + { version: 82, constantValueEnabled: true }, + { version: 82, constantValueEnabled: false }, + ])( + "when a version $version gallery has manual mode $constantValueEnabled then migration and reload keep its image and link", + async ({ version, constantValueEnabled }): Promise => { + const streamDocument: StreamDocument = { + locale: "en", + photoGallery: [ + { + image: { + url: "https://example.com/mapped.jpg", + width: 200, + height: 100, + alternateText: "Mapped image", + }, + clickthroughUrl: "/mapped", + }, + ], + }; + const images = { + field: "photoGallery", + constantValueEnabled, + constantValue: [ + { + assetImage: { + url: "https://example.com/manual.jpg", + width: 200, + height: 100, + alternateText: "Manual image", + }, + clickthroughUrl: "/manual", + }, + ], + }; + const migratedData = migrate( + { + root: { props: { version } }, + content: [ + { + type: "PhotoGallerySection", + props: + version === 37 + ? { + id: "old-gallery", + data: { images }, + styles: { + variant: "gallery", + image: { width: 200, aspectRatio: 2 }, + }, + liveVisibility: true, + } + : { + ...PhotoGallerySection.defaultProps, + id: "old-gallery", + styles: { variant: "gallery", showSectionHeading: false }, + slots: { + HeadingSlot: [], + PhotoGalleryWrapper: [ + { + type: "PhotoGalleryWrapper", + props: { + ...PhotoGalleryWrapper.defaultProps, + id: "old-images", + data: { images }, + }, + }, + ], + }, + }, + }, + ], + }, + migrationRegistry, + puckConfig, + streamDocument + ); + const savedData = JSON.parse(JSON.stringify(migratedData)); + const reloadedData = migrate( + savedData, + migrationRegistry, + puckConfig, + streamDocument + ); + expect(reloadedData).toEqual(savedData); + expect(reloadedData.root.props).toMatchObject({ + version: migrationRegistry.length, + }); + const resolvedData = await resolveAllData(reloadedData, puckConfig, { + streamDocument, + }); + const { container } = reactRender( + + + + ); + const link = await within(container).findByRole("link", { + name: constantValueEnabled ? "Manual image" : "Mapped image", + }); + expect(link.getAttribute("href")).toBe( + constantValueEnabled ? "/manual" : "/mapped" + ); + expect(container.querySelectorAll("img")).toHaveLength(1); + expect(link.querySelector("img")?.getAttribute("src")).toContain( + constantValueEnabled ? "manual.jpg" : "mapped.jpg" + ); + } + ); }); diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.tsx b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.tsx index 786f395032..96a2c209d1 100644 --- a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.tsx +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGallerySection.tsx @@ -10,31 +10,17 @@ import { PageSection } from "../../atoms/pageSection.tsx"; import { VisibilityWrapper } from "../../atoms/visibilityWrapper.tsx"; import { msg } from "../../../utils/i18n/platform.ts"; import { HeadingTextProps } from "../../contentBlocks/HeadingText.tsx"; -import { AssetImageType } from "../../../types/images.ts"; import { PhotoGalleryWrapperProps } from "./PhotoGalleryWrapper.tsx"; -import { getRandomPlaceholderImageObject } from "../../../utils/imagePlaceholders.ts"; +import { photoGallerySource } from "./photoGallerySource.ts"; import { ComponentErrorBoundary } from "../../../internal/components/ComponentErrorBoundary.tsx"; -import { resolveComponentData } from "../../../utils/resolveComponentData.tsx"; import { isMappedEntityFieldSelected, MappedEntityFieldConditionalRender, withMappedEntityFieldConditionalRender, } from "../entityFieldSectionUtils.ts"; -import { - getPhotoGalleryImageData, - PhotoGalleryImageValue, -} from "./photoGalleryUtils.ts"; +import { getPhotoGalleryImageData } from "./photoGalleryUtils.ts"; import { YextComponentConfig, YextFields } from "../../../fields/fields.ts"; - -// Generate 3 random placeholder images for the gallery -export const PLACEHOLDER: AssetImageType = { - ...getRandomPlaceholderImageObject({ width: 1000, height: 570 }), - width: 1000, - height: 570, - assetImage: { - name: "Placeholder", - }, -}; +import { useMappedEntitySectionEmptyState } from "../useMappedEntitySectionEmptyState.ts"; export interface PhotoGalleryStyles { /** @@ -128,11 +114,28 @@ const photoGallerySectionFields: YextFields = { const PhotoGallerySectionComponent: PuckComponent = ({ styles, slots, + conditionalRender, + puck, }) => { const { t } = useTranslation(); + const { setWrapperRef, isMappedContentEmpty } = + useMappedEntitySectionEmptyState({ + enabled: true, + initialIsMappedContentEmpty: conditionalRender?.isMappedContentEmpty, + }); + + // Keep the gallery mounted so field changes can update the section's visibility. + if (isMappedContentEmpty && !puck.isEditing) { + return ( + + ); + } return ( { type: "PhotoGalleryWrapper", props: { - data: { - images: { - field: "", - constantValue: [ - { assetImage: PLACEHOLDER }, - { assetImage: PLACEHOLDER }, - { assetImage: PLACEHOLDER }, - ], - constantValueEnabled: true, - }, - }, + data: { images: photoGallerySource.defaultValue }, styles: { image: { aspectRatio: 1.78, @@ -229,20 +222,21 @@ export const PhotoGallerySection: YextComponentConfig | undefined; const streamDocument = params.metadata.streamDocument; const locale = streamDocument?.locale ?? "en"; - const resolvedImages = photoGalleryWrapperProps?.data?.images - ? (resolveComponentData( - photoGalleryWrapperProps.data.images as any, - locale, - streamDocument - ) as unknown as PhotoGalleryImageValue[] | undefined) - : undefined; + const resolvedItems = photoGalleryWrapperProps?.data?.images + ? photoGallerySource.resolveItems( + photoGalleryWrapperProps.data.images, + { ...streamDocument, locale } + ) + : []; const { hasRenderableImages } = getPhotoGalleryImageData({ - resolvedImages, + resolvedItems, locale, streamDocument, aspectRatio: photoGalleryWrapperProps?.styles?.image?.aspectRatio, width: photoGalleryWrapperProps?.styles?.image?.width, isEditing: false, + hasExplicitLinkMapping: + !!photoGalleryWrapperProps?.data?.images?.mappings?.link?.field, }); return withMappedEntityFieldConditionalRender( @@ -260,12 +254,7 @@ export const PhotoGallerySection: YextComponentConfig liveVisibility={props.liveVisibility} isEditing={props.puck.isEditing} > - {props.conditionalRender?.isMappedContentEmpty && - !props.puck.isEditing ? ( - <> - ) : ( - - )} + ), diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGalleryWrapper.test.tsx b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGalleryWrapper.test.tsx new file mode 100644 index 0000000000..26d8612ea2 --- /dev/null +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGalleryWrapper.test.tsx @@ -0,0 +1,817 @@ +import React from "react"; +import { + act, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useGetPuck, type Config } from "@puckeditor/core"; +import { InternalLayoutEditor } from "../../../internal/components/InternalLayoutEditor.tsx"; +import { generateTemplateMetadata } from "../../../internal/types/templateMetadata.ts"; +import { type LayoutSaveState } from "../../../internal/types/saveState.ts"; +import { toPuckFields } from "../../../fields/fields.ts"; +import { page } from "@vitest/browser/context"; +import { axe, viewports } from "../../testing/componentTests.setup.ts"; +import { PhotoGalleryWrapper } from "./PhotoGalleryWrapper.tsx"; +import { photoGallerySource } from "./photoGallerySource.ts"; +import { VisualEditorProvider } from "../../../utils/VisualEditorProvider.tsx"; + +describe("PhotoGalleryWrapper", () => { + it.each([ + { + name: "gallery", + parentData: { variant: "gallery" as const }, + isEditing: false, + copies: 1, + }, + { + name: "desktop and mobile carousels", + parentData: { variant: "carousel" as const }, + isEditing: false, + copies: 2, + }, + { + name: "editor", + parentData: { variant: "gallery" as const }, + isEditing: true, + copies: 1, + }, + ])( + "when brands have optional links then the $name shows each brand", + ({ parentData, isEditing, copies }) => { + const { container } = render( + +
, + }} + /> + + ); + + for (const [name, href] of [ + ["Varilux", "https://example.com/varilux"], + ["Crizal", "https://example.com/crizal"], + ["Essilor", null], + ] as const) { + const images = within(container).getAllByAltText(name); + expect(images).toHaveLength(copies); + for (const image of images) { + expect(image.closest("a")?.getAttribute("href") ?? null).toBe( + isEditing ? null : href + ); + } + } + expect(container.querySelectorAll("a")).toHaveLength( + isEditing ? 0 : 2 * copies + ); + } + ); + + it("when a manual image has a link then it uses the page language and entity values", () => { + const { container } = render( + +
, + }} + /> + + ); + + expect( + within(container) + .getByRole("link", { name: "Brand Paris" }) + .getAttribute("href") + ).toBe("/paris"); + }); + + it.each([ + { + viewport: 375, + imageWidth: 1000, + parentData: { variant: "gallery" as const }, + }, + { + viewport: 800, + imageWidth: 1000, + parentData: { variant: "gallery" as const }, + }, + { + viewport: 1440, + imageWidth: 1000, + parentData: { variant: "gallery" as const }, + }, + { + viewport: 375, + imageWidth: 100, + parentData: { variant: "gallery" as const }, + }, + { + viewport: 800, + imageWidth: 100, + parentData: { variant: "gallery" as const }, + }, + { + viewport: 1440, + imageWidth: 100, + parentData: { variant: "gallery" as const }, + }, + { + viewport: 375, + imageWidth: 1000, + parentData: { variant: "carousel" as const }, + }, + { + viewport: 800, + imageWidth: 1000, + parentData: { variant: "carousel" as const }, + }, + { + viewport: 1440, + imageWidth: 1000, + parentData: { variant: "carousel" as const }, + }, + { + viewport: 375, + imageWidth: 100, + parentData: { variant: "carousel" as const }, + }, + { + viewport: 800, + imageWidth: 100, + parentData: { variant: "carousel" as const }, + }, + { + viewport: 1440, + imageWidth: 100, + parentData: { variant: "carousel" as const }, + }, + ])( + "when $parentData.variant images have links at $viewport px then their $imageWidth px size stays the same", + async ({ viewport, imageWidth, parentData }): Promise => { + await page.viewport(viewport, 900); + const { container, rerender } = render(<>); + let unlinkedSizes: { width: number; height: number }[] = []; + + for (const linkField of ["", "cta", "cta.link"]) { + rerender( + ({ + logo: { + url: "https://example.com/brand.jpg", + width: 1000, + height: 570, + alternateText: "Brand", + }, + cta: { label: "Brand", link: "/brand", linkType: "URL" }, + })), + }, + }} + > +
, + }} + /> + + ); + + await waitFor(() => { + if (parentData.variant === "carousel") { + // Wait for ResizeObserver to set the slide count before measuring. + expect( + within(container).getAllByRole("option", { selected: true }) + ).toHaveLength(viewport < 750 ? 1 : 3); + } + const sizes = within(container) + .getAllByAltText("Brand") + .map((image) => image.getBoundingClientRect()) + .filter(({ width }) => width > 0); + expect(sizes).toHaveLength(3); + sizes.forEach(({ width, height }, index) => { + expect(width).toBeLessThanOrEqual(Math.min(imageWidth, viewport)); + expect(height).toBeGreaterThan(0); + if (linkField) { + expect(width).toBeCloseTo(unlinkedSizes[index].width, 1); + expect(height).toBeCloseTo(unlinkedSizes[index].height, 1); + } + }); + if (!linkField) { + unlinkedSizes = sizes; + } + }); + } + } + ); + it.each([ + { + parentData: { variant: "gallery" as const }, + isEditing: false, + copies: 1, + }, + { + parentData: { variant: "carousel" as const }, + isEditing: false, + copies: 2, + }, + { parentData: { variant: "gallery" as const }, isEditing: true, copies: 1 }, + { + parentData: { variant: "carousel" as const }, + isEditing: true, + copies: 2, + }, + ])( + "when team CTAs use different link types and edit mode is $isEditing then $parentData keeps the images and link actions", + ({ parentData, isEditing, copies }): void => { + const { container } = render( + +
, + }} + /> + + ); + expect(container.querySelectorAll("img")).toHaveLength(4 * copies); + expect(container.querySelectorAll("a")).toHaveLength( + isEditing ? 0 : 3 * copies + ); + if (!isEditing) { + for (const image of within(container).getAllByAltText("Jane")) { + // The shared CTA masks email addresses in the HTML link. + expect(atob(image.closest("a")!.getAttribute("href")!)).toBe( + "mailto:jane@example.com" + ); + } + for (const link of container.querySelectorAll( + 'a[aria-label="Call John"]' + )) { + expect(link.getAttribute("href")).toBe("tel:+12125550100"); + } + expect( + container.querySelectorAll('a[aria-label="Call John"]') + ).toHaveLength(copies); + for (const image of within(container).getAllByAltText("Jill")) { + expect(image.closest("a")!.getAttribute("href")).toBe("/Team/Jill"); + } + } + for (const image of within(container).getAllByAltText("Jack")) { + expect(image.closest("a")).toBeNull(); + } + } + ); + + it("when a carousel source changes from three images to one then the last image stays visible", async (): Promise => { + await page.viewport(1440, 900); + const props = { + id: "changing-gallery", + data: { + images: { + ...photoGallerySource.defaultValue, + field: "photoGallery", + constantValueEnabled: false, + mappings: { + link: { + field: "", + constantValueEnabled: false, + constantValue: undefined, + }, + image: { + field: "$item", + constantValueEnabled: false, + constantValue: undefined, + }, + }, + }, + }, + styles: { image: { width: 100, aspectRatio: 1 }, carouselImageCount: 3 }, + parentData: { variant: "carousel" as const }, + puck: { + isEditing: false, + dragRef: null, + metadata: {}, + renderDropZone: (): React.ReactElement =>
, + }, + }; + const { container, rerender } = render( + + + + ); + await waitFor(() => + expect( + container.querySelectorAll(".carousel__slide--visible") + ).toHaveLength(6) + ); + rerender( + + + + ); + await waitFor(() => { + expect( + container.querySelectorAll(".carousel__slide--visible") + ).toHaveLength(2); + const image = within(container).getAllByAltText("One")[0]; + expect(image.getBoundingClientRect().width).toBeGreaterThan(0); + expect( + image.closest(".carousel__slide")!.getAttribute("aria-selected") + ).toBe("true"); + expect( + container.querySelectorAll( + ".carousel__back-button:enabled, .carousel__next-button:enabled" + ) + ).toHaveLength(0); + }); + }); + + it.each([viewports.desktop, viewports.tablet, viewports.mobile])( + "$name item source with optional image links", + async ({ name, width, height }): Promise => { + await page.viewport(width, height); + const { container } = render( + +
, + }} + /> + + ); + + await waitFor( + () => { + const images = Array.from(container.querySelectorAll("img")); + expect(images).toHaveLength(3); + for (const image of images) { + expect(image.complete).toBe(true); + expect(image.naturalWidth).toBeGreaterThan(0); + } + }, + { timeout: 5000 } + ); + expect( + within(container) + .getByRole("link", { name: "Varilux" }) + .getAttribute("href") + ).toBe("/varilux"); + expect( + within(container) + .getByRole("link", { name: "Crizal" }) + .getAttribute("href") + ).toBe("/crizal"); + expect(within(container).getByAltText("Essilor").closest("a")).toBeNull(); + await expect( + `PhotoGalleryWrapper/[${name}] item source with optional image links` + ).toMatchScreenshot(); + expect(await axe(container)).toHaveNoViolations(); + } + ); + it.each([0, 1])( + "when a gallery constant value is saved in sidebar %i then the open array item keeps focus", + async (sidebarIndex: number): Promise => { + const onChange = vi.fn(); + const SelectGallery = (): React.JSX.Element => { + const getPuck = useGetPuck(); + return ( + + ); + }; + const puckConfig: Config = { + components: { + PhotoGalleryWrapper: { + ...PhotoGalleryWrapper, + fields: toPuckFields(PhotoGalleryWrapper.fields!), + render: (props) => ( + <> + + {PhotoGalleryWrapper.render( + props as Parameters[0] + )} + + ), + }, + }, + }; + const Editor = (): React.JSX.Element => { + const [layoutSaveState, setLayoutSaveState] = + React.useState(); + return ( + { + const history = JSON.parse(payload.history); + onChange(history.data); + setLayoutSaveState({ history, hash: payload.hash }); + }} + publishLayout={vi.fn()} + sendLayoutForApproval={vi.fn()} + sendDevSaveStateData={vi.fn()} + buildVisualConfigLocalStorageKey={() => "gallery-focus-test"} + localDev={false} + /> + ); + }; + const { container } = render( + + + + ); + await waitFor( + () => { + const body = container.querySelector("iframe")?.contentDocument?.body; + expect(body).toBeDefined(); + fireEvent.click(within(body!).getByText("Select gallery")); + }, + { timeout: 5000 } + ); + fireEvent.click( + (await within(container).findAllByText("Item 1"))[sidebarIndex] + ); + const input = within(container).getByRole("textbox", { + name: "", + hidden: true, + }); + input.focus(); + fireEvent.change(input, { target: { value: "/brand" } }); + fireEvent.blur(input); + await waitFor(() => { + expect( + onChange.mock.lastCall?.[0].content[0].props.data.images + .constantValue[0].link.constantValue.en + ).toBe("/brand"); + }); + await act(async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await waitFor(() => { + expect( + within(container).getByRole("textbox", { name: "", hidden: true }) + ).toBe(input); + expect(within(container).getByDisplayValue("/brand")).toBe(input); + }); + } + ); +}); diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGalleryWrapper.tsx b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGalleryWrapper.tsx index 6c07d1c24c..2a13f5e941 100644 --- a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGalleryWrapper.tsx +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/PhotoGalleryWrapper.tsx @@ -1,24 +1,18 @@ -import { ComplexImageType, ImageType } from "@yext/pages-components"; import { ImageStylingFields, ImageStylingProps, } from "../../contentBlocks/image/styling.ts"; import { EntityField } from "../../../editor/EntityField.tsx"; import { Image } from "../../atoms/image.tsx"; +import { CTA } from "../../atoms/cta.tsx"; import { themeManagerCn } from "../../../utils/cn.ts"; import { useBackground } from "../../../hooks/useBackground.tsx"; import { useDocument } from "../../../hooks/useDocument.tsx"; -import { YextEntityField } from "../../../editor/YextEntityFieldSelector.tsx"; import { msg, pt } from "../../../utils/i18n/platform.ts"; -import { resolveComponentData } from "../../../utils/resolveComponentData.tsx"; import { getThemeColorCssValue } from "../../../utils/colors.ts"; -import { - AssetImageType, - ImageFillType, - TranslatableAssetImage, -} from "../../../types/images.ts"; +import { ImageFillType } from "../../../types/images.ts"; import { PuckComponent } from "@puckeditor/core"; -import { PLACEHOLDER } from "./PhotoGallerySection.tsx"; +import { photoGallerySource } from "./photoGallerySource.ts"; import React, { cloneElement } from "react"; import { useTranslation } from "react-i18next"; import { ThemeColor } from "../../../utils/themeConfigOptions.ts"; @@ -36,7 +30,10 @@ import { ImagePlus } from "lucide-react"; import { Button } from "../../../internal/puck/ui/button.tsx"; import { updateFields } from "../HeroSection.tsx"; import { isMappedEntityFieldSelected } from "../entityFieldSectionUtils.ts"; -import { renderMappedEntityFieldEmptyState } from "../EntityFieldSectionEmptyState.tsx"; +import { + EntityFieldSectionEmptyStateBox, + renderMappedEntityFieldEmptyState, +} from "../EntityFieldSectionEmptyState.tsx"; import { getPhotoGalleryImageData, ResolvedGalleryImage, @@ -53,11 +50,7 @@ export interface PhotoGalleryWrapperProps { * The source of the image data, which can be linked to a Yext field or provided as a constant. * @defaultValue A list of 3 placeholder images. */ - images: YextEntityField< - | ImageType[] - | ComplexImageType[] - | { assetImage: AssetImageType | TranslatableAssetImage }[] - >; + images: typeof photoGallerySource.value; }; styles: { /** Styling options for the gallery images, such as aspect ratio. */ @@ -91,14 +84,7 @@ const photoGalleryWrapperFields: YextFields = { type: "object", label: msg("fields.data", "Data"), objectFields: { - images: { - type: "entityField", - label: msg("fields.images", "Images"), - filter: { - types: ["type.image"], - includeListsOnly: true, - }, - }, + images: photoGallerySource.field, }, }, styles: { @@ -245,16 +231,33 @@ const DesktopImageItem = ({ aspectRatio={imageData.aspectRatio} width={imageData.width} className={themeManagerCn( - "rounded-image-borderRadius", - constrainToParent && "w-full h-auto object-contain max-w-full" + "rounded-image-borderRadius max-w-full", + constrainToParent && "w-full h-auto object-contain" )} sizes={sizes} imageFillType={imageFillType} /> ); + const linkedImage = + imageData.href && !isEditing ? ( + + ) : ( + imageElement + ); + if (!constrainToParent) { - return imageElement; + return linkedImage; } return ( @@ -262,7 +265,7 @@ const DesktopImageItem = ({ className="w-full max-w-full" style={{ maxWidth: `${imageData.width}px` }} > - {imageElement} + {linkedImage}
); }; @@ -280,6 +283,16 @@ const MobileImageItem = ({ return ; } + const imageElement = ( + + ); + return (
- + {imageData.href && !isEditing ? ( + + ) : ( + imageElement + )}
); }; @@ -485,17 +506,7 @@ export const PhotoGalleryWrapper: YextComponentConfig label: msg("components.gallery", "Gallery"), fields: photoGalleryWrapperFields, defaultProps: { - data: { - images: { - field: "", - constantValue: [ - { assetImage: PLACEHOLDER }, - { assetImage: PLACEHOLDER }, - { assetImage: PLACEHOLDER }, - ], - constantValueEnabled: true, - }, - }, + data: { images: photoGallerySource.defaultValue }, styles: { image: { aspectRatio: 1.78, @@ -535,21 +546,22 @@ const PhotoGalleryWrapperComponent: PuckComponent = ({ styles.carouselImageCount ); - const resolvedImages = resolveComponentData( - data.images, + const resolvedItems = photoGallerySource.resolveItems(data.images, { + ...streamDocument, locale, - streamDocument - ); + }); const { galleryImages, hasRenderableImages } = getPhotoGalleryImageData({ - resolvedImages, + resolvedItems, locale, streamDocument, aspectRatio: styles.image?.aspectRatio, width: styles.image?.width, isEditing: Boolean(puck?.isEditing), + hasExplicitLinkMapping: !!data.images.mappings?.link?.field, }); - const hasAnyImages = isMappedEntityFieldSelected(data.images) + const isMapped = isMappedEntityFieldSelected(data.images); + const hasAnyImages = isMapped ? hasRenderableImages : galleryImages.length > 0; const imageWidth = styles.image?.width || 1000; @@ -613,10 +625,10 @@ const PhotoGalleryWrapperComponent: PuckComponent = ({ ) ) : puck?.isEditing ? ( - renderMappedEntityFieldEmptyState(true) - ) : ( + + ) : isMapped ? ( renderMappedEntityFieldEmptyState(false) - )} + ) : null}
); }; diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGallerySource.test.ts b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGallerySource.test.ts new file mode 100644 index 0000000000..beb1918bb2 --- /dev/null +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGallerySource.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { photoGallerySource } from "./photoGallerySource.ts"; +import { getPhotoGalleryImageData } from "./photoGalleryUtils.ts"; +import { migrate } from "../../../utils/migrate.ts"; +import { photoGalleryItemSource } from "../../migrations/0083_photo_gallery_item_source.ts"; +import { type StreamDocument } from "../../../utils/types/StreamDocument.ts"; + +describe("photoGallerySource", () => { + it.each([ + { + name: "an image list", + field: "photoGallery", + imageField: "$item", + linkField: "", + streamDocument: { + locale: "en", + photoGallery: [ + { url: "https://example.com/one.jpg" }, + { + image: { url: "https://example.com/two.jpg" }, + clickthroughUrl: "/two", + }, + ], + }, + expected: [ + { url: "https://example.com/one.jpg", href: undefined }, + { url: "https://example.com/two.jpg", href: "/two" }, + ], + }, + { + name: "brands with CTA objects", + field: "c_brands", + imageField: "logo", + linkField: "cta", + streamDocument: { + locale: "en", + c_brands: [ + { + logo: { url: "https://example.com/varilux.jpg" }, + cta: { label: "Varilux", link: "https://example.com/varilux" }, + }, + { + logo: { url: "https://example.com/crizal.jpg" }, + cta: { label: "Crizal", link: "https://example.com/crizal" }, + }, + { logo: { url: "https://example.com/essilor.jpg" } }, + { cta: { label: "No image", link: "/missing" } }, + ], + }, + expected: [ + { + url: "https://example.com/varilux.jpg", + href: "https://example.com/varilux", + }, + { + url: "https://example.com/crizal.jpg", + href: "https://example.com/crizal", + }, + { url: "https://example.com/essilor.jpg", href: undefined }, + ], + }, + { + name: "a different entity with a nested CTA link", + field: "c_brands", + imageField: "logo", + linkField: "cta.link", + streamDocument: { + locale: "en", + c_brands: [ + { + logo: { url: "https://example.com/eyezen.jpg" }, + cta: { label: "Eyezen", link: "/eyezen" }, + }, + ], + }, + expected: [{ url: "https://example.com/eyezen.jpg", href: "/eyezen" }], + }, + { + name: "an entity with no brands", + field: "c_brands", + imageField: "logo", + linkField: "cta", + streamDocument: { locale: "en" }, + expected: [], + }, + ])( + "when the source is $name then each image keeps its own link", + ({ field, imageField, linkField, streamDocument, expected }) => { + const resolvedItems = photoGallerySource.resolveItems( + { + field, + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { + field: imageField, + constantValueEnabled: false, + constantValue: undefined, + }, + link: { + field: linkField, + constantValueEnabled: false, + constantValue: undefined, + }, + }, + }, + streamDocument + ); + const { galleryImages } = getPhotoGalleryImageData({ + resolvedItems, + locale: "en", + streamDocument, + isEditing: false, + hasExplicitLinkMapping: !!linkField, + }); + + expect( + galleryImages.map(({ image, href }) => ({ url: image.url, href })) + ).toEqual(expected); + } + ); + + it.each([ + { + locale: "fr", + expectedUrl: "https://example.com/fr.jpg", + expectedAlt: "Marque Paris", + }, + { + locale: "de", + expectedUrl: "https://example.com/default.jpg", + expectedAlt: "Brand Paris", + }, + ])( + "when saved images are migrated then $locale images and links remain visible", + ({ locale, expectedUrl, expectedAlt }) => { + const migratedData = migrate( + { + root: { props: { version: 0 } }, + content: [ + { + type: "PhotoGalleryWrapper", + props: { + id: "gallery", + data: { + images: { + field: "", + constantValueEnabled: true, + constantValue: [ + { + assetImage: { + hasLocalizedValue: "true", + defaultValue: { + url: "https://example.com/default.jpg", + alternateText: { defaultValue: "Brand [[name]]" }, + }, + fr: { + url: "https://example.com/fr.jpg", + alternateText: { defaultValue: "Marque [[name]]" }, + }, + }, + clickthroughUrl: "/brand", + }, + ], + }, + }, + }, + }, + ], + }, + [photoGalleryItemSource], + { components: {} }, + { locale } + ); + const streamDocument: StreamDocument = { locale, name: "Paris" }; + const resolvedItems = photoGallerySource.resolveItems( + migratedData.content[0].props.data.images, + streamDocument + ); + const { galleryImages } = getPhotoGalleryImageData({ + resolvedItems, + locale, + streamDocument, + isEditing: false, + }); + + expect(galleryImages).toHaveLength(1); + expect(galleryImages[0]).toMatchObject({ + image: { url: expectedUrl, alternateText: expectedAlt }, + href: "/brand", + }); + } + ); + + it("when a Team list maps headshots and CTAs then each photo keeps its link", () => { + const streamDocument: StreamDocument = { + locale: "en", + c_team: { + people: [ + { + headshot: { url: "https://example.com/alex.jpg" }, + cta: { label: "Alex", link: "/alex", linkType: "URL" }, + }, + { headshot: { url: "https://example.com/sam.jpg" } }, + ], + }, + }; + const resolvedItems = photoGallerySource.resolveItems( + { + field: "c_team.people", + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { + field: "headshot", + constantValueEnabled: false, + constantValue: undefined, + }, + link: { + field: "cta", + constantValueEnabled: false, + constantValue: undefined, + }, + }, + }, + streamDocument + ); + + expect( + getPhotoGalleryImageData({ + resolvedItems, + locale: "en", + streamDocument, + isEditing: false, + hasExplicitLinkMapping: true, + }).galleryImages.map(({ image, href }) => ({ url: image.url, href })) + ).toEqual([ + { url: "https://example.com/alex.jpg", href: "/alex" }, + { url: "https://example.com/sam.jpg", href: undefined }, + ]); + }); +}); diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGallerySource.ts b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGallerySource.ts new file mode 100644 index 0000000000..569c8593e2 --- /dev/null +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGallerySource.ts @@ -0,0 +1,46 @@ +import { type AssetImageType } from "../../../types/images.ts"; +import { type YextEntityField } from "../../../editor/YextEntityFieldSelector.tsx"; +import { createItemSource } from "../../../utils/itemSource/index.ts"; +import { getRandomPlaceholderImageObject } from "../../../utils/imagePlaceholders.ts"; +import { msg } from "../../../utils/i18n/platform.ts"; +import { type PhotoGalleryItem } from "./photoGalleryUtils.ts"; + +const PLACEHOLDER: AssetImageType = { + ...getRandomPlaceholderImageObject({ width: 1000, height: 570 }), + width: 1000, + height: 570, + assetImage: { name: "Placeholder" }, +}; + +export const photoGallerySource = createItemSource<{ + image: YextEntityField; + link: YextEntityField; +}>({ + label: msg("fields.images", "Images"), + mappingFields: { + image: { + type: "entityField", + label: msg("fields.image", "Image"), + filter: { types: ["type.image"] }, + }, + link: { + type: "entityField", + label: msg("fields.link", "Link"), + filter: { types: ["type.cta"] }, + constantValueFilter: { types: ["type.string"] }, + disableConstantValueToggle: true, + }, + }, + defaultValues: Array.from({ length: 3 }, () => ({ + image: { + field: "", + constantValue: PLACEHOLDER, + constantValueEnabled: true, + }, + link: { + field: "", + constantValue: { defaultValue: "" }, + constantValueEnabled: true, + }, + })), +}); diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.test.ts b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.test.ts index 1a7c6e3a7e..8ac06571cd 100644 --- a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.test.ts +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "vitest"; -import { getPhotoGalleryImageData } from "./photoGalleryUtils.ts"; +import { + getPhotoGalleryImageData, + type PhotoGalleryItem, +} from "./photoGalleryUtils.ts"; describe("getPhotoGalleryImageData", () => { it("returns no renderable images for an empty list", () => { expect( getPhotoGalleryImageData({ - resolvedImages: [], + resolvedItems: [], locale: "en", isEditing: false, }) @@ -17,12 +20,14 @@ describe("getPhotoGalleryImageData", () => { it("treats blank image urls as empty", () => { const result = getPhotoGalleryImageData({ - resolvedImages: [ + resolvedItems: [ { image: { - url: " ", - width: 100, - height: 100, + image: { + url: " ", + width: 100, + height: 100, + }, }, }, ], @@ -36,19 +41,23 @@ describe("getPhotoGalleryImageData", () => { it("keeps valid images while filtering invalid ones on live", () => { const result = getPhotoGalleryImageData({ - resolvedImages: [ + resolvedItems: [ { image: { - url: "", - width: 100, - height: 100, + image: { + url: "", + width: 100, + height: 100, + }, }, }, { image: { - url: "https://example.com/gallery.jpg", - width: 200, - height: 120, + image: { + url: "https://example.com/gallery.jpg", + width: 200, + height: 120, + }, }, }, ], @@ -65,12 +74,14 @@ describe("getPhotoGalleryImageData", () => { it("keeps empty image entries in editing mode", () => { const result = getPhotoGalleryImageData({ - resolvedImages: [ + resolvedItems: [ { image: { - url: "", - width: 100, - height: 100, + image: { + url: "", + width: 100, + height: 100, + }, }, }, ], @@ -85,21 +96,23 @@ describe("getPhotoGalleryImageData", () => { it("resolves localized asset images for the requested locale", () => { const result = getPhotoGalleryImageData({ - resolvedImages: [ + resolvedItems: [ { - assetImage: { - en: { - url: "https://example.com/en.jpg", - width: 100, - height: 100, - }, - fr: { - url: "https://example.com/fr.jpg", - width: 120, - height: 120, - }, - hasLocalizedValue: "true", - } as any, + image: { + assetImage: { + en: { + url: "https://example.com/en.jpg", + width: 100, + height: 100, + }, + fr: { + url: "https://example.com/fr.jpg", + width: 120, + height: 120, + }, + hasLocalizedValue: "true", + } as any, + }, }, ], locale: "fr", @@ -114,4 +127,215 @@ describe("getPhotoGalleryImageData", () => { expect(result.galleryImages[0]?.image.height).toBe(120); expect(result.hasRenderableImages).toBe(true); }); + + it("uses clickthroughUrl unless an explicit link mapping is selected", () => { + const resolvedItems = [ + { + image: { + image: { + url: "https://example.com/gallery.jpg", + width: 200, + height: 120, + }, + clickthroughUrl: "/gallery", + }, + }, + ]; + + expect( + getPhotoGalleryImageData({ + resolvedItems, + locale: "en", + isEditing: false, + }).galleryImages[0]?.href + ).toBe("/gallery"); + expect( + getPhotoGalleryImageData({ + resolvedItems, + locale: "en", + isEditing: false, + hasExplicitLinkMapping: true, + }).galleryImages[0]?.href + ).toBeUndefined(); + expect( + getPhotoGalleryImageData({ + resolvedItems: [{ ...resolvedItems[0], link: "/mapped" }], + locale: "en", + isEditing: false, + hasExplicitLinkMapping: true, + }).galleryImages[0]?.href + ).toBe("/mapped"); + }); + + it("keeps images without valid links visible", () => { + const result = getPhotoGalleryImageData({ + resolvedItems: [ + { + image: { + url: "https://example.com/gallery.jpg", + width: 200, + height: 120, + }, + link: { defaultValue: "javascript:alert(1)" }, + }, + { + image: { + url: "https://example.com/second.jpg", + width: 200, + height: 120, + }, + link: { defaultValue: "https://example.com/page" }, + }, + ], + locale: "en", + isEditing: false, + }); + + expect(result.galleryImages).toHaveLength(2); + expect(result.galleryImages.map((item) => item.href)).toEqual([ + undefined, + "https://example.com/page", + ]); + }); + + it.each<{ + link: PhotoGalleryItem["link"]; + expectedHref: string | undefined; + expectedType: string; + }>([ + { + link: { label: "", link: "team@example.com", linkType: "EMAIL" }, + expectedHref: "team@example.com", + expectedType: "EMAIL", + }, + { + link: { label: "", link: "+1 (212) 555-0100", linkType: "PHONE" }, + expectedHref: "+1 (212) 555-0100", + expectedType: "PHONE", + }, + { + link: { label: "", link: "mailto:team@example.com", linkType: "Email" }, + expectedHref: "mailto:team@example.com", + expectedType: "Email", + }, + { + link: { label: "", link: "tel:+12125550100", linkType: "Phone" }, + expectedHref: "tel:+12125550100", + expectedType: "Phone", + }, + { + link: { + label: "", + link: { + defaultValue: "/English", + fr: "/Francais", + hasLocalizedValue: "true", + }, + linkType: "URL", + }, + expectedHref: "/Francais", + expectedType: "URL", + }, + { + link: "mailto:team@example.com", + expectedHref: "mailto:team@example.com", + expectedType: "URL", + }, + { + link: "tel:+12125550100", + expectedHref: "tel:+12125550100", + expectedType: "URL", + }, + { + link: { label: "", link: " ", linkType: "EMAIL" }, + expectedHref: undefined, + expectedType: "EMAIL", + }, + { + link: { label: "", link: "javascript:alert(1)", linkType: "URL" }, + expectedHref: undefined, + expectedType: "URL", + }, + ])( + "when the link is $link then it keeps its value and type", + ({ link, expectedHref, expectedType }): void => { + const result = getPhotoGalleryImageData({ + resolvedItems: [ + { + image: { + url: "https://example.com/team.jpg", + width: 100, + height: 100, + }, + link, + }, + ], + locale: "fr", + isEditing: false, + }); + expect(result.galleryImages).toHaveLength(1); + expect(result.galleryImages[0]).toMatchObject({ + href: expectedHref, + linkType: expectedType, + }); + } + ); + + it.each(["", "Headshot"])( + "when image alternative text is '%s' then the CTA label supplies a missing link name", + (alternateText): void => { + const result = getPhotoGalleryImageData({ + resolvedItems: [ + { + image: { + url: "https://example.com/team.jpg", + width: 100, + height: 100, + alternateText, + }, + link: { + link: "/team", + label: { + defaultValue: "Team", + fr: "Equipe [[name]]", + hasLocalizedValue: "true", + }, + }, + }, + ], + locale: "fr", + streamDocument: { name: "Paris" }, + isEditing: false, + }); + expect(result.galleryImages[0]?.ariaLabel).toBe( + alternateText ? undefined : "Equipe Paris" + ); + } + ); + + it.each(["EMAIL", "Email", "PHONE", "Phone"] as const)( + "when a %s CTA starts with javascript then its image has no link", + (linkType): void => { + for (const link of ["javascript:alert(1)", " JaVaScRiPt:alert(1) "]) { + const result = getPhotoGalleryImageData({ + resolvedItems: [ + { + image: { + url: "https://example.com/team.jpg", + width: 100, + height: 100, + }, + link: { label: "Team", link, linkType }, + }, + ], + locale: "en", + isEditing: false, + }); + + expect(result.hasRenderableImages).toBe(true); + expect(result.galleryImages).toHaveLength(1); + expect(result.galleryImages[0]?.href).toBeUndefined(); + } + } + ); }); diff --git a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.ts b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.ts index d7b0825e1b..5258908bdd 100644 --- a/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.ts +++ b/packages/visual-editor/src/components/pageSections/PhotoGallerySection/photoGalleryUtils.ts @@ -1,94 +1,133 @@ -import { ComplexImageType, ImageType } from "@yext/pages-components"; +import { + ComplexImageType, + ImageType, + type LinkType, +} from "@yext/pages-components"; import { AssetImageType, - isLocalizedAssetImage, resolveLocalizedAssetImage, TranslatableAssetImage, } from "../../../types/images.ts"; import { resolveComponentData } from "../../../utils/resolveComponentData.tsx"; +import { + type TranslatableCTA, + type TranslatableString, +} from "../../../types/types.ts"; +import { type StreamDocument } from "../../../utils/types/StreamDocument.ts"; +import { isNonNormalizableLinkType } from "../../../utils/normalizeLink.ts"; -export type PhotoGalleryImageValue = +type PhotoGalleryImageValue = | ImageType | ComplexImageType + | TranslatableAssetImage | { assetImage: AssetImageType | TranslatableAssetImage }; +export type PhotoGalleryItem = { + image?: PhotoGalleryImageValue; + link?: TranslatableString | TranslatableCTA; +}; + export type ResolvedGalleryImage = { isEmpty: boolean; originalIndex: number; image: ImageType | AssetImageType; aspectRatio?: number; width?: number; - originalImage: PhotoGalleryImageValue; + originalImage?: PhotoGalleryImageValue; + href?: string; + linkType?: LinkType; + ariaLabel?: string; }; +const LINK_REGEX_VALIDATION = + /^(https?:\/\/[^\s]+|mailto:[^\s]+|tel:[^\s]+|\/[^\s]*|#[^\s]*)$/i; + /** - * Normalizes the resolved photo gallery field value into the image shape used by - * the gallery renderers and reports whether the mapped field produced any - * renderable images. - * - * Empty images are retained only in editor mode so the wrapper can still render - * its empty state; live rendering filters them out entirely. + * 1. Gets each image and its alternative text for the selected language. + * 2. Gets the optional link from text, a CTA, or the image clickthrough URL. + * 3. Removes empty images from the live page. Keeps them in the editor. */ export const getPhotoGalleryImageData = ({ - resolvedImages, + resolvedItems, locale, streamDocument, aspectRatio, width, isEditing, + hasExplicitLinkMapping = false, }: { - resolvedImages: PhotoGalleryImageValue[] | undefined; + resolvedItems: PhotoGalleryItem[] | undefined; locale: string; - streamDocument?: Record; + streamDocument?: StreamDocument; aspectRatio?: number; width?: number; isEditing: boolean; + hasExplicitLinkMapping?: boolean; }): { galleryImages: ResolvedGalleryImage[]; hasRenderableImages: boolean; } => { const allGalleryImages = ( - Array.isArray(resolvedImages) ? resolvedImages : [] - ).map((rawImage, originalIndex) => { + Array.isArray(resolvedItems) ? resolvedItems : [] + ).map((item, originalIndex) => { + const rawImage = item.image; let image: ImageType | AssetImageType | undefined; - let altText = ""; if ( typeof rawImage === "object" && rawImage !== null && - "assetImage" in rawImage + "assetImage" in rawImage && + !("url" in rawImage) ) { - if (isLocalizedAssetImage(rawImage.assetImage)) { - image = resolveLocalizedAssetImage(rawImage.assetImage, locale); - altText = resolveComponentData( - image?.alternateText ?? "", - locale, - streamDocument - ); - } else { - image = rawImage.assetImage; - altText = resolveComponentData( - rawImage.assetImage?.alternateText ?? "", - locale, - streamDocument - ); - } + image = resolveLocalizedAssetImage(rawImage.assetImage, locale); } else if ( typeof rawImage === "object" && rawImage !== null && "image" in rawImage ) { image = rawImage.image; - altText = rawImage.image?.alternateText ?? ""; } else { - image = rawImage; - altText = rawImage?.alternateText ?? ""; + image = resolveLocalizedAssetImage(rawImage, locale); } + const altText = resolveComponentData( + image?.alternateText ?? "", + locale, + streamDocument + ); const url = image?.url; const imageHeight = image?.height || 570; const imageWidth = image?.width || 1000; const isEmpty = !url || (typeof url === "string" && url.trim() === ""); + const inputLink = + item.link ?? + (!hasExplicitLinkMapping && + rawImage && + typeof rawImage === "object" && + "clickthroughUrl" in rawImage && + typeof rawImage.clickthroughUrl === "string" + ? rawImage.clickthroughUrl + : undefined); + const cta = + inputLink && typeof inputLink === "object" && "link" in inputLink + ? (inputLink as TranslatableCTA) + : undefined; + const linkType = cta?.linkType ?? "URL"; + const resolvedLink = inputLink + ? resolveComponentData( + cta ? (cta.link ?? "") : inputLink, + locale, + streamDocument + ) + : undefined; + const href = + typeof resolvedLink === "string" && + resolvedLink.trim() && + !/^javascript:/i.test(resolvedLink.trim()) && + (isNonNormalizableLinkType(linkType) || + LINK_REGEX_VALIDATION.test(resolvedLink.trim())) + ? resolvedLink.trim() + : undefined; return { isEmpty, @@ -109,6 +148,12 @@ export const getPhotoGalleryImageData = ({ aspectRatio, width: width || 1000, originalImage: rawImage, + href, + linkType, + ariaLabel: + !altText && cta?.label + ? resolveComponentData(cta.label, locale, streamDocument) + : undefined, }; }); diff --git a/packages/visual-editor/src/components/pageSections/entityFieldSectionUtils.ts b/packages/visual-editor/src/components/pageSections/entityFieldSectionUtils.ts index a4eaf0dc05..bc107bf345 100644 --- a/packages/visual-editor/src/components/pageSections/entityFieldSectionUtils.ts +++ b/packages/visual-editor/src/components/pageSections/entityFieldSectionUtils.ts @@ -22,7 +22,10 @@ type ConditionalRenderData = { }; export const isMappedEntityFieldSelected = ( - entityField?: YextEntityField + entityField?: Pick< + YextEntityField, + "field" | "constantValueEnabled" + > ): boolean => { return ( Boolean(entityField?.field) && entityField?.constantValueEnabled === false diff --git a/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[desktop] item source with optional image links.png b/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[desktop] item source with optional image links.png new file mode 100644 index 0000000000..764f655cd2 Binary files /dev/null and b/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[desktop] item source with optional image links.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[mobile] item source with optional image links.png b/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[mobile] item source with optional image links.png new file mode 100644 index 0000000000..79636d6561 Binary files /dev/null and b/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[mobile] item source with optional image links.png differ diff --git a/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[tablet] item source with optional image links.png b/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[tablet] item source with optional image links.png new file mode 100644 index 0000000000..a6aea41d49 Binary files /dev/null and b/packages/visual-editor/src/components/testing/screenshots/PhotoGalleryWrapper/[tablet] item source with optional image links.png differ diff --git a/packages/visual-editor/src/editor/yextEntityFieldUtils.test.ts b/packages/visual-editor/src/editor/yextEntityFieldUtils.test.ts index fb9428f3f0..749648e1fc 100644 --- a/packages/visual-editor/src/editor/yextEntityFieldUtils.test.ts +++ b/packages/visual-editor/src/editor/yextEntityFieldUtils.test.ts @@ -3,8 +3,92 @@ import { getEntityFieldDisplayName, getFieldsForSelector, } from "./yextEntityFieldUtils.ts"; +import { ITEM_SOURCE_SELF_FIELD } from "../utils/itemSource/itemSourceTypes.ts"; describe("getFieldsForSelector", () => { + it("offers image lists and image-bearing object lists for direct item sources", () => { + const entityFields = { + fields: [ + { + name: "images", + definition: { + name: "images", + isList: true, + typeRegistryId: "type.image", + type: {}, + }, + }, + { + name: "gallery", + definition: { + name: "gallery", + isList: true, + typeRegistryId: "type.image", + type: {}, + }, + children: { + fields: [ + { + name: "image", + definition: { + name: "image", + typeRegistryId: "type.image", + type: {}, + }, + }, + ], + }, + }, + { + name: "products", + definition: { name: "products", isList: true, type: {} }, + children: { + fields: [ + { + name: "cover", + definition: { + name: "cover", + typeRegistryId: "type.image", + type: {}, + }, + }, + ], + }, + }, + { + name: "names", + definition: { + name: "names", + isList: true, + typeRegistryId: "type.string", + type: {}, + }, + }, + ], + }; + + expect( + getFieldsForSelector(entityFields, { + itemSourceTypes: [["type.image"]], + }).map((field) => field.name) + ).toEqual(["gallery", "images", "products"]); + expect( + getFieldsForSelector( + entityFields, + { types: ["type.image"] }, + undefined, + "images" + ).map((field) => field.name) + ).toEqual([ITEM_SOURCE_SELF_FIELD]); + expect( + getFieldsForSelector( + entityFields, + { types: ["type.cta"] }, + undefined, + "images" + ) + ).toEqual([]); + }); it("allows one descendant to satisfy multiple compatible mapping requirements", () => { const fields = getFieldsForSelector( { @@ -313,4 +397,105 @@ describe("item-source parent selection", () => { expect(fields).toEqual([]); }); + + it.each([ + { + filter: { + itemSourceTypes: [["type.image" as const], ["type.cta" as const]], + }, + expected: ["brands", "images", "links"], + }, + { + filter: { + mappedSourceTypes: [["type.image" as const], ["type.cta" as const]], + }, + expected: ["brands"], + }, + ])( + "when the source filter is $filter then available lists are $expected", + ({ filter, expected }): void => { + expect( + getFieldsForSelector( + { + fields: [ + { + name: "images", + definition: { + name: "images", + typeName: "type.image", + isList: true, + type: {}, + }, + }, + { + name: "links", + definition: { + name: "links", + typeRegistryId: "type.cta", + isList: true, + type: {}, + }, + }, + { + name: "singleImage", + definition: { + name: "singleImage", + typeName: "type.image", + type: {}, + }, + }, + { + name: "brands", + definition: { name: "brands", isList: true, type: {} }, + children: { + fields: [ + { + name: "image", + definition: { + name: "image", + typeName: "type.image", + type: {}, + }, + }, + { + name: "cta", + definition: { + name: "cta", + typeName: "type.cta", + type: {}, + }, + }, + ], + }, + }, + ], + }, + filter + ).map((field) => field.name) + ).toEqual(expected); + } + ); + + it("when a rich text mapping uses a string list then complete items are available", () => { + expect( + getFieldsForSelector( + { + fields: [ + { + name: "captions", + definition: { + name: "captions", + typeName: "type.string", + isList: true, + type: {}, + }, + }, + ], + }, + { types: ["type.rich_text_v2"] }, + undefined, + "captions" + ).map((field) => field.name) + ).toEqual(["$item"]); + }); }); diff --git a/packages/visual-editor/src/editor/yextEntityFieldUtils.ts b/packages/visual-editor/src/editor/yextEntityFieldUtils.ts index 39de8d6c17..58e6334d90 100644 --- a/packages/visual-editor/src/editor/yextEntityFieldUtils.ts +++ b/packages/visual-editor/src/editor/yextEntityFieldUtils.ts @@ -12,6 +12,7 @@ import { getListSourceRootFields, type MappedSourceFieldFilter, } from "../utils/cardSlots/mappedSource.ts"; +import { ITEM_SOURCE_SELF_FIELD } from "../utils/itemSource/itemSourceTypes.ts"; const DISPLAY_NAME_SEPARATOR = " > "; @@ -276,13 +277,33 @@ const getSubdocumentStreamFields = ( const getScopedFieldsForSelector = ( entityFields: StreamFields | null, sourceField: string, - filter: RenderEntityFieldFilter + filter: MappedSourceFieldFilter ): YextSchemaField[] => { const scopedStreamFields = getSubdocumentStreamFields( entityFields, sourceField ); - if (!scopedStreamFields) { + const sourceSchemaField = getSchemaFieldAtPath(entityFields, sourceField); + const itemFields = + sourceSchemaField?.definition.isList && filter.types?.length + ? getFilteredEntityFields( + { + fields: [ + { + ...sourceSchemaField, + name: ITEM_SOURCE_SELF_FIELD, + definition: { + ...sourceSchemaField.definition, + isList: false, + }, + children: undefined, + }, + ], + }, + filter + ) + : []; + if (!scopedStreamFields && !itemFields.length) { return []; } @@ -291,7 +312,18 @@ const getScopedFieldsForSelector = ( return sortFields( dedupeFieldsByName( - getFilteredEntityFields(scopedStreamFields, filter).map((field) => { + [ + ...itemFields.map((field) => ({ + ...field, + displayName: rootDisplayName ?? sourceField, + })), + ...(scopedStreamFields + ? getFilteredEntityFields(scopedStreamFields, filter) + : []), + ].map((field) => { + if (field.name === ITEM_SOURCE_SELF_FIELD) { + return field; + } const displayName = getEntityFieldDisplayName( `${sourceField}.${field.name}`, @@ -317,7 +349,8 @@ const getScopedFieldsForSelector = ( * * 1. Scope to a selected source item when `sourceField` is provided. * 2. For item-source and mapped-source pickers, restrict roots to fields that - * can satisfy the required descendant type sets. `itemSourceTypes` takes + * can satisfy the required type sets. Item sources also accept matching + * complete list items. `itemSourceTypes` takes * precedence over `mappedSourceTypes` when both are present and requires only * one matching group; mapped-source pickers require every group. * 3. Filter incompatible resolved values out when a stream document is @@ -370,7 +403,7 @@ export const getFieldsForSelector = ( if (filter.itemSourceTypes?.length) { return sortFields( dedupeFieldsByName( - getListSourceRootFields(entityFields) + getListSourceRootFields(entityFields, true) .map((field) => ({ ...field, displayName: @@ -378,7 +411,16 @@ export const getFieldsForSelector = ( field.displayName ?? field.name, })) - .filter(hasRequiredDescendants) + .filter( + (field) => + getFilteredEntityFields( + { fields: [{ ...field, children: undefined }] }, + { + types: filter.itemSourceTypes!.flat(), + includeListsOnly: true, + } + ).length > 0 || hasRequiredDescendants(field) + ) .filter((field) => !streamDocument ? true diff --git a/packages/visual-editor/src/fields/EntityFieldSelectorField.test.tsx b/packages/visual-editor/src/fields/EntityFieldSelectorField.test.tsx index 3b41be4efb..e1fe4f62ca 100644 --- a/packages/visual-editor/src/fields/EntityFieldSelectorField.test.tsx +++ b/packages/visual-editor/src/fields/EntityFieldSelectorField.test.tsx @@ -30,6 +30,8 @@ import { TemplateMetadataContext } from "../internal/hooks/useMessageReceivers.t import { generateTemplateMetadata } from "../internal/types/templateMetadata.ts"; import { type StreamFields } from "../types/entityFields.ts"; import { YextAutoField } from "./YextAutoField.tsx"; +import { photoGallerySource } from "../components/pageSections/PhotoGallerySection/photoGallerySource.ts"; +import { createItemSource } from "../utils/itemSource/createItemSource.ts"; import { getConstantConfigFromType, returnConstantFieldConfig, @@ -1117,4 +1119,379 @@ describe("EntityFieldSelectorField", () => { }, }); }); + + it.each([ + { + previousField: "c_brands", + nextField: "photoGallery", + label: "Photos", + expectedImageField: "$item", + }, + { + previousField: "photoGallery", + nextField: "c_brands", + label: "Brands", + expectedImageField: "", + }, + ])( + "when the gallery source changes to $nextField then its mappings update", + ({ previousField, nextField, label, expectedImageField }) => { + const { onChange } = renderRepeatedEntityField({ + field: photoGallerySource.field as EntityFieldSelectorField, + value: { + field: previousField, + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { + field: previousField === "photoGallery" ? "$item" : "logo", + constantValueEnabled: false, + }, + link: { field: "cta", constantValueEnabled: false }, + }, + }, + entityFields: { + fields: [ + { + name: "photoGallery", + displayName: "Photos", + definition: { + name: "photoGallery", + typeName: "type.image", + isList: true, + type: {}, + }, + }, + { + name: "c_brands", + displayName: "Brands", + definition: { name: "c_brands", isList: true, type: {} }, + children: { + fields: [ + { + name: "logo", + definition: { + name: "logo", + typeName: "type.image", + type: {}, + }, + }, + { + name: "cta", + definition: { name: "cta", typeName: "type.cta", type: {} }, + }, + ], + }, + }, + ], + }, + }); + + fireEvent.click(screen.getAllByRole("combobox")[0]); + fireEvent.click(within(screen.getByRole("listbox")).getByText(label)); + + expect(onChange).toHaveBeenCalledWith({ + field: nextField, + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { field: expectedImageField, constantValueEnabled: false }, + link: { field: "", constantValueEnabled: false }, + }, + }); + } + ); + + it("when a brand has a CTA then the gallery link selector offers the complete CTA", () => { + renderRepeatedEntityField({ + field: photoGallerySource.field as EntityFieldSelectorField, + value: { + ...photoGallerySource.defaultValue, + field: "c_brands", + constantValueEnabled: false, + }, + entityFields: { + fields: [ + { + name: "c_brands", + definition: { name: "c_brands", isList: true, type: {} }, + children: { + fields: [ + { + name: "logo", + displayName: "Brand Image", + definition: { + name: "logo", + typeName: "type.image", + type: {}, + }, + }, + { + name: "cta", + displayName: "Brand CTA", + definition: { name: "cta", typeName: "type.cta", type: {} }, + children: { + fields: [ + { + name: "label", + definition: { + name: "label", + typeName: "type.string", + type: {}, + }, + }, + { + name: "link", + definition: { + name: "link", + typeName: "type.string", + type: {}, + }, + }, + { + name: "linkType", + definition: { + name: "linkType", + typeName: "type.option", + type: {}, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + + fireEvent.click(screen.getAllByRole("combobox")[2]); + + expect( + within(screen.getByRole("listbox")) + .getAllByRole("option") + .map((option) => option.textContent) + ).toEqual(["Select a Field", "Brand CTA"]); + }); + + it("when a manual gallery image uses an entity field then single images are available", () => { + renderEntityField({ + field: (photoGallerySource.field as EntityFieldSelectorField).repeated! + .manualItemFields.image as EntityFieldSelectorField, + value: { + field: "", + constantValueEnabled: false, + constantValue: undefined, + }, + entityFields: { + fields: [ + { + name: "logo", + displayName: "Logo", + definition: { name: "logo", typeName: "type.image", type: {} }, + }, + ], + }, + }); + + fireEvent.click(screen.getByRole("combobox")); + + expect(within(screen.getByRole("listbox")).getByText("Logo")).toBeDefined(); + }); + + it("when a manual gallery link uses text then the link input is available", () => { + renderEntityField({ + field: (photoGallerySource.field as EntityFieldSelectorField).repeated! + .manualItemFields.link as EntityFieldSelectorField, + value: { + field: "", + constantValueEnabled: true, + constantValue: { defaultValue: "/brand" }, + }, + }); + + expect(screen.getByDisplayValue("/brand")).toBeDefined(); + }); + it.each([ + { previousField: "photoGallery", nextField: "", label: "Select a Field" }, + { previousField: "", nextField: "c_brands", label: "Brands" }, + ])( + "when the source changes from '$previousField' to '$nextField' then old mappings are cleared", + ({ previousField, nextField, label }): void => { + const { onChange } = renderRepeatedEntityField({ + field: photoGallerySource.field as EntityFieldSelectorField, + value: { + field: previousField, + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { field: "$item", constantValueEnabled: false }, + link: { field: "cta", constantValueEnabled: false }, + }, + }, + entityFields: { + fields: [ + { + name: "photoGallery", + definition: { + name: "photoGallery", + isList: true, + typeName: "type.image", + type: {}, + }, + }, + { + name: "c_brands", + displayName: "Brands", + definition: { name: "c_brands", isList: true, type: {} }, + children: { + fields: [ + { + name: "logo", + definition: { + name: "logo", + typeName: "type.image", + type: {}, + }, + }, + ], + }, + }, + ], + }, + }); + fireEvent.click(screen.getAllByRole("combobox")[0]); + fireEvent.click(within(screen.getByRole("listbox")).getByText(label)); + expect(onChange).toHaveBeenCalledWith({ + field: nextField, + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { field: "", constantValueEnabled: false }, + link: { field: "", constantValueEnabled: false }, + }, + }); + } + ); + + it.each([ + { secondaryType: "type.string" as const, expectedPrimary: "$item" }, + { secondaryType: "type.image" as const, expectedPrimary: "" }, + ])( + "when the second mapping accepts $secondaryType then automatic mapping is '$expectedPrimary'", + ({ secondaryType, expectedPrimary }): void => { + const source = createItemSource({ + label: "Items", + mappingFields: { + primary: { + type: "entityField", + label: "Primary", + filter: { types: ["type.image"] }, + }, + secondary: { + type: "entityField", + label: "Secondary", + filter: { types: [secondaryType] }, + }, + }, + }); + const { onChange } = renderRepeatedEntityField({ + field: source.field as EntityFieldSelectorField, + value: { + field: "previous", + constantValueEnabled: false, + constantValue: [], + mappings: { + primary: { field: "cover", constantValueEnabled: false }, + secondary: { field: "caption", constantValueEnabled: false }, + }, + }, + entityFields: { + fields: [ + { + name: "images", + displayName: "Images", + definition: { + name: "images", + typeRegistryId: "type.image", + isList: true, + type: {}, + }, + }, + ], + }, + }); + + fireEvent.click(screen.getAllByRole("combobox")[0]); + fireEvent.click(within(screen.getByRole("listbox")).getByText("Images")); + + expect(onChange).toHaveBeenCalledWith({ + field: "images", + constantValueEnabled: false, + constantValue: [], + mappings: { + primary: { field: expectedPrimary, constantValueEnabled: false }, + secondary: { field: "", constantValueEnabled: false }, + }, + }); + } + ); + + it("when several mappings accept image items then the user can select a complete item", () => { + const source = createItemSource({ + label: "Images", + mappingFields: { + primary: { + type: "entityField", + label: "Primary", + filter: { types: ["type.image"] }, + }, + secondary: { + type: "entityField", + label: "Secondary", + filter: { types: ["type.image"] }, + }, + }, + }); + const { onChange } = renderRepeatedEntityField({ + field: source.field as EntityFieldSelectorField, + value: { + field: "images", + constantValueEnabled: false, + constantValue: [], + mappings: { + primary: { field: "", constantValueEnabled: false }, + secondary: { field: "", constantValueEnabled: false }, + }, + }, + entityFields: { + fields: [ + { + name: "images", + displayName: "Images", + definition: { + name: "images", + typeName: "type.image", + isList: true, + type: {}, + }, + }, + ], + }, + }); + + fireEvent.click(screen.getAllByRole("combobox")[1]); + fireEvent.click(within(screen.getByRole("listbox")).getByText("Images")); + + expect(onChange).toHaveBeenCalledWith({ + field: "images", + constantValueEnabled: false, + constantValue: [], + mappings: { + primary: { field: "$item", constantValueEnabled: false }, + secondary: { field: "", constantValueEnabled: false }, + }, + }); + }); }); diff --git a/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx b/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx index 78b5cb4c11..636f8d8d67 100644 --- a/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx +++ b/packages/visual-editor/src/fields/EntityFieldSelectorField.tsx @@ -36,6 +36,7 @@ import { TemplateMetadataContext } from "../internal/hooks/useMessageReceivers.t import { type RepeatedEntityFieldMetadata, type RepeatedEntityFieldValue, + ITEM_SOURCE_SELF_FIELD, } from "../utils/itemSource/itemSourceTypes.ts"; import { getConstantConfigFromType, @@ -106,12 +107,13 @@ const getItemSourceTooltipRequirements = ( ); }; +/** Clears mapped fields, including saved values with no constantValue property. */ const clearEntityFieldBindings = (value: unknown): unknown => { if ( value && typeof value === "object" && "field" in value && - "constantValue" in value + ("constantValue" in value || "constantValueEnabled" in value) ) { return { ...value, @@ -135,12 +137,13 @@ const clearEntityFieldBindings = (value: unknown): unknown => { return value; }; +/** Checks nested values for a selected entity field. */ const hasEntityFieldBindings = (value: unknown): boolean => { if ( value && typeof value === "object" && "field" in value && - "constantValue" in value + ("constantValue" in value || "constantValueEnabled" in value) ) { return !!(value as { field?: string }).field; } @@ -175,6 +178,7 @@ const RepeatedEntityFieldSelector = ({ repeated: RepeatedEntityFieldMetadata>; }) => { const translatedLabel = field.label ? pt(field.label) : ""; + const entityFields = useEntityFields(); const constantValueEnabled = !!value?.constantValueEnabled; const baseValue: RepeatedEntityFieldValue> = { field: field.fixedRepeatedField || value?.field || "", @@ -218,22 +222,41 @@ const RepeatedEntityFieldSelector = ({ ? nextValue.field : ""; - if ( - !previousField || - !nextField || - previousField === nextField || - !hasEntityFieldBindings(baseValue.mappings) - ) { - onChange(nextValue); - return; + const changedSource = previousField !== nextField; + let mappings = nextValue.mappings; + if (changedSource && hasEntityFieldBindings(baseValue.mappings)) { + mappings = clearEntityFieldBindings( + baseValue.mappings + ) as typeof mappings; } - onChange({ - ...nextValue, - mappings: clearEntityFieldBindings(baseValue.mappings), - }); + if (changedSource && nextField) { + const matchingMappings = Object.entries(repeated.mappingFields).filter( + ([, mappingField]) => + mappingField.type === "entityField" && + getFieldsForSelector( + entityFields, + mappingField.filter, + undefined, + nextField + ).some((option) => option.name === ITEM_SOURCE_SELF_FIELD) + ); + if (matchingMappings.length === 1) { + const [mappingKey] = matchingMappings[0]; + mappings = { + ...mappings, + [mappingKey]: { + ...(mappings?.[mappingKey] as Record), + field: ITEM_SOURCE_SELF_FIELD, + constantValueEnabled: false, + }, + }; + } + } + + onChange({ ...nextValue, mappings }); }, - [baseValue, onChange] + [baseValue, entityFields, onChange, repeated.mappingFields] ); return ( @@ -606,6 +629,9 @@ export const EntityFieldInput = >({ const options = filteredEntityFields.map((field) => { return { label: + (field.name === ITEM_SOURCE_SELF_FIELD + ? field.displayName + : undefined) ?? getScopedEntityFieldDisplayName( sourceField || undefined, field.name, diff --git a/packages/visual-editor/src/fields/YextAutoField.tsx b/packages/visual-editor/src/fields/YextAutoField.tsx index 986d0ff6c7..182f72f60f 100644 --- a/packages/visual-editor/src/fields/YextAutoField.tsx +++ b/packages/visual-editor/src/fields/YextAutoField.tsx @@ -1,11 +1,10 @@ import React from "react"; import { AutoField, type FieldProps } from "@puckeditor/core"; -import type { YextFieldDefinition } from "./fields.ts"; +import { toPuckFields, type YextFieldDefinition } from "./fields.ts"; import { isYextOverrideType, YextPuckFieldOverrides, } from "./fieldOverrides.ts"; -import { adaptYextField } from "./yextFieldAdapter.ts"; type YextAutoFieldProps = Omit< FieldProps, @@ -15,25 +14,6 @@ type YextAutoFieldProps = Omit< value: ValueType; }; -const normalizeField = ( - field: YextFieldDefinition -): YextFieldDefinition => { - return adaptYextField(field, (yextField) => { - // Nested Puck override field types render correctly, but Puck still creates - // a default child component for non-core field types, which produces React - // warnings. Wrapping nested overrides as `custom` avoids that path. - const FieldOverride = YextPuckFieldOverrides[yextField.type]; - - return { - ...yextField, - type: "custom", - render: ({ field: _, ...props }) => ( - - ), - }; - }); -}; - export const YextAutoField = ({ field, ...props @@ -44,7 +24,12 @@ export const YextAutoField = ({ return ; } - const normalizedField = React.useMemo(() => normalizeField(field), [field]); + // Custom fields avoid Puck's default child renderer for Yext field types. + // Use the shared renderer so field updates do not mount nested controls again. + const normalizedField = React.useMemo( + () => toPuckFields({ value: field }).value, + [field] + ); return ; }; diff --git a/packages/visual-editor/src/fields/fields.test.tsx b/packages/visual-editor/src/fields/fields.test.tsx index 96132c54fd..a6f490f205 100644 --- a/packages/visual-editor/src/fields/fields.test.tsx +++ b/packages/visual-editor/src/fields/fields.test.tsx @@ -114,4 +114,17 @@ describe("toPuckFields", () => { basicSelectorField ); }); + it("when field definitions change then the renderer stays the same and uses the new field", (): void => { + const first = toPuckFields({ + choice: { type: "basicSelector", label: "First", options: [] }, + }).choice as CustomField; + const second = toPuckFields({ + choice: { type: "basicSelector", label: "Second", options: [] }, + }).choice as CustomField; + + expect(second.render).toBe(first.render); + expect( + (renderCustomField(second) as React.ReactElement).props.field.label + ).toBe("Second"); + }); }); diff --git a/packages/visual-editor/src/fields/fields.ts b/packages/visual-editor/src/fields/fields.ts index f792c5c7b7..a6bd82f349 100644 --- a/packages/visual-editor/src/fields/fields.ts +++ b/packages/visual-editor/src/fields/fields.ts @@ -1,10 +1,11 @@ -import { createElement } from "react"; +import { createElement, type ReactElement } from "react"; import type { ArrayField, CustomField, ComponentConfig, DefaultComponentProps, Field, + FieldProps, Fields, ObjectField, } from "@puckeditor/core"; @@ -106,6 +107,20 @@ export type YextFieldMap< [PropName in keyof Omit]: YextFieldDefinition; }; +/** Keeps custom field components mounted when Puck updates field definitions. */ +const renderYextField = ({ + field, + ...props +}: FieldProps>): ReactElement => + createElement(YextAutoField, { + ...props, + field: ( + field as CustomField & { + yextField: YextFieldDefinition; + } + ).yextField, + }); + /** * Converts Yext field definitions into a runtime `Fields` object that Puck can * render safely. @@ -127,10 +142,7 @@ export const toPuckFields = < (yextField) => ({ ...yextField, type: "custom", - render: ({ field: _, ...props }) => - createElement(YextAutoField, { - ...(props as any), - field: yextField, - }), + yextField, + render: renderYextField, }) ) as Fields; diff --git a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx index be9bb2124b..7cc3f67fec 100644 --- a/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx +++ b/packages/visual-editor/src/internal/components/InternalLayoutEditor.tsx @@ -93,6 +93,11 @@ type InternalLayoutEditorProps = { metadata?: Metadata; }; +/** Keeps field contents mounted when the editor saves state, and removes label icons. */ +const LayoutFieldLabel = ( + props: React.ComponentProps +): React.ReactElement => ; + // Render Puck editor export const InternalLayoutEditor = ({ puckConfig, @@ -112,6 +117,11 @@ export const InternalLayoutEditor = ({ const { i18n } = usePlatformTranslation(); const streamDocument = useDocument(); const { errorCount, errorSources, errorDetails } = useErrorContext(); + // Keep plugin sidebar fields mounted when saved state changes. + const plugins = React.useMemo( + () => [{ ...blocks, label: pt("sections", "Sections") }, outline], + [i18n.language] + ); /** * When the Puck history changes save it to localStorage and send a message @@ -396,7 +406,7 @@ export const InternalLayoutEditor = ({ config={translatedPuckConfigWithRootFields} data={{}} // we use puckInitialHistory instead initialHistory={puckInitialHistory} - plugins={[{ ...blocks, label: pt("sections", "Sections") }, outline]} + plugins={plugins} overrides={{ fields: fieldsOverride, header: () => ( @@ -594,10 +604,7 @@ export const InternalLayoutEditor = ({ ); }, - // oxlint-disable-next-line no-unused-vars removed all icons from all field labels - fieldLabel: ({ icon, children, ...rest }) => ( - {children} - ), + fieldLabel: LayoutFieldLabel, puck: puckOverride, }} metadata={{ diff --git a/packages/visual-editor/src/utils/cardSlots/mappedSource.ts b/packages/visual-editor/src/utils/cardSlots/mappedSource.ts index e0c5e2d80d..fa5daa60a6 100644 --- a/packages/visual-editor/src/utils/cardSlots/mappedSource.ts +++ b/packages/visual-editor/src/utils/cardSlots/mappedSource.ts @@ -20,11 +20,12 @@ const getListFields = ( }); /** - * Returns every list field with nested children so wrappers can treat that - * list's item shape as a mapped source. + * Returns lists with child fields for mapped sources. Item sources can also + * include lists whose complete items can be mapped. */ export const getListSourceRootFields = ( - entityFields: StreamFields | YextSchemaField[] | null + entityFields: StreamFields | YextSchemaField[] | null, + includeItems = false ): YextSchemaField[] => { const fields = Array.isArray(entityFields) ? entityFields @@ -33,8 +34,9 @@ export const getListSourceRootFields = ( return getListFields(fields).filter( (field) => !!field.definition?.isList && - Array.isArray(field.children?.fields) && - field.children.fields.length > 0 + ((Array.isArray(field.children?.fields) && + field.children.fields.length > 0) || + includeItems) ); }; diff --git a/packages/visual-editor/src/utils/itemSource/createItemSource.test.ts b/packages/visual-editor/src/utils/itemSource/createItemSource.test.ts index 86ae55cc91..4b190791b9 100644 --- a/packages/visual-editor/src/utils/itemSource/createItemSource.test.ts +++ b/packages/visual-editor/src/utils/itemSource/createItemSource.test.ts @@ -4,6 +4,7 @@ import { type TranslatableString, } from "../../types/types.ts"; import { createItemSource } from "./index.ts"; +import { ITEM_SOURCE_SELF_FIELD } from "./itemSourceTypes.ts"; type ArticleItemProps = { title: { @@ -401,4 +402,204 @@ describe("createItemSource", () => { }, ]); }); + + it("resolves direct image items and mapped object items with optional links", () => { + const gallerySource = createItemSource<{ + image: { + field: string; + constantValueEnabled?: boolean; + constantValue: { url: string }; + }; + link: { + field: string; + constantValueEnabled?: boolean; + constantValue: string; + }; + }>({ + label: "Images", + mappingFields: { + image: { + type: "entityField", + filter: { types: ["type.image"] }, + }, + link: { + type: "entityField", + filter: { types: ["type.string"] }, + }, + }, + }); + + expect((gallerySource.field as any).filter).toEqual({ + itemSourceTypes: [["type.image"], ["type.string"]], + }); + expect( + (gallerySource.field as any).repeated.mappingFields.image.filter + ).toEqual({ + types: ["type.image"], + }); + + expect( + gallerySource.resolveItems( + { + field: "images", + constantValue: [], + constantValueEnabled: false, + mappings: { + image: { + field: ITEM_SOURCE_SELF_FIELD, + constantValue: { url: "" }, + constantValueEnabled: false, + }, + link: { + field: "clickthroughUrl", + constantValue: "", + constantValueEnabled: false, + }, + }, + }, + { + locale: "en", + images: [ + { url: "https://example.com/one.jpg" }, + { + image: { url: "https://example.com/two.jpg" }, + clickthroughUrl: "https://example.com/two", + }, + ], + } + ) + ).toEqual([ + { image: { url: "https://example.com/one.jpg" }, link: undefined }, + { + image: { + image: { url: "https://example.com/two.jpg" }, + clickthroughUrl: "https://example.com/two", + }, + link: "https://example.com/two", + }, + ]); + + expect( + gallerySource.resolveItems( + { + field: "products", + constantValue: [], + constantValueEnabled: false, + mappings: { + image: { + field: "cover", + constantValue: { url: "" }, + constantValueEnabled: false, + }, + link: { + field: "destination", + constantValue: "", + constantValueEnabled: false, + }, + }, + }, + { + locale: "en", + products: [ + { + cover: { url: "https://example.com/product.jpg" }, + destination: "/product", + }, + ], + } + ) + ).toEqual([ + { image: { url: "https://example.com/product.jpg" }, link: "/product" }, + ]); + }); + + it.each([ + { + constantValueEnabled: false, + expected: { url: "https://example.com/source.jpg" }, + }, + { + constantValueEnabled: true, + expected: { url: "https://example.com/manual.jpg" }, + }, + ])( + "when a direct mapping has constant mode $constantValueEnabled then it uses the selected value", + ({ constantValueEnabled, expected }) => { + const source = createItemSource<{ + image: { + field: string; + constantValueEnabled: boolean; + constantValue: { url: string }; + }; + }>({ + label: "Images", + mappingFields: { + image: { + type: "entityField", + filter: { types: ["type.image"] }, + disableConstantValueToggle: false, + }, + }, + }); + + expect( + source.resolveItems( + { + field: "images", + constantValueEnabled: false, + constantValue: [], + mappings: { + image: { + field: "$item", + constantValueEnabled, + constantValue: { url: "https://example.com/manual.jpg" }, + }, + }, + }, + { locale: "en", images: [{ url: "https://example.com/source.jpg" }] } + ) + ).toEqual([{ image: expected }]); + } + ); + it.each([ + { + type: "type.image" as const, + item: { url: "https://example.com/image.jpg" }, + }, + { type: "type.string" as const, item: "Caption" }, + ])( + "when the mapping accepts $type then complete items resolve", + ({ type, item }): void => { + const source = createItemSource({ + label: "Items", + mappingFields: { + value: { + type: "entityField", + filter: { types: [type] }, + }, + }, + }); + + expect(source.field).toMatchObject({ + filter: { itemSourceTypes: [[type]] }, + repeated: { + mappingFields: { value: { filter: { types: [type] } } }, + manualItemFields: { value: { filter: { types: [type] } } }, + }, + }); + expect( + source.resolveItems( + { + field: "items", + constantValueEnabled: false, + constantValue: [], + mappings: { + value: { field: "$item", constantValueEnabled: false }, + }, + }, + { items: [item] } + ) + ).toEqual([{ value: item }]); + } + ); }); diff --git a/packages/visual-editor/src/utils/itemSource/createItemSource.ts b/packages/visual-editor/src/utils/itemSource/createItemSource.ts index 85005b708f..0a2baff8c6 100644 --- a/packages/visual-editor/src/utils/itemSource/createItemSource.ts +++ b/packages/visual-editor/src/utils/itemSource/createItemSource.ts @@ -20,7 +20,7 @@ import { resolveItemValue } from "./itemSourceResolution.ts"; /** * Item-source assembly. * - * 1. Builds one repeated `entityField` config from the authored mapping props. + * 1. Uses mappingFields for manual inputs and linked item mappings. * 2. Generates the default repeated value for linked and manual modes. * 3. Resolves linked or manual items into render-ready values without writing * derived data back onto component props. diff --git a/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts b/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts index e6df799af8..139bdddda5 100644 --- a/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts +++ b/packages/visual-editor/src/utils/itemSource/itemSourceResolution.ts @@ -3,7 +3,10 @@ import { type YextFieldDefinition } from "../../fields/fields.ts"; import { resolveYextEntityField } from "../resolveYextEntityField.ts"; import { type StreamDocument } from "../types/StreamDocument.ts"; import { isEntityFieldDefinition } from "./itemSourceFieldTransforms.ts"; -import { type ResolvedItemField } from "./itemSourceTypes.ts"; +import { + ITEM_SOURCE_SELF_FIELD, + type ResolvedItemField, +} from "./itemSourceTypes.ts"; /** * Runtime item resolution. @@ -28,6 +31,13 @@ export const resolveItemValue = ( return undefined as ResolvedItemField; } + if ( + !entityField?.constantValueEnabled && + entityField?.field === ITEM_SOURCE_SELF_FIELD + ) { + return itemDocument as ResolvedItemField; + } + return resolveYextEntityField( itemDocument ?? streamDocument, { diff --git a/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts b/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts index c5387edf8a..6790b81ee1 100644 --- a/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts +++ b/packages/visual-editor/src/utils/itemSource/itemSourceTypes.ts @@ -6,6 +6,8 @@ import { } from "../../fields/fields.ts"; import { type StreamDocument } from "../types/StreamDocument.ts"; +export const ITEM_SOURCE_SELF_FIELD = "$item"; + /** * Public item-source types. * @@ -16,8 +18,16 @@ import { type StreamDocument } from "../types/StreamDocument.ts"; export type CreateItemSourceOptions< TItemProps extends Record, > = { + /** Label shown above the item source controls. */ label: string; + /** + * Fields for each item. These define both the manual item inputs and the + * mappings to fields within each object in a selected entity list. + * A list can be selected when its item type or a child field matches any + * mapping field filter. + */ mappingFields: YextFieldMap; + /** Initial manual items. The first item is also the template for new items. */ defaultValues?: TItemProps[]; }; diff --git a/packages/visual-editor/src/utils/migrate.test.ts b/packages/visual-editor/src/utils/migrate.test.ts index e7e5cc0194..3e09ec66c3 100644 --- a/packages/visual-editor/src/utils/migrate.test.ts +++ b/packages/visual-editor/src/utils/migrate.test.ts @@ -8,6 +8,7 @@ import { normalizeFooterLogoImageMigration } from "../components/migrations/0075 import { slotMappedCardsMigration } from "../components/migrations/0076_slot_mapped_cards.ts"; import { removeMapboxApiKeyPropsMigration } from "../components/migrations/0078_remove_mapbox_api_key_props.ts"; import { imageFillTypeMigration } from "../components/migrations/0079_image_fill_type.ts"; +import { photoGalleryItemSource } from "../components/migrations/0083_photo_gallery_item_source.ts"; describe("migrate", () => { it("successfully applies a migration", async () => { @@ -1649,6 +1650,78 @@ describe("migrate", () => { }, }); }); + + it.each([ + { + name: "manual images", + images: { + field: "", + constantValueEnabled: true, + constantValue: [ + { + assetImage: { + url: "https://example.com/image.jpg", + width: 100, + height: 100, + }, + clickthroughUrl: "/image", + }, + ], + }, + expectedImage: { + url: "https://example.com/image.jpg", + width: 100, + height: 100, + }, + expectedLink: "/image", + expectedMapping: "", + }, + { + name: "linked image list", + images: { + field: "photoGallery", + constantValueEnabled: false, + constantValue: [], + }, + expectedImage: undefined, + expectedLink: undefined, + expectedMapping: "$item", + }, + ])( + "migrates $name to gallery items", + ({ images, expectedImage, expectedLink, expectedMapping }) => { + const migratedData = migrate( + { + root: { props: { version: 0 } }, + content: [ + { + type: "PhotoGalleryWrapper", + props: { + id: "gallery", + data: { images }, + styles: { image: { aspectRatio: 1.78 } }, + }, + }, + ], + zones: {}, + }, + [photoGalleryItemSource], + { components: {} }, + { locale: "en" } + ); + const props = migratedData.content[0]?.props; + + expect(props.styles).toEqual({ image: { aspectRatio: 1.78 } }); + expect(props.data.images.field).toBe(images.field); + expect(props.data.images.mappings.image.field).toBe(expectedMapping); + expect(props.data.images.constantValue[0]?.image.constantValue).toEqual( + expectedImage + ); + expect(props.data.images.constantValue[0]?.link.constantValue).toEqual( + expectedLink ? { defaultValue: expectedLink } : undefined + ); + } + ); }); const migration: Migration = { diff --git a/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts b/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts index 9dc5324119..5ec7fd50ee 100644 --- a/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts +++ b/packages/visual-editor/src/vite-plugin/defaultLayoutData.ts @@ -1,7 +1,7 @@ const mainDefaultLayout = { root: { props: { - version: 73, + version: 83, title: { field: "name", constantValue: "", @@ -56,6 +56,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1, width: 100, + imageFillType: "fill", }, }, }, @@ -401,6 +402,7 @@ const mainDefaultLayout = { desktopContainerPosition: "left", mobileContentAlignment: "left", mobileImagePosition: "bottom", + showPhone: false, }, slots: { BusinessNameSlot: [ @@ -554,6 +556,31 @@ const mainDefaultLayout = { }, }, ], + PhoneSlot: [ + { + type: "PhoneNumbersSlot", + props: { + id: "HeroSection-6171b068-a44d-406e-9094-95f1ca7a53c5-PhoneSlot", + data: { + phoneNumbers: [ + { + number: { + field: "mainPhone", + constantValue: "", + }, + label: { + defaultValue: "Phone", + }, + }, + ], + }, + styles: { + phoneFormat: "domestic", + includePhoneHyperlink: true, + }, + }, + }, + ], }, analytics: { scope: "heroSection", @@ -850,6 +877,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1.78, width: 640, + imageFillType: "fill", }, sizes: { base: "calc(100vw - 32px)", @@ -1025,6 +1053,7 @@ const mainDefaultLayout = { aspectRatio: 1.78, width: 640, imageConstrain: "fill", + imageFillType: "fill", }, sizes: { base: "calc(100vw - 32px)", @@ -1200,6 +1229,7 @@ const mainDefaultLayout = { aspectRatio: 1.78, width: 640, imageConstrain: "fill", + imageFillType: "fill", }, sizes: { base: "calc(100vw - 32px)", @@ -1375,6 +1405,7 @@ const mainDefaultLayout = { aspectRatio: 1.78, width: 640, imageConstrain: "fill", + imageFillType: "fill", }, sizes: { base: "calc(100vw - 32px)", @@ -2162,6 +2193,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1, width: 200, + imageFillType: "fill", }, hideWidthProp: true, className: @@ -2340,6 +2372,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1, width: 200, + imageFillType: "fill", }, hideWidthProp: true, className: @@ -2518,6 +2551,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1, width: 200, + imageFillType: "fill", }, hideWidthProp: true, className: @@ -2800,6 +2834,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1.78, width: 640, + imageFillType: "fill", }, sizes: { base: "calc(100vw - 32px)", @@ -2971,6 +3006,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1.78, width: 640, + imageFillType: "fill", }, sizes: { base: "calc(100vw - 32px)", @@ -3142,6 +3178,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1.78, width: 640, + imageFillType: "fill", }, sizes: { base: "calc(100vw - 32px)", @@ -3334,37 +3371,68 @@ const mainDefaultLayout = { field: "", constantValue: [ { - assetImage: { - url: "https://images.unsplash.com/photo-1504548840739-580b10ae7715?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&height=570&width=1000&fit=max", - width: 1000, - height: 570, - assetImage: { - name: "Placeholder", + image: { + field: "", + constantValueEnabled: true, + constantValue: { + url: "https://images.unsplash.com/photo-1504548840739-580b10ae7715?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&height=570&width=1000&fit=max", + width: 1000, + height: 570, + assetImage: { name: "Placeholder" }, }, }, + link: { + field: "", + constantValueEnabled: true, + constantValue: { defaultValue: "" }, + }, }, { - assetImage: { - url: "https://images.unsplash.com/photo-1755745360285-0633c972b0fd?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&height=570&width=1000&fit=max", - width: 1000, - height: 570, - assetImage: { - name: "Placeholder", + image: { + field: "", + constantValueEnabled: true, + constantValue: { + url: "https://images.unsplash.com/photo-1755745360285-0633c972b0fd?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&height=570&width=1000&fit=max", + width: 1000, + height: 570, + assetImage: { name: "Placeholder" }, }, }, + link: { + field: "", + constantValueEnabled: true, + constantValue: { defaultValue: "" }, + }, }, { - assetImage: { - url: "https://images.unsplash.com/photo-1504548840739-580b10ae7715?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&height=570&width=1000&fit=max", - width: 1000, - height: 570, - assetImage: { - name: "Placeholder", + image: { + field: "", + constantValueEnabled: true, + constantValue: { + url: "https://images.unsplash.com/photo-1504548840739-580b10ae7715?ixlib=rb-4.1.0&q=85&fm=jpg&crop=entropy&cs=srgb&height=570&width=1000&fit=max", + width: 1000, + height: 570, + assetImage: { name: "Placeholder" }, }, }, + link: { + field: "", + constantValueEnabled: true, + constantValue: { defaultValue: "" }, + }, }, ], constantValueEnabled: true, + mappings: { + image: { + field: "", + constantValueEnabled: false, + }, + link: { + field: "", + constantValueEnabled: false, + }, + }, }, }, styles: { @@ -3499,6 +3567,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1.78, width: 640, + imageFillType: "fill", }, hideWidthProp: true, className: @@ -3660,6 +3729,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1.78, width: 640, + imageFillType: "fill", }, hideWidthProp: true, className: @@ -3821,6 +3891,7 @@ const mainDefaultLayout = { styles: { aspectRatio: 1.78, width: 640, + imageFillType: "fill", }, hideWidthProp: true, className: @@ -4077,6 +4148,7 @@ const mainDefaultLayout = { styles: { width: 100, aspectRatio: 1, + imageFillType: "fill", }, }, }, @@ -4109,6 +4181,7 @@ const mainDefaultLayout = { styles: { width: 0, aspectRatio: 1, + imageFillType: "fill", }, }, }, diff --git a/starter/localData/dev-location-stream__en__cbafb9cd1c3e63d9814e236ba9181377.json b/starter/localData/dev-location-stream__en__cbafb9cd1c3e63d9814e236ba9181377.json index 3b8e734d86..ba4c74522a 100644 --- a/starter/localData/dev-location-stream__en__cbafb9cd1c3e63d9814e236ba9181377.json +++ b/starter/localData/dev-location-stream__en__cbafb9cd1c3e63d9814e236ba9181377.json @@ -738,7 +738,8 @@ ], "url": "https://a.mktgcdn.com/p-dev/riaolTLcpz-o-o1mImrnaEaeNBs58dqlB7TS2moQgyo/2048x2048.jpg", "width": 2048 - } + }, + "clickthroughUrl": "https://www.yext.com/" }, { "image": { diff --git a/starter/src/templates/dev.tsx b/starter/src/templates/dev.tsx index 98340644be..ce8a0fbbc5 100644 --- a/starter/src/templates/dev.tsx +++ b/starter/src/templates/dev.tsx @@ -65,6 +65,7 @@ export const config = { "mainPhone", "emails", "services", + "photoGallery", "c_deliveryPromo", "ref_listings", ],