import { LightningElement, api, track } from "lwc"; import type * as CoveoSDK from "coveo-search-ui"; import { DateTime } from "luxon"; import { track as trackGTM } from "dxUtils/analytics"; import { CONTENT_TYPE_LABELS, CONTENT_TYPE_ICONS } from "dxConstants/contentTypes"; import { getContentTypeColorVariables } from "dxUtils/contentTypes"; import { pollUntil } from "dxUtils/async"; import resultsTemplate from "./resultsTemplate"; // Max height for breadcrumb without wrapping const MAX_BREADCRUMB_HEIGHT = 16; interface CoveoSearch { state: typeof CoveoSDK.state; get: typeof CoveoSDK.get; $$: typeof CoveoSDK.$$; InitializationEvents: typeof CoveoSDK.InitializationEvents; QueryEvents: typeof CoveoSDK.QueryEvents; SearchEndpoint: typeof CoveoSDK.SearchEndpoint; TemplateCache: typeof CoveoSDK.TemplateCache; UnderscoreTemplate: typeof CoveoSDK.UnderscoreTemplate; init: typeof CoveoSDK.init; IQuerySuccessEventArgs: CoveoSDK.IQuerySuccessEventArgs; TemplateHelpers: any; } declare const Coveo: CoveoSearch; function getPaginationState(event: CoveoSDK.IQuerySuccessEventArgs): { numberOfPages: number; currentPage: number; } { const pageSize = event.query.numberOfResults!; const totalResults = event.results.totalCount!; const numberOfPages = Math.ceil(totalResults / pageSize); const currentPage = event.query.firstResult! / pageSize + 1; return { numberOfPages, currentPage }; } const isInternalDomain = (domain: string) => domain === "developer.salesforce.com" || domain === "developer-website-s.herokuapp.com"; const isTrailheadDomain = (domain: string) => domain === "trailhead.salesforce.com" || domain === "dev.trailhead.salesforce.com"; const buildTemplateHelperBadge = (value: keyof typeof CONTENT_TYPE_LABELS) => { const style = getContentTypeColorVariables(value); const label = CONTENT_TYPE_LABELS[value]; const { iconSprite, iconSymbol } = CONTENT_TYPE_ICONS[value]; return `
${label}
`; }; const processParts = (parts: string[], internalFlag = false) => { // filter /docs/ breadcrumb item from internal domains const filterFn = internalFlag ? (part: string) => part !== "docs" : (part: string) => part; return parts.filter(filterFn).map((part) => { // Remove special characters & .htm/.xml extension part = part .replace(/_/g, "") .replace(/-/g, "") .replace(/.html*/g, "") .replace(/.xml/g, "") .replace(/b2c/g, "B2C"); // Capitalize first letter of each word part = part.replace(/\w\S*/g, (w) => { return w.replace(/^\w/, (c) => c.toUpperCase()); }); return `${decodeURI(part)}`; }); }; const buildTemplateHelperUriBreadcrumbs = (value: string) => { const url = new URL(value); // exclude youtube links from breadcrumbs const hostnamePattern = /^((www\.)?(youtube\.com|youtu\.be))$/; // we don't want to show atlas docs because the url structure is mad ugly if (hostnamePattern.test(url.hostname) || url.pathname.includes("atlas.")) { return ""; } let parts = url.pathname.split("/").filter((part) => part !== ""); // Remove language prefix from trailhead URLs if (isTrailheadDomain(url.hostname)) { parts = parts .slice(1) .filter((part) => part !== "content" && part !== "learn"); } const breadcrumbs = processParts(parts, isInternalDomain(url.hostname)); if (!isInternalDomain(url.hostname)) { // Remove the first breadcrumb item if it's an internal domain (i.e drop developer.salesforce.com from developer.salesforce.com / B2C Commerce / Open Commerce API / Filtering) breadcrumbs.unshift( `${url.hostname}` ); return ` ${breadcrumbs.join(" / ")} `; } else if (breadcrumbs.length === 1) { // Hide breadcrumbs if there is only one breadcrumb item return ""; } // remove the last breadcrumb item (the search result title makes it redundant) breadcrumbs.pop(); return `/ ${breadcrumbs.join(" / ")} /`; }; const buildTemplateHelperCommunityBreadcrumbs = () => { return `trailhead.salesforce.com / trailblazer-community / feed / `; }; const buildTemplateHelperBreadcrumbs = (value: string) => { const parts = value.split("/").filter((part) => part !== ""); // Don't show breadcrumbs if there's only one part if (parts.length === 1) { return ""; } const breadcrumbs = processParts(parts); // remove last breadcrumb item breadcrumbs.pop(); return ` / ${breadcrumbs.join(" / ")} / `; }; const buildTemplateHelperMetaBreadcrumbs = (value: string) => { const parts = value.split("/").filter((part) => part !== ""); // Don't show breadcrumbs if there's only one part if (parts.length === 1) { return ""; } const breadcrumbs = processParts(parts); return ` / ${breadcrumbs.join(" / ")} `; }; const buildTemplateHelperPostedDate = (value: string) => { const time = DateTime.fromMillis(Number(value)).toLocaleString( DateTime.DATE_MED ); return `Posted on: ${time} - `; }; const buildTemplateHelperReplies = (value: string) => { const number = Number(value); return `${value} ${number > 1 ? "Replies" : "Reply"}`; }; const buildTemplateHelperLikes = (value: string) => { const number = Number(value); return `${value} ${number > 1 ? "Likes" : "Like"}`; }; // @ts-ignore Dark Magic (TM) we are overriding the 'title' field with a custom getter. We should really stop doing this. export default class SearchResults extends LightningElement { @api coveoOrganizationId!: string; @api coveoPublicAccessToken!: string; @api coveoSearchPipeline!: string; private root: HTMLElement | null = null; private isInitialized = false; @track _query: string = ""; @api get searchQuery() { return this._query; } set searchQuery(q: string) { this._query = q; if (this.isInitialized && this._query !== "") { this.updateSearchQuery(); } } private totalResults: number | null = null; private get title() { return this.totalResults ? this.totalResults.toLocaleString() : "0"; } private query: string = ""; private hasFilters: boolean = false; private get hasQuery(): boolean { return this.query !== ""; } private get coveoAnalyticsEndpoint() { return `https://${this.coveoOrganizationId}.analytics.org.coveo.com/rest/ua`; } private updateSearchQuery() { Coveo.state(this.root!, "q", this._query); } private clearFilters() { const BreadcrumbManager = Coveo.get( this.root!.querySelector(".CoveoBreadcrumb") as HTMLElement ) as any; BreadcrumbManager.clearBreadcrumbs(); } private currentPage: number = 25; private totalPages: number = 1; private originalBreadcrumbs: string[] = []; private initialWindowWidth = window.innerWidth; private goToPage(e: CustomEvent) { const page = e.detail; const Pager = Coveo.get( this.root!.querySelector(".CoveoPager") as HTMLElement ) as any; Pager.setPage(page); this.currentPage = page; } private dismissFiltersOverlay = () => { const overlay = this.root!.querySelector( ".coveo-dropdown-background-active" ) as HTMLElement | null; overlay?.click(); }; private attachListeners(root: HTMLElement) { Coveo.$$(root).on( Coveo.InitializationEvents.afterInitialization, () => { const url = new URL(window.location.href); const searchParams = url.searchParams; const keywordsParam = searchParams.get("keywords"); // Accomodate for the global nav using 'keywords' param instead of 'q' if (keywordsParam) { this._query = searchParams.get("keywords")!; searchParams.delete("keywords"); window.history.replaceState(null, "", url.href); } if (this._query !== "") { Coveo.state(this.root!, "q", this.searchQuery); } if (Coveo.state(this.root!, "q") === "") { Coveo.state(this.root!, "sort", "date descending"); } this.isInitialized = true; } ); Coveo.$$(root).on(Coveo.QueryEvents.querySuccess, (event: any) => { const { currentPage, numberOfPages } = getPaginationState( event.detail ); this.currentPage = currentPage; this.totalPages = numberOfPages; this.totalResults = event.detail.results.totalCount; this.query = event.detail.query.q ?? ""; this.hasFilters = event.detail.query?.facets?.some((f: any) => { return f.currentValues.some((cv: any) => { return cv.state === "selected"; }); }); // Note that this logic means that if someone clicks a search result before // onetrust has loaded, and thus navigates away from the page, we will lose the tracking if (document.readyState === "complete") { this.trackSearchResults(event, this.query, this.totalResults); } else { const query = this.query; const totalResults = this.totalResults; window.addEventListener("load", () => { this.trackSearchResults(event, query, totalResults); }); } }); Coveo.$$(root).on(Coveo.QueryEvents.deferredQuerySuccess, async () => { // wait specified time to ensure breadcrumbs are rendered before processing them await pollUntil( () => { const coveoResults = this.root!.querySelector(".CoveoResult"); return Boolean(coveoResults); }, 20, 1000 ); this.processBreadcrumbs(root); window.onresize = () => this.processBreadcrumbs(root); }); } // check if the breadcrumb is overflowing or not based on the height of a single character in the text element private isTextWrapping = (element: HTMLElement) => { return element.offsetHeight > MAX_BREADCRUMB_HEIGHT; }; private truncateBreadcrumbText = (breadcrumbItems: HTMLElement[]) => { breadcrumbItems.forEach((breadcrumbItem: HTMLElement) => { const breadcrumbItemText = breadcrumbItem.textContent!; if (breadcrumbItemText.length > 30) { breadcrumbItem.textContent = `${breadcrumbItemText.substring( 0, 30 )}...`; } }); }; private addBreadcrumbEllipsis = ( breadcrumbItems: HTMLElement[], breadcrumb: HTMLElement ) => { for (let i = 1; i < breadcrumbItems.length; i++) { if (this.isTextWrapping(breadcrumb)) { // if the previous element is an ellipsis, make it empty (in order to avoid multiple grouped ellipsis) if (breadcrumbItems[i - 1]?.textContent === "...") { breadcrumbItems[i].innerHTML = ""; } else { breadcrumbItems[i].textContent = "..."; } } else { break; // Exit the loop if the breadcrumb is no longer overflowing } } // remove any empty breadcrumb items breadcrumb.innerHTML = breadcrumb.innerHTML .replace( / ?\/ +<\/span> +\/ ?/g, " / " ) // when first loading the page on mobile, the breadcrumb items are not grouped correctly .replace( `... / ...`, `...` ); }; private formatBreadcrumbs = (breadcrumbs: HTMLElement[]) => { breadcrumbs?.forEach((breadcrumb: HTMLElement) => { // Get all breadcrumb items that are separated by '/' const breadcrumbItems: any = breadcrumb.querySelectorAll(".breadcrumb-item"); // Check if the breadcrumb is overflowing by comparing it's height to the height of the first breadcrumb item if (this.isTextWrapping(breadcrumb)) { // it is overflowing, so we need to truncate long titles to 30 characters this.truncateBreadcrumbText(breadcrumbItems); // Iteratively check if the breadcrumb is still overflowing and replace text with '...' starting from the second breadcrumb item this.addBreadcrumbEllipsis(breadcrumbItems, breadcrumb); // After processing all breadcrumb items, if it's still overflowing, hide the breadcrumb element if (this.isTextWrapping(breadcrumb)) { breadcrumb.style.display = "none"; } } }); }; private restoreBreadcrumbs = (breadcrumbs: HTMLElement[]) => { breadcrumbs.forEach((breadcrumb: HTMLElement, index: number) => { // eslint-disable-next-line @lwc/lwc/no-inner-html breadcrumb.innerHTML = this.originalBreadcrumbs[index]; }); }; private windowSizeIncreased = () => window.innerWidth > this.initialWindowWidth; private processBreadcrumbs(root: HTMLElement) { // Get all breadcrumbs from search results const breadcrumbs = Array.from( root.querySelectorAll(".breadcrumb") ) as HTMLElement[]; if (this.originalBreadcrumbs.length === 0) { this.originalBreadcrumbs = breadcrumbs.map( (breadcrumb) => breadcrumb.innerHTML ); } if (this.windowSizeIncreased()) { /* Reset the breadcrumbs to their original state and process them again. The additional space means we can replace ellipsis with full text. */ this.restoreBreadcrumbs(breadcrumbs); } this.formatBreadcrumbs(breadcrumbs); } private initializeCoveo() { this.root = this.template.querySelector(".CoveoSearchInterface")!; const resultsList = this.template.querySelector(".CoveoResultList"); if (resultsList) { // eslint-disable-next-line @lwc/lwc/no-inner-html resultsList.innerHTML = resultsTemplate; } Coveo.SearchEndpoint.configureCloudV2Endpoint( this.coveoOrganizationId, this.coveoPublicAccessToken, `https://${this.coveoOrganizationId}.org.coveo.com/rest/search` ); this.attachListeners(this.root); Coveo.TemplateHelpers.registerTemplateHelper( "badge", buildTemplateHelperBadge ); Coveo.TemplateHelpers.registerTemplateHelper( "breadcrumbs", buildTemplateHelperBreadcrumbs ); Coveo.TemplateHelpers.registerTemplateHelper( "metabreadcrumbs", buildTemplateHelperMetaBreadcrumbs ); Coveo.TemplateHelpers.registerTemplateHelper( "uriBreadcrumbs", buildTemplateHelperUriBreadcrumbs ); Coveo.TemplateHelpers.registerTemplateHelper( "communityBreadcrumbs", buildTemplateHelperCommunityBreadcrumbs ); Coveo.TemplateHelpers.registerTemplateHelper( "postedDate", buildTemplateHelperPostedDate ); Coveo.TemplateHelpers.registerTemplateHelper( "replies", buildTemplateHelperReplies ); Coveo.TemplateHelpers.registerTemplateHelper( "likes", buildTemplateHelperLikes ); Coveo.init(this.root); } renderedCallback() { if (!this.isInitialized) { if (Object.prototype.hasOwnProperty.call(window, "Coveo")) { this.initializeCoveo(); } else { // eslint-disable-next-line @lwc/lwc/no-document-query const script = document.querySelector("script.coveo-script"); script?.addEventListener("load", () => { this.initializeCoveo(); }); } } } private trackSearchResults(event: Event, term: string, resultCount: any) { trackGTM(event.target!, "custEv_search", { search_term: term, search_category: "", search_type: "site search", search_result_count: resultCount }); } }