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() !== "");
+};