import { LightningElement, api } from "lwc"; import debounce from "debounce"; import { Result, ResultList, Unsubscribe, ResultsPerPage, SearchBox, buildInteractiveResult, buildResultList, buildResultsPerPage, buildSearchBox, loadAdvancedSearchQueryActions, SearchEngine, SearchAppState, loadQueryActions } from "@coveo/headless"; import { buildSearchEngine, getSidebarSearchParams } from "dxUtils/coveo"; import { RecentSearches } from "dxUtils/recentSearches"; import { toJson } from "dxUtils/normalizers"; import { Option, PopoverRequestCloseType, SidebarSearchResult } from "typings/custom"; const RESULT_CLICK_LOCAL_STORAGE_KEY = "dx-sidebar-search-result-click"; export const SESSION_KEY = "dx-sidebar-search-state"; const DEFAULT_RESULT_COUNT = 20; const SEARCH_DEBOUNCE_DELAY = 1200; const UserRecentSearches = new RecentSearches(); const normalizeCoveoAdvancedQueryValue = (version: string): string => version?.split(".").join("\\."); export default class SidebarSearch extends LightningElement { @api coveoOrganizationId!: string; @api coveoPublicAccessToken!: string; @api coveoSearchHub!: string; @api get coveoAdvancedQueryConfig(): { [key: string]: any } { return this._coveoAdvancedQueryConfig; } set coveoAdvancedQueryConfig(value) { const jsonValue = toJson(value); const hasChanged = this._coveoAdvancedQueryConfig && Object.entries(jsonValue).some( ([key, val]: [string, any]) => this._coveoAdvancedQueryConfig[key] !== val ); this._coveoAdvancedQueryConfig = jsonValue; if (hasChanged && this.engine && this.value !== "") { this.dispatchOnLoading(true); } if (hasChanged && this.engine) { // set advanced filters needs access to the latest coveoAdvancedQueryConfig this.setAdvancedFilters(this.engine); this.submitSearch(true); } } @api public fetchMoreResults() { if (this.resultList?.state?.moreResultsAvailable) { this.fetchingMoreResults = true; this.resultList.fetchMoreResults(); } } @api setInputValue(searchTerm: string) { if (searchTerm !== this.value) { this.value = searchTerm || ""; this.handleValueChange(true); } } @api requestOpenDropdown(value: boolean) { this.dropdownRequestedOpen = value; } private _coveoAdvancedQueryConfig!: { [key: string]: any }; private dropdownRequestedOpen: boolean = false; private recentSearches: Option[] = []; private unsubscribeFromResultList: Unsubscribe = () => {}; private resultList: ResultList | null = null; private resultsPerPage: ResultsPerPage | null = null; private preloadedState?: SearchAppState; private searchBox: SearchBox | null = null; private value?: string = ""; private engine: SearchEngine | null = null; private fetchingMoreResults: boolean = false; private didRender = false; private get hasCorrectCoveoConfig(): boolean { const coveoConfigurations = [ this.coveoOrganizationId, this.coveoPublicAccessToken, this.coveoSearchHub, this.coveoAdvancedQueryConfig ]; return coveoConfigurations.every( (val) => !!val && (typeof val === "string" || typeof val === "object") ); } private get isDropdownOpen() { return ( this.dropdownRequestedOpen && this.recentSearches && this.recentSearches.length > 0 && !this.value ); } constructor() { super(); this.updateRecentSearches(); this.value = getSidebarSearchParams() || ""; } disconnectedCallback() { this.unsubscribeFromResultList(); } renderedCallback() { // in prod some of the critical coveo configuration attributes seem to arrive later // so I needed to put this coveo startup flow here where we can wait for them to arrive if (this.hasCorrectCoveoConfig && !this.engine) { this.initializeUserSession(); this.initializeCoveo(); } else if (!this.hasCorrectCoveoConfig) { console.error( "Incorrect coveo search configuration provided. All coveo configuration attributes must be defined.", { coveoOrganizationId: this.coveoOrganizationId, coveoPublicAccessToken: this.coveoPublicAccessToken, coveoSearchHub: this.coveoSearchHub, coveoAdvancedQueryConfig: this.coveoAdvancedQueryConfig } ); } // If this is the first render and there is a search value, trigger // term highlighting if (!this.didRender && this.value) { this.dispatchHighlightedTermChange(); } this.didRender = true; } private initializeUserSession() { const stringified = window.sessionStorage.getItem(SESSION_KEY); if (!stringified) { return; } try { const json = JSON.parse(stringified); const hasSameConfig = Object.entries( json.coveoAdvancedQueryConfig ).every( ([key, value]: [string, any]) => this.coveoAdvancedQueryConfig[key] === value ); const hasSameQuery = json?.state?.query?.q === this.value; if (hasSameConfig && hasSameQuery) { this.preloadedState = json?.state; } else { window.sessionStorage.removeItem(SESSION_KEY); } } catch (e) { console.error(e); window.sessionStorage.removeItem(SESSION_KEY); } } private initializeCoveo = () => { try { this.engine = buildSearchEngine({ organizationId: this.coveoOrganizationId, publicAccessToken: this.coveoPublicAccessToken, searchHub: this.coveoSearchHub, preloadedState: this.preloadedState }); } catch (ex) { console.error(`Coveo engine failed to initialize (${ex})`); } if (!this.engine) { console.error("Coveo engine failed to initialize!"); return; } if (this.preloadedState) { const actions = loadQueryActions(this.engine); this.engine.dispatch(actions.updateQuery({ q: this.value || "" })); } this.resultList = buildResultList(this.engine, { options: { fieldsToInclude: ["sfhtml_url__c"] } }); this.resultsPerPage = buildResultsPerPage(this.engine, { initialState: { numberOfResults: DEFAULT_RESULT_COUNT } }); this.searchBox = buildSearchBox(this.engine, { options: { numberOfSuggestions: 3 } }); this.setAdvancedFilters(this.engine); this.unsubscribeFromResultList = this.resultList?.subscribe( this.onResultListChange ); if (this.value) { this.dispatchHighlightedTermChange(); } if (!this.preloadedState) { if (this.value) { this.submitSearch(); } else { this.dispatchOnLoading(false); } } this.syncAnalytics(); }; private syncAnalytics = () => { const storedResult = window.localStorage.getItem( RESULT_CLICK_LOCAL_STORAGE_KEY ); if (storedResult) { const interactiveResult = buildInteractiveResult(this.engine!, { options: { result: JSON.parse(storedResult) } }); interactiveResult.cancelPendingSelect(); interactiveResult.select(); window.localStorage.removeItem(RESULT_CLICK_LOCAL_STORAGE_KEY); } }; private onResultListChange = () => { if (!this.resultList) { return; } const { isLoading, firstSearchExecuted, results } = this.resultList.state; if ((!firstSearchExecuted && !isLoading) || !this.value) { // coveo search is firing off some unwanted payloads on startup return; } if (!isLoading) { this.dispatchOnChangeEvent(results); } if (!this.fetchingMoreResults) { this.dispatchOnLoading(isLoading); } if (!isLoading && this.fetchingMoreResults) { this.fetchingMoreResults = false; } }; private setAdvancedFilters(engine: SearchEngine) { const aq = Object.entries(this.coveoAdvancedQueryConfig).reduce( (result, [key, value]) => `${result}(@${key}=="${normalizeCoveoAdvancedQueryValue( value )}")`, "" ); const registerSearchQueryAction = loadAdvancedSearchQueryActions( engine ).registerAdvancedSearchQueries({ aq: aq }); engine.dispatch(registerSearchQueryAction); } private normalizeCoveoResult = (result: Result): SidebarSearchResult => { const { clickUri, excerpt, excerptHighlights, raw: { sfhtml_url__c }, title, titleHighlights, uniqueId } = result; // discussion about this normalization here: https://salesforce-internal.slack.com/archives/C020S6784JX/p1639506238376600 let pathname, queryParam; if (sfhtml_url__c) { pathname = `/docs/${sfhtml_url__c}`; } else { const isNestedGuide = clickUri.includes("/guide/"); const url = new URL(clickUri); const extension = isNestedGuide && !url.pathname?.endsWith(".html") ? ".html" : ""; pathname = `${url.pathname}${extension}`; queryParam = url.search; } let href; let isSelected = false; const isReferenceUrl = clickUri.includes("/references/"); if (isReferenceUrl) { if (queryParam) { href = `${pathname}${queryParam}&q=${this.value}`; } else { href = `${pathname}?q=${this.value}`; } //NOTE: This is specific to references related comparison const resultHrefWithMetaQuery = `${pathname}${queryParam}`; const urlParams = new URLSearchParams(window.location.search); const metaQueryParam = urlParams.get("meta"); let currentUrlPathWithQuery = window.location.pathname; if (metaQueryParam) { currentUrlPathWithQuery = `${currentUrlPathWithQuery}?meta=${metaQueryParam}`; } isSelected = resultHrefWithMetaQuery === currentUrlPathWithQuery; } else { href = `${pathname}?q=${this.value}`; isSelected = pathname === window.location.pathname; } return { title, titleHighlights, excerpt, excerptHighlights, uniqueId, href, selected: isSelected, select: () => window.localStorage.setItem( RESULT_CLICK_LOCAL_STORAGE_KEY, JSON.stringify(result) ) }; }; private updateSessionStorage() { window.sessionStorage.setItem( SESSION_KEY, JSON.stringify({ coveoAdvancedQueryConfig: this.coveoAdvancedQueryConfig, state: this.engine?.state }) ); } private dispatchOnChangeEvent(results: Result[]) { this.updateSessionStorage(); this.dispatchEvent( new CustomEvent("change", { detail: { results: results.map(this.normalizeCoveoResult), value: this.value } }) ); } private dispatchOnLoading(loading: boolean) { this.dispatchEvent(new CustomEvent("loading", { detail: loading })); } private dispatchHighlightedTermChange() { this.dispatchEvent( new CustomEvent("highlightedtermchange", { detail: this.value, composed: true, bubbles: true }) ); } private updateRecentSearches = () => (this.recentSearches = UserRecentSearches.get().map((item: string) => ({ id: item, label: item }))); private dispatchSidebarSearchChange(searchTerm: string | undefined): void { this.dispatchEvent( new CustomEvent("sidebarsearchchange", { detail: searchTerm ?? "", bubbles: true, composed: true }) ); } private submitSearch = debounce((isSyncingSearchValue = false) => { if (this.searchBox) { UserRecentSearches.add(this.value); this.updateRecentSearches(); // When `isSyncingSearchValue` is true, we are submitting in a case // where the search box is already synced correctly. if (!isSyncingSearchValue) { this.dispatchSidebarSearchChange(this.value); } this.searchBox.updateText(this.value || ""); this.searchBox.submit(); } }, SEARCH_DEBOUNCE_DELAY); private onClickRecentSearch(e: CustomEvent) { this.value = e.detail; this.dispatchSidebarSearchChange(this.value); this.submitSearch(true); } private onDropdownRequestClose(e: CustomEvent) { switch (e.detail as PopoverRequestCloseType) { case "interact-outside": case "keypress-escape": this.dropdownRequestedOpen = false; break; default: break; } } private onInputChange(e: CustomEvent) { this.value = e.detail; this.handleValueChange(false); this.dispatchHighlightedTermChange(); } private onInputFocus() { this.dropdownRequestedOpen = true; } private onInputClear() { this.dropdownRequestedOpen = false; } private handleValueChange(isSyncingSearchValue = false) { if (this.value) { this.dispatchOnLoading(true); if (this.searchBox) { this.submitSearch(isSyncingSearchValue); } } else { // coveo's reaction to an empty value triggers a return of results // bootlegging our own ux here` this.dispatchOnLoading(false); this.dispatchOnChangeEvent([]); // Empty search values are not submitted for search, so we need to manually // trigger a search change on our own here this.dispatchSidebarSearchChange(this.value); } } }