diff --git a/packages/visual-editor/src/components/Locator.tsx b/packages/visual-editor/src/components/Locator.tsx index 9651b7623..614dd1d29 100644 --- a/packages/visual-editor/src/components/Locator.tsx +++ b/packages/visual-editor/src/components/Locator.tsx @@ -66,7 +66,12 @@ import { createSearchHeadlessConfig, } from "../utils/searchHeadlessConfig.ts"; import { BackgroundStyle } from "../utils/themeConfigOptions.ts"; -import { StreamDocument } from "../utils/types/StreamDocument.ts"; +import { + StreamDocument, + LocatorConfig, + EntityTypeScope, + LocatorSourcePageSetInfo, +} from "../utils/types/StreamDocument.ts"; import { getValueFromQueryString } from "../utils/urlQueryString.tsx"; import { Body } from "./atoms/body.tsx"; import { Heading } from "./atoms/heading.tsx"; @@ -102,22 +107,101 @@ const translateDistanceUnit = ( return t("kilometer", { count, defaultValue: "kilometer" }); }; -const getEntityType = (entityTypeEnvVar?: string) => { +/** + * Retrieves the first applicable entity type for entities indexed by the locator. Reads from: + * 1. If _pageset doesn't exist, the Puck metadata's entityTypeEnvVar + * 2. The typeConfig.locatorConfig.entityType field of _pageset in the stream document + * 3. The first element of __.locatorSourcePageSets in the stream document + * + * Used by the FilterSearch component, since including multiple entity types will result in + * duplicate results for builtin.location. + */ +const getAnyEntityType = (entityTypeEnvVar?: string) => { const entityDocument: StreamDocument = useDocument(); if (!entityDocument._pageset && entityTypeEnvVar) { return entityDocument._env?.[entityTypeEnvVar] || DEFAULT_ENTITY_TYPE; } + const pageSet = parseJsonObject(entityDocument?._pageset); + const locatorConfig: LocatorConfig = pageSet?.typeConfig?.locatorConfig; + const entityType = locatorConfig?.entityType; + if (entityType) { + return entityType; + } + + // there should always be at least one source page set + const locatorSourcePageSets = getLocatorSourcePageSets(); + return locatorSourcePageSets?.[0]?.entityType || DEFAULT_ENTITY_TYPE; +}; + +/** + * Returns a combined list of (entityTypeId, savedFilterId) pairs from the locator source entity + * page sets in __.locatorSourcePageSets and the independent entity type scopes in + * typeConfig.locatorConfig.entityTypeScopes of the _pageset field. + */ +const getAllEntityTypeScopes = () => { + const streamDocument: StreamDocument = useDocument(); + const pageSet = parseJsonObject(streamDocument?._pageset); + const locatorConfig: LocatorConfig = pageSet?.typeConfig?.locatorConfig; + + // legacy support for single page set locators + if (locatorConfig?.entityType) { + return [ + { + entityType: locatorConfig.entityType, + savedFilter: locatorConfig?.savedFilter, + }, + ]; + } + + const locatorSourcePageSets = getLocatorSourcePageSets(); + const locatorSourcePageSetScopes = locatorSourcePageSets.map( + (v) => + ({ + entityType: v.entityType, + savedFilter: v.savedFilter, + }) as EntityTypeScope + ); + const independentConfigScopes = locatorConfig?.entityTypeScopes || []; + return [...locatorSourcePageSetScopes, ...independentConfigScopes]; +}; + +/** Retrieves the parsed JSON object from a string; returns an empty object if parsing fails. */ +const parseJsonObject = (jsonString: string | undefined) => { + if (!jsonString) { + return {}; + } try { - const entityType = JSON.parse(entityDocument._pageset).typeConfig - .locatorConfig.entityType; - return entityType || DEFAULT_ENTITY_TYPE; + return JSON.parse(jsonString); } catch { - return DEFAULT_ENTITY_TYPE; + return {}; } }; -function getFacetFieldOptions(entityType: string): DynamicOption[] { +const getLocatorSourcePageSets = () => { + const doc: StreamDocument = useDocument(); + const locatorSourcePageSets: Record = + parseJsonObject(doc?.__?.locatorSourcePageSets); + return Object.values(locatorSourcePageSets); +}; + +function getFacetFieldOptions(entityTypes: string[]): DynamicOption[] { + const facetFields: DynamicOption[] = []; + const addedValues: Set = new Set(); + entityTypes.forEach((entityType) => + getFacetFieldOptionsForEntityType(entityType).forEach((option) => { + if (option?.value && !addedValues.has(option.value)) { + facetFields.push(option); + addedValues.add(option.value); + } + }) + ); + return facetFields.sort((a, b) => a.label.localeCompare(b.label)); +} + +function getFacetFieldOptionsForEntityType( + entityType: string +): DynamicOption[] { let filterOptions: DynamicOption[] = []; switch (entityType) { case "location": @@ -435,7 +519,7 @@ function getFacetFieldOptions(entityType: string): DynamicOption[] { default: filterOptions = []; } - return filterOptions.sort((a, b) => a.label.localeCompare(b.label)); + return filterOptions; } export interface LocatorProps { @@ -551,8 +635,11 @@ const locatorFields: Fields = { type: "dynamicSelect", dropdownLabel: msg("fields.field", "Field"), getOptions: () => { - const entityType = getEntityType(); - return getFacetFieldOptions(entityType); + const entityTypeScopes = getAllEntityTypeScopes(); + const entityTypes = entityTypeScopes + .map((scope) => scope?.entityType) + .filter((s) => s !== undefined); + return getFacetFieldOptions(entityTypes); }, placeholderOptionLabel: msg( "fields.options.selectAField", @@ -735,7 +822,7 @@ const LocatorInternal = ({ const { t, i18n } = useTranslation(); const preferredUnit = getPreferredDistanceUnit(i18n.language); - const entityType = getEntityType(puck.metadata?.entityTypeEnvVar); + const entityType = getAnyEntityType(puck.metadata?.entityTypeEnvVar); const streamDocument = useDocument(); const resultCount = useSearchState( (state) => state.vertical.resultsCount || 0 @@ -1011,7 +1098,7 @@ const LocatorInternal = ({ .executeFilterSearch(queryParam, false, [ { fieldApiName: LOCATION_FIELD, - entityType: entityType, + entityType: entityType, // only use a single entity type to avoid duplicate results fetchEntities: false, }, ]) @@ -1256,6 +1343,7 @@ const LocatorInternal = ({ handleFilterSelect(params)} diff --git a/packages/visual-editor/src/components/LocatorResultCard.tsx b/packages/visual-editor/src/components/LocatorResultCard.tsx index 3f49e6848..fbfd03a01 100644 --- a/packages/visual-editor/src/components/LocatorResultCard.tsx +++ b/packages/visual-editor/src/components/LocatorResultCard.tsx @@ -20,7 +20,7 @@ import { msg, pt } from "../utils/i18n/platform.ts"; import { PhoneAtom } from "./atoms/phone.tsx"; import { useTemplateProps } from "../hooks/useDocument.tsx"; import { resolveComponentData } from "../utils/resolveComponentData.tsx"; -import { resolveUrlTemplateOfChild } from "../utils/urls/resolveUrlTemplate.ts"; +import { resolveUrlFromSourcePageSets } from "../utils/urls/resolveUrlFromSourcePageSets.ts"; import { HoursStatusAtom } from "./atoms/hoursStatus.tsx"; import { HoursTableAtom } from "./atoms/hoursTable.tsx"; import { YextField } from "../editor/YextField.tsx"; @@ -760,7 +760,7 @@ export const LocatorResultCard = React.memo( "TAP_TO_CALL" ); - const resolvedUrl = resolveUrlTemplateOfChild( + const resolvedUrl = resolveUrlFromSourcePageSets( location, streamDocument, relativePrefixToRoot @@ -899,7 +899,7 @@ export const LocatorResultCard = React.memo( )}
- {props.primaryCTA.liveVisibility && ( + {props.primaryCTA.liveVisibility && resolvedUrl && ( , + overrides?: Partial +): StreamDocument => ({ + ...baseStreamDocument, + ...overrides, + __: { + ...baseStreamDocument.__, + ...overrides?.__, + locatorSourcePageSets: JSON.stringify(sourceEntityPageSets), + }, +}); + +describe("resolveUrlFromEntityTypeScopes", () => { + it("returns undefined when entity type scopes are missing", () => { + expect( + resolveUrlFromSourcePageSets( + { type: "ce_location" }, + baseStreamDocument, + "" + ) + ).toBeUndefined(); + }); + + it("returns undefined when entity type is missing from the profile", () => { + const streamDocument = createStreamDocument({ + ignored: { + entityType: "ce_location", + pathInfo: { + template: + "stores/[[address.region]]/[[address.city]]/[[address.line1]]/[[id]]", + }, + }, + }); + + expect( + resolveUrlFromSourcePageSets({}, streamDocument, "") + ).toBeUndefined(); + }); + + it("resolves URL when entity type matches a scope without saved filter", () => { + const streamDocument = createStreamDocument({ + ignored: { + entityType: "ce_location", + pathInfo: { + template: + "stores/[[address.region]]/[[address.city]]/[[address.line1]]/[[id]]", + }, + }, + }); + + expect( + resolveUrlFromSourcePageSets( + { + type: "ce_location", + savedFilters: ["1111"], + id: "2222", + address: { + region: "VA", + city: "Arlington", + line1: "1101 Wilson Blvd", + }, + }, + streamDocument, + "" + ) + ).toBe("stores/va/arlington/1101-wilson-blvd/2222"); + }); + + it("resolves URL when entity matches required saved filter", () => { + const streamDocument = createStreamDocument({ + ignored: { + entityType: "ce_location", + internalSavedFilterId: 1111, + pathInfo: { + template: + "stores/[[address.region]]/[[address.city]]/[[address.line1]]/[[id]]", + primaryLocale: "en", + }, + }, + }); + + expect( + resolveUrlFromSourcePageSets( + { + type: "ce_location", + savedFilters: ["1111"], + id: "2222", + address: { + region: "VA", + city: "Arlington", + line1: "1101 Wilson Blvd", + }, + }, + streamDocument, + "" + ) + ).toBe("stores/va/arlington/1101-wilson-blvd/2222"); + }); + + it("returns undefined when saved filter does not match", () => { + const streamDocument = createStreamDocument({ + ignored: { + entityType: "ce_location", + internalSavedFilterId: 1111, + pathInfo: { + template: + "stores/[[address.region]]/[[address.city]]/[[address.line1]]/[[id]]", + primaryLocale: "en", + }, + }, + }); + + expect( + resolveUrlFromSourcePageSets( + { type: "ce_location", savedFilters: ["2222"] }, + streamDocument, + "" + ) + ).toBeUndefined(); + }); + + it("returns undefined when the matching scope has no URL template", () => { + const streamDocument = createStreamDocument({ + ignored: { + entityType: "ce_location", + pathInfo: {}, + }, + }); + + expect( + resolveUrlFromSourcePageSets({ type: "ce_location" }, streamDocument, "") + ).toBeUndefined(); + }); + + it("prepends relative prefix and locale for non-primary locale", () => { + const streamDocument = createStreamDocument( + { + ignored: { + entityType: "ce_location", + pathInfo: { + template: + "stores/[[address.region]]/[[address.city]]/[[address.line1]]/[[id]]", + }, + }, + }, + { + locale: "es", + __: { + isPrimaryLocale: false, + }, + } + ); + + expect( + resolveUrlFromSourcePageSets( + { + type: "ce_location", + id: "1111", + address: { + region: "VA", + city: "Arlington", + line1: "1101 Wilson Blvd", + }, + }, + streamDocument, + "../" + ) + ).toBe("../es/stores/va/arlington/1101-wilson-blvd/1111"); + }); + + it("selects the matching entity type when multiple scopes are present", () => { + const streamDocument = createStreamDocument({ + ignored: { + entityType: "ce_atm", + pathInfo: { + template: + "atms/[[address.region]]/[[address.city]]/[[address.line1]]/[[id]]", + }, + }, + ignored2: { + entityType: "ce_location", + pathInfo: { + template: + "stores/[[address.region]]/[[address.city]]/[[address.line1]]/[[id]]", + }, + }, + }); + + expect( + resolveUrlFromSourcePageSets( + { + type: "ce_location", + id: "1111", + address: { + region: "VA", + city: "Arlington", + line1: "1101 Wilson Blvd", + }, + }, + streamDocument, + "" + ) + ).toBe("stores/va/arlington/1101-wilson-blvd/1111"); + }); + + it("skips scopes with unmatched saved filters and resolves from a later match", () => { + const streamDocument = createStreamDocument({ + ignored: { + entityType: "ce_location", + internalSavedFilterId: 1111, + pathInfo: { + template: "first/[[id]]", + }, + }, + ignored2: { + entityType: "ce_location", + internalSavedFilterId: 2222, + pathInfo: { + template: "second/[[id]]", + }, + }, + }); + + expect( + resolveUrlFromSourcePageSets( + { + type: "ce_location", + savedFilters: ["2222"], + id: "3333", + }, + streamDocument, + "" + ) + ).toBe("second/3333"); + }); +}); diff --git a/packages/visual-editor/src/utils/urls/resolveUrlFromSourcePageSets.ts b/packages/visual-editor/src/utils/urls/resolveUrlFromSourcePageSets.ts new file mode 100644 index 000000000..21d72ac5b --- /dev/null +++ b/packages/visual-editor/src/utils/urls/resolveUrlFromSourcePageSets.ts @@ -0,0 +1,85 @@ +import { + LocatorSourcePageSetInfo, + StreamDocument, +} from "../types/StreamDocument.ts"; +import { resolveUrlFromPathInfo } from "./resolveUrlFromPathInfo.ts"; +import { mergeMeta } from "./resolveUrlTemplate.ts"; + +/** + * Resolves the URL for an entity in the search API response using the URL template of the + * source page set that contains the entity. Source page sets are read from the + * __.locatorSourcePageSets field of the stream document. + */ +export function resolveUrlFromSourcePageSets( + profile: any, + streamDocument: StreamDocument, + relativePrefixToRoot: string = "" +) { + const sourcePageSetsString = streamDocument?.__?.locatorSourcePageSets; + if (!sourcePageSetsString) { + return; + } + let sourcePageSets: LocatorSourcePageSetInfo[]; + try { + sourcePageSets = Object.values(JSON.parse(sourcePageSetsString)); + } catch (error) { + console.error("Failed to parse locatorSourcePageSets:", error); + return; + } + + const entityTypeApiName = profile?.type; + if (!sourcePageSets || sourcePageSets.length === 0 || !entityTypeApiName) { + return; + } + // contains the internal saved search IDs that apply to the entity; if no saved search IDs + // apply, the property is not included in the search response + const savedFiltersForEntity: string[] = profile?.savedFilters ?? []; + + console.log( + "sourcePageSets", + sourcePageSets, + "savedFiltersForEntity", + savedFiltersForEntity + ); + const sourceEntityPageSet = sourcePageSets.find( + (pageSetInfo: LocatorSourcePageSetInfo) => + pageSetIncludesEntity( + savedFiltersForEntity, + entityTypeApiName, + pageSetInfo + ) + ); + if (!sourceEntityPageSet || !sourceEntityPageSet?.pathInfo) { + return; + } + + const docWithPathInfo = { + ...streamDocument, + __: { + ...streamDocument.__, + pathInfo: sourceEntityPageSet.pathInfo, + }, + }; + return resolveUrlFromPathInfo( + mergeMeta(profile, docWithPathInfo), + relativePrefixToRoot, + false + ); +} + +/** Returns true if the entity type scope includes the entity. */ +const pageSetIncludesEntity = ( + savedFiltersForEntity: string[], + entityTypeApiName: string, + pageSetInfo: LocatorSourcePageSetInfo +): boolean => { + return ( + pageSetInfo?.entityType === entityTypeApiName && + // savedFilter is not present => scope includes all entities of this type + // savedFilter is present => entity's savedFilterIds must contain the scope's savedFilter + (!pageSetInfo?.internalSavedFilterId || + savedFiltersForEntity.includes( + pageSetInfo.internalSavedFilterId.toString() + )) + ); +};