@yext/search-headless
Version:
A library for powering UI components for Yext Search integrations
638 lines (590 loc) • 22.6 kB
text/typescript
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;
}
}