UNPKG

@yext/search-headless

Version:

A library for powering UI components for Yext Search integrations

638 lines (590 loc) 22.6 kB
import { SearchCore, QueryTrigger, QuerySource, QuestionSubmissionRequest, AutocompleteResponse, UniversalSearchResponse, QuestionSubmissionResponse, VerticalResults, FacetOption, DisplayableFacet, SortBy, Context, LatLong, SearchParameterField, FilterSearchResponse, UniversalLimit, VerticalSearchResponse, AdditionalHttpHeaders, VerticalSearchRequest, UniversalSearchRequest, GenerativeDirectAnswerResponse } from '@yext/search-core'; import StateListener from './models/state-listener'; import { State } from './models/state'; import StateManager from './models/state-manager'; import { Unsubscribe } from '@reduxjs/toolkit'; import HttpManager from './http-manager'; import * as searchUtilities from './search-utilities'; import { SelectableStaticFilter } from './models/utils/selectableStaticFilter'; import { transformFiltersToCoreFormat } from './utils/transform-filters'; import { SearchTypeEnum } from './models/utils/searchType'; import { initialState as initialVerticalState } from './slices/vertical'; import { initialState as initialUniversalState } from './slices/universal'; import { initialState as initialFiltersState } from './slices/filters'; import { initialState as initialDirectAnswerState } from './slices/directanswer'; import { initialState as initialQueryRulesState } from './slices/queryrules'; import { initialState as initialSearchStatusState } from './slices/searchstatus'; import { initialState as initialGenerativeDirectAnswerState } from './slices/generativedirectanswer'; import { isVerticalResults } from './models/slices/vertical'; import { HeadlessConfig } from './index'; /** * Provides the functionality for interacting with a Search experience. * * @public */ export default class SearchHeadless { /** * Common utility functions for manipulating Search-related data. */ public readonly utilities = searchUtilities; constructor( private config: HeadlessConfig, private core: SearchCore, private stateManager: StateManager, private httpManager: HttpManager, private additionalHttpHeaders?: AdditionalHttpHeaders ) { this.stateManager.dispatchEvent('meta/setExperienceKey', config.experienceKey); this.stateManager.dispatchEvent('meta/setLocale', config.locale); } /** * Sets {@link QueryState.isPagination} to the specified input. * * @param input - The input to set */ setIsPagination(input: boolean): void { this.stateManager.dispatchEvent('query/setIsPagination', input); } /** * Sets {@link QueryState.input} to the specified input. * * @param input - The input to set */ setQuery(input: string): void { this.stateManager.dispatchEvent('query/setInput', input); } /** * Sets {@link QueryState.queryTrigger} to the specified trigger. * * @param trigger - The query trigger to set */ setQueryTrigger(trigger: QueryTrigger): void { this.stateManager.dispatchEvent('query/setTrigger', trigger); } /** * Sets {@link QueryState.querySource} to the specified source. * * @param source - The query source to set */ setQuerySource(source: QuerySource): void { this.stateManager.dispatchEvent('query/setSource', source); } /** * Sets up Headless to manage the vertical indicated by the verticalKey. * * @param verticalKey - The vertical key to set */ setVertical(verticalKey: string): void { this._resetSearcherStates(); this.stateManager.dispatchEvent('vertical/setVerticalKey', verticalKey); this.stateManager.dispatchEvent('meta/setSearchType', SearchTypeEnum.Vertical); } /** * Sets up Headless to manage universal searches. */ setUniversal(): void { this._resetSearcherStates(); this.stateManager.dispatchEvent('vertical/setVerticalKey', undefined); this.stateManager.dispatchEvent('meta/setSearchType', SearchTypeEnum.Universal); } /** * Resets the direct answer, filters, query rules, search status, vertical, universal, * and generative direct answer states to their initial values. */ private _resetSearcherStates() { this.stateManager.dispatchEvent('set-state', { ...this.state, directAnswer: initialDirectAnswerState, filters: initialFiltersState, queryRules: initialQueryRulesState, searchStatus: initialSearchStatusState, vertical: initialVerticalState, universal: initialUniversalState, generativeDirectAnswer: initialGenerativeDirectAnswerState }); } /** * Sets {@link VerticalSearchState.limit} to the specified limit. * * @param limit - The vertical limit to set */ setVerticalLimit(limit: number): void { this.stateManager.dispatchEvent('vertical/setLimit', limit); } /** * Sets {@link UniversalSearchState.limit} to the specified limit. * * @param limit - The universal limit to set */ setUniversalLimit(limit: UniversalLimit): void { this.stateManager.dispatchEvent('universal/setLimit', limit); } /** * Sets {@link VerticalSearchState.offset} to the specified offset. * * @param offset - The vertical offset to set */ setOffset(offset: number): void { this.stateManager.dispatchEvent('vertical/setOffset', offset); } /** * Sets {@link FiltersState."static"} to the specified filters. * * @param filters - The static filters to set */ setStaticFilters(filters: SelectableStaticFilter[]): void { this.stateManager.dispatchEvent('filters/setStatic', filters); } /** * Sets {@link FiltersState.facets} to the specified facets. * * @param facets - The facets to set */ setFacets(facets: DisplayableFacet[]): void { this.stateManager.dispatchEvent('filters/setFacets', facets); } /** * Unselects all {@link FiltersState.facets | facets}. */ resetFacets(): void { this.stateManager.dispatchEvent('filters/resetFacets'); } /** * Sets {@link SpellCheckState.enabled} to the specified boolean value. * * @param enabled - Whether or not spellcheck should be set to enabled */ setSpellCheckEnabled(enabled: boolean): void { this.stateManager.dispatchEvent('spellCheck/setEnabled', enabled); } /** * Sets {@link SessionTrackingState.enabled} to the specified boolean value. * * @param enabled - Whether or not session tracking should be set to enabled */ setSessionTrackingEnabled(enabled: boolean): void { this.stateManager.dispatchEvent('sessionTracking/setEnabled', enabled); } /** * Sets {@link SessionTrackingState.sessionId} to the specified ID. * * @param sessionId - The session ID to set */ setSessionId(sessionId: string): void { this.stateManager.dispatchEvent('sessionTracking/setSessionId', sessionId); } /** * Sets the alternativeVerticals for {@link VerticalSearchState.noResults} to the * specified verticals. * * @param alternativeVerticals - The alternative verticals to set */ setAlternativeVerticals(alternativeVerticals: VerticalResults[]): void { this.stateManager.dispatchEvent('vertical/setAlternativeVerticals', alternativeVerticals); } /** * Sets {@link VerticalSearchState.sortBys} to the specified sortBys. * * @param sortBys - The sortBys to set */ setSortBys(sortBys: SortBy[]): void { this.stateManager.dispatchEvent('vertical/setSortBys', sortBys); } /** * Sets {@link MetaState.context} to the specified context. * * @param context - The context to set */ setContext(context: Context): void { this.stateManager.dispatchEvent('meta/setContext', context); } /** * Sets {@link MetaState.referrerPageUrl} to the specified URL. * * @param referrerPageUrl - The referring page URL to set */ setReferrerPageUrl(referrerPageUrl: string): void { this.stateManager.dispatchEvent('meta/setReferrerPageUrl', referrerPageUrl); } /** * Sets {@link LocationState.userLocation} to the specified latitude and * longitude. * * @param latLong - The user location to set */ setUserLocation(latLong: LatLong): void { this.stateManager.dispatchEvent('location/setUserLocation', latLong); } /** * Sets the {@link State} to the specified state. * * @param state - The state to set */ setState(state: State): void { this.stateManager.dispatchEvent('set-state', state); } /** * Sets {@link UniversalSearchState.restrictVerticals} to the specified vertical * keys. * * @param restrictVerticals - The new verticals to restrict a universal search */ setRestrictVerticals(restrictVerticals: string[]): void { this.stateManager.dispatchEvent('universal/setRestrictVerticals', restrictVerticals); } /** * Sets {@link VerticalSearchState.locationRadius} to the specified number of meters. * * @param locationRadius - The radius (in meters) to filter vertical searches by. */ setLocationRadius(locationRadius: number | undefined): void { this.stateManager.dispatchEvent('vertical/setLocationRadius', locationRadius); } /** * Gets the current state of the SearchHeadless instance. */ get state(): State { return this.stateManager.getState(); } /** * Adds a listener for a specific state value of type T. * * @param listener - The listener to add * @returns The function for removing the added listener */ addListener<T>(listener: StateListener<T>): Unsubscribe { return this.stateManager.addListener<T>(listener); } /** * Submits a question to the Search API with the specified request data. * * @param request - The data for the network request * @returns A Promise of a {@link QuestionSubmissionResponse} from the Search API */ async submitQuestion( request: Omit<QuestionSubmissionRequest, 'additionalHttpHeaders'> ): Promise<QuestionSubmissionResponse> { return this.core.submitQuestion({ ...request, additionalHttpHeaders: this.additionalHttpHeaders }); } /** * Performs a Search across all verticals with relevant parts of the * state used as input to the search. Updates the state with the response data. * * @returns A Promise of a {@link UniversalSearchResponse} from the Search API */ async executeUniversalQuery(): Promise<UniversalSearchResponse | undefined> { if (this.state.meta.searchType !== SearchTypeEnum.Universal) { console.error('The meta.searchType must be set to \'universal\' for universal search. ' + 'Set the searchType to universal by calling `setUniversal()`'); return; } const thisRequestId = this.httpManager.updateRequestId('universalQuery'); this.stateManager.dispatchEvent('searchStatus/setIsLoading', true); const { input, querySource, queryTrigger } = this.state.query; const skipSpellCheck = !this.state.spellCheck.enabled; const sessionTrackingEnabled = this.state.sessionTracking.enabled; const { limit, restrictVerticals } = this.state.universal; const sessionId = this.state.sessionTracking.sessionId; const { referrerPageUrl, context } = this.state.meta; const { userLocation } = this.state.location; const request: UniversalSearchRequest = { query: input || '', querySource, queryTrigger, skipSpellCheck, ...(sessionTrackingEnabled && { sessionId }), sessionTrackingEnabled, limit, location: userLocation, context, referrerPageUrl, restrictVerticals, additionalHttpHeaders: this.additionalHttpHeaders }; let response: UniversalSearchResponse; try { response = await this.core.universalSearch(request); } catch (e) { const isLatestResponse = this.httpManager.processRequestId('universalQuery', thisRequestId); if (isLatestResponse) { this.stateManager.dispatchEvent('searchStatus/setIsLoading', false); } return Promise.reject(e); } const isLatestResponse = this.httpManager.processRequestId('universalQuery', thisRequestId); if (!isLatestResponse) { return response; } this.stateManager.dispatchEvent('universal/setVerticals', response.verticalResults); this.stateManager.dispatchEvent('query/setQueryId', response.queryId); this.stateManager.dispatchEvent('query/setMostRecentSearch', input); this.stateManager.dispatchEvent('spellCheck/setResult', response.spellCheck); this.stateManager.dispatchEvent('query/setSearchIntents', response.searchIntents || []); this.stateManager.dispatchEvent('location/setLocationBias', response.locationBias); this.stateManager.dispatchEvent('searchStatus/setIsLoading', false); this.stateManager.dispatchEvent('meta/setUUID', response.uuid); this.stateManager.dispatchEvent('directAnswer/setResult', response.directAnswer); this.stateManager.dispatchEvent('queryRules/setActions', response.queryRulesActionsData || []); return response; } /** * Performs an autocomplete request across all verticals using the query input * stored in state. * * @returns A Promise of an {@link AutocompleteResponse} from the Search API */ async executeUniversalAutocomplete(): Promise<AutocompleteResponse> { const query = this.state.query.input || ''; return this.core.universalAutocomplete({ input: query, additionalHttpHeaders: this.additionalHttpHeaders }); } /** * Perform a Search for a single vertical with relevant parts of the * state used as input to the search. Updates the state with the response data. * * @returns A Promise of a {@link VerticalSearchResponse} from the Search API or * of undefined if there is no verticalKey defined in state */ async executeVerticalQuery(): Promise<VerticalSearchResponse | undefined> { if (this.state.meta.searchType !== SearchTypeEnum.Vertical) { console.error('The meta.searchType must be set to \'vertical\' for vertical search. ' + 'Set the searchType to vertical by calling `setVertical()`'); return; } const thisRequestId = this.httpManager.updateRequestId('verticalQuery'); const verticalKey = this.state.vertical.verticalKey; if (!verticalKey) { console.error('no verticalKey supplied for vertical search'); return; } this.stateManager.dispatchEvent('searchStatus/setIsLoading', true); const { input, isPagination, queryId, querySource, queryTrigger } = this.state.query; const skipSpellCheck = !this.state.spellCheck.enabled; const sessionTrackingEnabled = this.state.sessionTracking.enabled; const sessionId = this.state.sessionTracking.sessionId; const staticFilter = transformFiltersToCoreFormat(this.state.filters.static) || undefined; const facets = this.state.filters?.facets; const { limit, offset, sortBys, locationRadius } = this.state.vertical; const { referrerPageUrl, context } = this.state.meta; const { userLocation } = this.state.location; const nextQueryId = isPagination ? queryId : undefined; const facetsToApply = facets?.map(facet => { return { fieldId: facet.fieldId, options: facet.options.filter(o => o.selected) }; }); const request: VerticalSearchRequest = { query: input || '', querySource, queryTrigger, verticalKey, staticFilter, facets: facetsToApply, retrieveFacets: true, limit, offset, skipSpellCheck, ...(sessionTrackingEnabled && { sessionId }), sessionTrackingEnabled, location: userLocation, sortBys, context, referrerPageUrl, locationRadius, queryId: nextQueryId, additionalHttpHeaders: this.additionalHttpHeaders }; let response: VerticalSearchResponse; try { response = await this.core.verticalSearch(request); } catch (e) { const isLatestResponse = this.httpManager.processRequestId('verticalQuery', thisRequestId); if (isLatestResponse) { this.stateManager.dispatchEvent('searchStatus/setIsLoading', false); } return Promise.reject(e); } const isLatestResponse = this.httpManager.processRequestId('verticalQuery', thisRequestId); if (!isLatestResponse) { return response; } this.stateManager.dispatchEvent('query/setQueryId', response.queryId); this.stateManager.dispatchEvent('query/setMostRecentSearch', input); this.stateManager.dispatchEvent('query/setIsPagination', false); this.stateManager.dispatchEvent('filters/setFacets', response.facets); this.stateManager.dispatchEvent('spellCheck/setResult', response.spellCheck); this.stateManager.dispatchEvent('query/setSearchIntents', response.searchIntents || []); this.stateManager.dispatchEvent('location/setLocationBias', response.locationBias); this.stateManager.dispatchEvent('directAnswer/setResult', response.directAnswer); this.stateManager.dispatchEvent('meta/setUUID', response.uuid); this.stateManager.dispatchEvent('searchStatus/setIsLoading', false); this.stateManager.dispatchEvent('vertical/handleSearchResponse', response); this.stateManager.dispatchEvent('queryRules/setActions', response.queryRulesActionsData || []); return response; } /** * Performs an autocomplete request for a single vertical using the query input * and vertical key stored in state. * * @returns A Promise of an {@link AutocompleteResponse} from the Search API or * of undefined if there is no verticalKey defined in state */ async executeVerticalAutocomplete(): Promise<AutocompleteResponse | undefined> { if (this.state.meta.searchType !== SearchTypeEnum.Vertical) { console.error('The meta.searchType must be set to \'vertical\' for vertical autocomplete. ' + 'Set the searchType to vertical by calling `setVertical()`'); return; } const query = this.state.query.input || ''; const verticalKey = this.state.vertical.verticalKey; if (!verticalKey) { console.error('no verticalKey supplied for vertical autocomplete'); return; } return this.core.verticalAutocomplete({ input: query, verticalKey, additionalHttpHeaders: this.additionalHttpHeaders }); } /** * Performs a filtersearch request against specified fields within a single * vertical using the vertical key stored in state. * * @param query - The query for which to search * @param sectioned - Whether or not the results should be sectioned by field * @param fields - The entity fields to search * @returns A Promise of a {@link FilterSearchResponse} from the Search API or * of undefined if there is no verticalKey defined in state */ async executeFilterSearch( query: string, sectioned: boolean, fields: SearchParameterField[] ): Promise<FilterSearchResponse | undefined> { if (this.state.meta.searchType !== SearchTypeEnum.Vertical) { console.error('The meta.searchType must be set to \'vertical\' for filter search. ' + 'Set the searchType to vertical by calling `setVertical()`'); return; } const verticalKey = this.state.vertical.verticalKey; if (!verticalKey) { console.error('no verticalKey supplied for filter search'); return; } return this.core.filterSearch({ input: query, verticalKey, sessionTrackingEnabled: this.state.sessionTracking.enabled, sectioned, fields, additionalHttpHeaders: this.additionalHttpHeaders }); } /** * Sets a specified facet option to be selected or unselected. * * @param fieldId - The fieldId for the facet * @param facetOption - The option of the facet to select * @param selected - Whether or not the facet option should be selected */ setFacetOption(fieldId: string, facetOption: FacetOption, selected: boolean): void { const payload = { shouldSelect: selected, fieldId, facetOption }; this.stateManager.dispatchEvent('filters/setFacetOption', payload); } /** * Sets a static filter option and whether or not it is selected in state. * * @param filter - The static filter and whether it is selected */ setFilterOption(filter: SelectableStaticFilter): void { this.stateManager.dispatchEvent('filters/setFilterOption', filter); } /** * Perform a generativeDirectAnswer request to the query most recent search stored in state. * * @returns A Promise of a {@link GenerativeDirectAnswerResponse} from the Search API or * of undefined if there is no results defined in state */ async executeGenerativeDirectAnswer(): Promise<GenerativeDirectAnswerResponse | undefined> { const thisRequestId = this.httpManager.updateRequestId('generativeDirectAnswer'); const searchId = this.state.meta.uuid; const searchTerm = this.state.query.mostRecentSearch; let results: VerticalResults[] | undefined; if (this.state.meta.searchType === SearchTypeEnum.Vertical) { if (isVerticalResults(this.state.vertical)) { results = [this.state.vertical]; } } else { results = this.state.universal.verticals; } if (!searchId) { console.error('no search id supplied for generative direct answer'); return; } if (!searchTerm) { console.error('no search term supplied for generative direct answer'); return; } if (!results || results.length === 0) { console.error('no results supplied for generative direct answer'); return; } this.stateManager.dispatchEvent('generativeDirectAnswer/setIsLoading', true); let response: GenerativeDirectAnswerResponse; try { response = await this.core.generativeDirectAnswer({ searchId, results, searchTerm, additionalHttpHeaders: this.additionalHttpHeaders }); } catch (e) { const isLatestResponse = this.httpManager.processRequestId('generativeDirectAnswer', thisRequestId); if (isLatestResponse) { this.stateManager.dispatchEvent('generativeDirectAnswer/setResponse', undefined); this.stateManager.dispatchEvent('generativeDirectAnswer/setIsLoading', false); } return Promise.reject(e); } const isLatestResponse = this.httpManager.processRequestId('generativeDirectAnswer', thisRequestId); if (!isLatestResponse) { return response; } this.stateManager.dispatchEvent('generativeDirectAnswer/setResponse', response); this.stateManager.dispatchEvent('generativeDirectAnswer/setIsLoading', false); return response; } }