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
});
}
}