diff --git a/packages/visual-editor/src/components/footer/FooterExpandedLinkSectionSlot.tsx b/packages/visual-editor/src/components/footer/FooterExpandedLinkSectionSlot.tsx index 6fe858acda..f7a3ec6f23 100644 --- a/packages/visual-editor/src/components/footer/FooterExpandedLinkSectionSlot.tsx +++ b/packages/visual-editor/src/components/footer/FooterExpandedLinkSectionSlot.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { PuckComponent, setDeep } from "@puckeditor/core"; import { msg, pt } from "../../utils/i18n/platform.ts"; import { useDocument } from "../../hooks/useDocument.tsx"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; +import { resolveLocalizedFooterLinkSection } from "./resolveLocalizedFooterLinkSection.ts"; import { TranslatableString, TranslatableCTA } from "../../types/types.ts"; import { YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; import { CTA } from "../atoms/cta.tsx"; @@ -41,52 +41,48 @@ const FooterExpandedLinkSectionSlotInternal: PuckComponent< const background = useBackground(); const isDarkBackground = background?.isDarkColor ?? false; - const label = resolveComponentData(data.label, i18n.language, streamDocument); - const links = data.links; + const { label, links } = resolveLocalizedFooterLinkSection( + data, + i18n.language, + streamDocument + ); const defaultColor: ThemeColor = isDarkBackground ? { selectedColor: "white", contrastingColor: "black" } : { selectedColor: "palette-primary-dark", contrastingColor: "white" }; const resolvedColor = styles?.color ?? defaultColor; + if (links.length === 0 && !puck.isEditing) { + return <>; + } + return (
- - {label} - + {label.trim() !== "" && ( + + {label} + + )}
- {links && links.length > 0 - ? links.map((linkData, index) => { - const linkLabel = resolveComponentData( - linkData.label, - i18n.language, - streamDocument - ); - const link = resolveComponentData( - linkData.link, - i18n.language, - streamDocument - ); - - return ( - - ); - }) + {links.length > 0 + ? links.map((linkData, index) => ( + + )) : puck.isEditing &&
}
diff --git a/packages/visual-editor/src/components/footer/FooterExpandedLinksWrapper.tsx b/packages/visual-editor/src/components/footer/FooterExpandedLinksWrapper.tsx index 31e6b07036..babaca76a2 100644 --- a/packages/visual-editor/src/components/footer/FooterExpandedLinksWrapper.tsx +++ b/packages/visual-editor/src/components/footer/FooterExpandedLinksWrapper.tsx @@ -4,10 +4,8 @@ import { msg, pt } from "../../utils/i18n/platform.ts"; import { TranslatableString, TranslatableCTA } from "../../types/types.ts"; import { i18nComponentsInstance } from "../../utils/i18n/components.ts"; import { useDocument } from "../../hooks/useDocument.tsx"; -import { - getDisplayValue, - resolveComponentData, -} from "../../utils/resolveComponentData.tsx"; +import { getDisplayValue } from "../../utils/resolveComponentData.tsx"; +import { resolveLocalizedFooterLinkSection } from "./resolveLocalizedFooterLinkSection.ts"; import { CTA } from "../atoms/cta.tsx"; import { useBackground } from "../../hooks/useBackground.tsx"; import { Body } from "../atoms/body.tsx"; @@ -246,13 +244,19 @@ const FooterExpandedLinksWrapperInternal: PuckComponent< styles, desktopContentAlignment = "left", mobileContentAlignment = "left", + puck, } = props; const streamDocument = useDocument(); const { i18n } = useTranslation(); const background = useBackground(); const isDarkBackground = background?.isDarkColor ?? false; - const sections = data.sections || []; + const resolvedSections = (data.sections || []).map((section) => + resolveLocalizedFooterLinkSection(section, i18n.language, streamDocument) + ); + const sections = puck.isEditing + ? resolvedSections + : resolvedSections.filter((section) => section.links.length > 0); const defaultLabelColor = isDarkBackground ? ({ selectedColor: "white", contrastingColor: "black" } as ThemeColor) : ({ selectedColor: "black", contrastingColor: "white" } as ThemeColor); @@ -286,13 +290,6 @@ const FooterExpandedLinksWrapperInternal: PuckComponent< } > {sections.map((section, sectionIndex) => { - const label = resolveComponentData( - section.label, - i18n.language, - streamDocument - ); - const links = section.links || []; - return (
- - {label} - + {section.label.trim() !== "" && ( + + {section.label} + + )}
- {links.map((linkData, linkIndex) => { - const linkLabel = resolveComponentData( - linkData.label, - i18n.language, - streamDocument - ); - const link = resolveComponentData( - linkData.link, - i18n.language, - streamDocument - ); - - return ( - - ); - })} + {section.links.length > 0 + ? section.links.map((linkData, linkIndex) => ( + + )) + : puck.isEditing &&
}
); diff --git a/packages/visual-editor/src/components/footer/FooterLinksSlot.tsx b/packages/visual-editor/src/components/footer/FooterLinksSlot.tsx index 6ed074f85e..d7b1e9e0aa 100644 --- a/packages/visual-editor/src/components/footer/FooterLinksSlot.tsx +++ b/packages/visual-editor/src/components/footer/FooterLinksSlot.tsx @@ -3,7 +3,7 @@ import { PuckComponent, setDeep } from "@puckeditor/core"; import { cva } from "class-variance-authority"; import { msg, pt } from "../../utils/i18n/platform.ts"; import { useDocument } from "../../hooks/useDocument.tsx"; -import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; +import { resolveLocalizedCtas } from "../../utils/resolveLocalizedCtas.ts"; import { CTA } from "../atoms/cta.tsx"; import { TranslatableCTA } from "../../types/types.ts"; import { i18nComponentsInstance } from "../../utils/i18n/components.ts"; @@ -167,29 +167,22 @@ const FooterLinksSlotInternal: PuckComponent = ( } = props; const streamDocument = useDocument(); const { i18n } = useTranslation(); + const resolvedLinks = resolveLocalizedCtas( + data.links, + i18n.language, + streamDocument + ); - if (!data.links || data.links.length === 0) { + if (resolvedLinks.length === 0) { return puck.isEditing ?
: <>; } - const links = data.links.map((linkData, index) => { - const label = resolveComponentData( - linkData.label, - i18n.language, - streamDocument - ); - - const link = resolveComponentData( - linkData.link, - i18n.language, - streamDocument - ); - + const links = resolvedLinks.map((linkData, index) => { return ( { + it("resolves the section label and removes links that are blank in the active locale", () => { + const section = { + label: { defaultValue: "Resources", fr: "Ressources" }, + links: [ + { + linkType: "URL" as const, + label: { defaultValue: "About", fr: "À propos" }, + link: { defaultValue: "/about", fr: "/fr/about" }, + }, + { + linkType: "URL" as const, + label: { defaultValue: "Careers", fr: "" }, + link: { defaultValue: "/careers", fr: "" }, + }, + ], + }; + + expect(resolveLocalizedFooterLinkSection(section, "fr")).toEqual({ + label: "Ressources", + links: [ + { + linkType: "URL", + label: "À propos", + link: "/fr/about", + }, + ], + }); + }); + + it("keeps valid links when the localized section label is blank", () => { + const section = { + label: { defaultValue: "Resources", fr: "" }, + links: [ + { + linkType: "URL" as const, + label: { defaultValue: "About", fr: "À propos" }, + link: { defaultValue: "/about", fr: "/fr/about" }, + }, + ], + }; + + expect(resolveLocalizedFooterLinkSection(section, "fr")).toMatchObject({ + label: "", + links: [{ label: "À propos", link: "/fr/about" }], + }); + }); + + it("resolves an entity-backed section label", () => { + const section = { + label: { + field: "", + constantValue: { defaultValue: "Resources", fr: "Ressources" }, + constantValueEnabled: true, + }, + links: [], + }; + + expect(resolveLocalizedFooterLinkSection(section, "fr")).toEqual({ + label: "Ressources", + links: [], + }); + }); +}); diff --git a/packages/visual-editor/src/components/footer/resolveLocalizedFooterLinkSection.ts b/packages/visual-editor/src/components/footer/resolveLocalizedFooterLinkSection.ts new file mode 100644 index 0000000000..79f6a8da8a --- /dev/null +++ b/packages/visual-editor/src/components/footer/resolveLocalizedFooterLinkSection.ts @@ -0,0 +1,34 @@ +import { type YextEntityField } from "../../editor/YextEntityFieldSelector.tsx"; +import { + type TranslatableCTA, + type TranslatableString, +} from "../../types/types.ts"; +import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; +import { + type ResolvedCTA, + resolveLocalizedCtas, +} from "../../utils/resolveLocalizedCtas.ts"; + +type FooterLinkSection = { + label: TranslatableString | YextEntityField; + links: TranslatableCTA[]; +}; + +export type ResolvedFooterLinkSection< + T extends FooterLinkSection = FooterLinkSection, +> = Omit & { + label: string; + links: ResolvedCTA[]; +}; + +export const resolveLocalizedFooterLinkSection = ( + section: T, + locale: string, + streamDocument?: Record +): ResolvedFooterLinkSection => { + return { + ...section, + label: resolveComponentData(section.label, locale, streamDocument), + links: resolveLocalizedCtas(section.links, locale, streamDocument), + }; +}; diff --git a/packages/visual-editor/src/components/header/HeaderLinks.tsx b/packages/visual-editor/src/components/header/HeaderLinks.tsx index 71d462a63f..6612e56f5d 100644 --- a/packages/visual-editor/src/components/header/HeaderLinks.tsx +++ b/packages/visual-editor/src/components/header/HeaderLinks.tsx @@ -19,6 +19,10 @@ import { import { ThemeColor, ThemeOptions } from "../../utils/themeConfigOptions.ts"; import { BodyProps } from "../atoms/body.tsx"; import { isNonNormalizableLinkType } from "../../utils/normalizeLink.ts"; +import { + type ResolvedCTA, + resolveLocalizedCtas, +} from "../../utils/resolveLocalizedCtas.ts"; import { toPuckFields, YextComponentConfig, @@ -214,15 +218,19 @@ const HeaderLinksComponent: PuckComponent = ({ : t("secondaryHeaderLinks", "Secondary Header Links"); const validLinks = React.useMemo( - () => data.links?.filter((item) => !!item?.link) || [], - [data.links] + () => resolveLocalizedCtas(data.links, i18n.language, streamDocument), + [data.links, i18n.language, streamDocument] ); const validAlwaysCollapsedLinks = React.useMemo( () => isSecondary ? [] - : data.collapsedLinks?.filter((item) => !!item?.link) || [], - [isSecondary, data.collapsedLinks] + : resolveLocalizedCtas( + data.collapsedLinks, + i18n.language, + streamDocument + ), + [isSecondary, data.collapsedLinks, i18n.language, streamDocument] ); // Derive styles based on display mode and styles props. @@ -276,7 +284,7 @@ const HeaderLinksComponent: PuckComponent = ({ } }, [menuContext, displayMode, isSecondary, validAlwaysCollapsedLinks.length]); - const renderLink = (item: TranslatableCTA, index: number) => ( + const renderLink = (item: ResolvedCTA, index: number) => ( = ({ color={styles?.color} openInNewTab={item.openInNewTab} eventName={`cta.${type.toLowerCase()}.${index}`} - label={resolveComponentData(item.label, i18n.language, streamDocument)} + label={item.label} linkType={item.linkType} - link={resolveComponentData(item.link, i18n.language, streamDocument)} + link={item.link} normalizeLink={ isNonNormalizableLinkType(item.linkType) ? false diff --git a/packages/visual-editor/src/components/header/PrimaryHeaderSlot.tsx b/packages/visual-editor/src/components/header/PrimaryHeaderSlot.tsx index 2ab76ac72b..926429daa2 100644 --- a/packages/visual-editor/src/components/header/PrimaryHeaderSlot.tsx +++ b/packages/visual-editor/src/components/header/PrimaryHeaderSlot.tsx @@ -9,11 +9,11 @@ import { ThemeColor, } from "../../utils/themeConfigOptions.ts"; import { CTAWrapperProps } from "../contentBlocks/CtaWrapper.tsx"; -import { TranslatableCTA } from "../../types/types.ts"; import { ImageWrapperProps } from "../contentBlocks/image/Image.tsx"; import { msg } from "../../utils/i18n/platform.ts"; import { PageSection, PageSectionProps } from "../atoms/pageSection.tsx"; import { resolveComponentData } from "../../utils/resolveComponentData.tsx"; +import { resolveLocalizedCtas } from "../../utils/resolveLocalizedCtas.ts"; import { useOverflow } from "../../hooks/useOverflow.ts"; import { usePreviewWindow } from "../../hooks/usePreviewWindow.ts"; import { getViewport } from "../../hooks/useViewport.ts"; @@ -515,18 +515,28 @@ export const PrimaryHeaderSlot: YextComponentConfig = { !!secondaryCTA?.label && !!secondaryCTA?.link; - const showNavContent: boolean = + const primaryLinksData = data.props.slots.LinksSlot?.[0]?.props.data; + const hasPrimaryLinks = + resolveLocalizedCtas(primaryLinksData?.links, locale, streamDocument) + .length > 0 || + resolveLocalizedCtas( + primaryLinksData?.collapsedLinks, + locale, + streamDocument + ).length > 0; + + const secondaryHeader = data.props.parentValues?.SecondaryHeaderSlot?.[0]; + const secondaryLinks = + secondaryHeader?.props.slots.LinksSlot?.[0]?.props.data.links; + const hasSecondaryLinks = + !!secondaryHeader?.props.data.show && + resolveLocalizedCtas(secondaryLinks, locale, streamDocument).length > 0; + + const showNavContent = showPrimaryCTA || showSecondaryCTA || - !!data.props.slots.LinksSlot?.[0]?.props.data.links?.some( - (l: TranslatableCTA) => l.label && l.link - ) || - !!( - data.props.parentValues?.SecondaryHeaderSlot?.[0]?.props.data.show && - data.props.parentValues?.SecondaryHeaderSlot?.[0]?.props.data.links?.some( - (l: TranslatableCTA) => l.label && l.link - ) - ); + hasPrimaryLinks || + hasSecondaryLinks; return { ...data, diff --git a/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[mobile] version 36 with no nearby locations.png b/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[mobile] version 36 with no nearby locations.png index 212a4f36f4..d50017e4c3 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[mobile] version 36 with no nearby locations.png and b/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[mobile] version 36 with no nearby locations.png differ diff --git a/packages/visual-editor/src/utils/resolveLocalizedCtas.test.ts b/packages/visual-editor/src/utils/resolveLocalizedCtas.test.ts new file mode 100644 index 0000000000..132a40b99e --- /dev/null +++ b/packages/visual-editor/src/utils/resolveLocalizedCtas.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { type TranslatableCTA } from "../types/types.ts"; +import { resolveLocalizedCtas } from "./resolveLocalizedCtas.ts"; + +const createCta = ( + label: TranslatableCTA["label"], + link: TranslatableCTA["link"] +): TranslatableCTA => ({ + linkType: "URL", + label, + link, +}); + +describe("resolveLocalizedCtas", () => { + it.each([ + { + name: "active-locale values", + cta: createCta( + { defaultValue: "Default", fr: "Français" }, + { defaultValue: "/default", fr: "/francais" } + ), + expected: { label: "Français", link: "/francais" }, + }, + { + name: "default values when the active locale is missing", + cta: createCta({ defaultValue: "Default" }, { defaultValue: "/default" }), + expected: { label: "Default", link: "/default" }, + }, + { + name: "legacy string values", + cta: createCta("Legacy", "/legacy"), + expected: { label: "Legacy", link: "/legacy" }, + }, + ])("resolves $name", ({ cta, expected }) => { + expect(resolveLocalizedCtas([cta], "fr")).toMatchObject([expected]); + }); + + it.each([ + { + name: "empty localized label", + cta: createCta( + { defaultValue: "Default", fr: "" }, + { defaultValue: "/default", fr: "/francais" } + ), + }, + { + name: "empty localized link", + cta: createCta( + { defaultValue: "Default", fr: "Français" }, + { defaultValue: "/default", fr: "" } + ), + }, + { + name: "whitespace-only localized values", + cta: createCta( + { defaultValue: "Default", fr: " " }, + { defaultValue: "/default", fr: "\t" } + ), + }, + ])("omits a CTA with an $name", ({ cta }) => { + expect(resolveLocalizedCtas([cta], "fr")).toEqual([]); + }); +}); diff --git a/packages/visual-editor/src/utils/resolveLocalizedCtas.ts b/packages/visual-editor/src/utils/resolveLocalizedCtas.ts new file mode 100644 index 0000000000..003632cbcb --- /dev/null +++ b/packages/visual-editor/src/utils/resolveLocalizedCtas.ts @@ -0,0 +1,25 @@ +import { type TranslatableCTA } from "../types/types.ts"; +import { resolveComponentData } from "./resolveComponentData.tsx"; + +/** + * A {@link TranslatableCTA} whose label and link have been localized by + * {@link resolveLocalizedCtas}. + */ +export type ResolvedCTA = Omit & { + label: string; + link: string; +}; + +export const resolveLocalizedCtas = ( + ctas: TranslatableCTA[] | undefined, + locale: string, + streamDocument?: Record +): ResolvedCTA[] => { + return (ctas ?? []) + .map((cta) => ({ + ...cta, + label: resolveComponentData(cta.label, locale, streamDocument), + link: resolveComponentData(cta.link, locale, streamDocument), + })) + .filter((cta) => cta.label.trim() !== "" && cta.link.trim() !== ""); +};