+ Generates an HTML representation of the provided Rich Text field value. Note that
+the HTML will contain a wrapper div. This is to support click analytics for Rich Text
+links.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
fieldValue
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
A Rich Text field value.
+
+
+
+
+
+
+
fieldName
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The name of the field, to be included in the payload of a click
+ analytics event. This parameter is optional.
+
+
+
+
+
+
+
targetConfig
+
+
+
+
+
+Object
+|
+
+string
+
+
+
+
+
+
+
+
+
+
Configuration object specifying the 'target' behavior for
+ the various types of links. If a string is provided, it is assumed that
+ is the 'target' behavior across all types of links. This parameter is optional.
+ The HTML representation of the field value, serialized as a string.
+
+
+
+
+
+
+ Type
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/answers-umd.js.html b/docs/answers-umd.js.html
index 37d1113d7..eb7e38312 100644
--- a/docs/answers-umd.js.html
+++ b/docs/answers-umd.js.html
@@ -29,16 +29,49 @@
Source: answers-umd.js
/** @module */
import Core from './core/core';
+import cssVars from 'css-vars-ponyfill';
import {
- TemplateLoader,
- COMPONENT_MANAGER,
+ DefaultTemplatesLoader,
Renderers,
- DOM
+ DOM,
+ SearchParams
} from './ui/index';
+import Component from './ui/components/component';
import ErrorReporter from './core/errors/errorreporter';
-import { AnalyticsReporter } from './core';
+import ConsoleErrorReporter from './core/errors/consoleerrorreporter';
+import { AnalyticsReporter, NoopAnalyticsReporter } from './core';
+import Storage from './core/storage/storage';
+import { AnswersComponentError } from './core/errors/errors';
+import AnalyticsEvent from './core/analytics/analyticsevent';
+import StorageKeys from './core/storage/storagekeys';
+import QueryTriggers from './core/models/querytriggers';
+import SearchConfig from './core/models/searchconfig';
+import ComponentManager from './ui/components/componentmanager';
+import VerticalPagesConfig from './core/models/verticalpagesconfig';
+import { SANDBOX, PRODUCTION, LOCALE, QUERY_SOURCE } from './core/constants';
+import RichTextFormatter from './core/utils/richtextformatter';
+import { isValidContext } from './core/utils/apicontext';
+import FilterNodeFactory from './core/filters/filternodefactory';
+import { urlWithoutQueryParamsAndHash } from './core/utils/urlutils';
+import TranslationProcessor from './core/i18n/translationprocessor';
+import Filter from './core/models/filter';
+import SearchComponent from './ui/components/search/searchcomponent';
+import QueryUpdateListener from './core/statelisteners/queryupdatelistener';
+
+/** @typedef {import('./core/services/errorreporterservice').default} ErrorReporterService */
+/** @typedef {import('./core/services/analyticsreporterservice').default} AnalyticsReporterService */
+
+/**
+ * @typedef Services
+ * @property {ErrorReporterService} errorReporterService
+ */
+
+const DEFAULTS = {
+ locale: LOCALE,
+ querySource: QUERY_SOURCE
+};
/**
* The main Answers interface
@@ -49,6 +82,23 @@
Source: answers-umd.js
return Answers.getInstance();
}
+ /**
+ * A reference to the Component base class for custom
+ * components to extend
+ */
+ this.Component = Component;
+
+ /**
+ * A reference to the AnalyticsEvent base class for reporting
+ * custom analytics
+ */
+ this.AnalyticsEvent = AnalyticsEvent;
+
+ /**
+ * A reference to the FilterNodeFactory class for creating {@link FilterNode}s.
+ */
+ this.FilterNodeFactory = FilterNodeFactory;
+
/**
* A reference of the renderer to use for the components
* This is provided during initialization.
@@ -56,17 +106,48 @@
Source: answers-umd.js
*/
this.renderer = new Renderers.Handlebars();
+ /**
+ * A reference to the formatRichText function.
+ * @type {Function}
+ */
+ this.formatRichText = (markdown, eventOptionsFieldName, targetConfig) =>
+ RichTextFormatter.format(markdown, eventOptionsFieldName, targetConfig);
+
/**
* A local reference to the component manager
* @type {ComponentManager}
*/
- this.components = COMPONENT_MANAGER;
+ this.components = ComponentManager.getInstance();
+
+ /**
+ * A local reference to the core api
+ * @type {Core}
+ */
+ this.core = null;
/**
* A callback function to invoke once the library is ready.
* Typically fired after templates are fetched from server for rendering.
*/
this._onReady = function () {};
+
+ /**
+ * @type {boolean}
+ * @private
+ */
+ this._eligibleForAnalytics = false;
+
+ /**
+ * @type {Services}
+ * @private
+ */
+ this._services = null;
+
+ /**
+ * @type {AnalyticsReporterService}
+ * @private
+ */
+ this._analyticsReporterService = null;
}
static setInstance (instance) {
@@ -81,43 +162,228 @@
Source: answers-umd.js
return this.instance;
}
- init (config) {
- this.components.setCore(new Core({
- apiKey: config.apiKey,
- answersKey: config.answersKey,
- locale: config.locale
- }))
- .setRenderer(this.renderer)
- .setAnalyticsReporter(new AnalyticsReporter(config.apiKey, config.answersKey));
+ _initStorage (parsedConfig) {
+ const storage = new Storage({
+ updateListener: (data, url) => {
+ if (parsedConfig.onStateChange) {
+ parsedConfig.onStateChange(Object.fromEntries(data), url);
+ }
+ },
+ resetListener: data => {
+ let query = data.get(StorageKeys.QUERY);
+ const hasQuery = query || query === '';
+ this.core.storage.delete(StorageKeys.PERSISTED_LOCATION_RADIUS);
+ this.core.storage.delete(StorageKeys.PERSISTED_FILTER);
+ this.core.storage.delete(StorageKeys.PERSISTED_FACETS);
+ this.core.storage.delete(StorageKeys.SORT_BYS);
+ this.core.filterRegistry.clearAllFilterNodes();
+
+ if (!hasQuery) {
+ this.core.clearResults();
+ } else {
+ this.core.storage.set(StorageKeys.QUERY_TRIGGER, QueryTriggers.QUERY_PARAMETER);
+ }
+
+ if (!data.get(StorageKeys.SEARCH_OFFSET)) {
+ this.core.storage.set(StorageKeys.SEARCH_OFFSET, 0);
+ }
+
+ data.forEach((value, key) => {
+ if (key === StorageKeys.QUERY) {
+ return;
+ }
+ const parsedValue = this._parsePersistentStorageValue(key, value);
+ this.core.storage.set(key, parsedValue);
+ });
+
+ this.core.storage.set(StorageKeys.HISTORY_POP_STATE, data);
+
+ if (hasQuery) {
+ this.core.storage.set(StorageKeys.QUERY, query);
+ }
+ },
+ persistedValueParser: this._parsePersistentStorageValue
+ });
+ storage.init(window.location.search);
+ storage.set(StorageKeys.SEARCH_CONFIG, parsedConfig.search);
+ storage.set(StorageKeys.VERTICAL_PAGES_CONFIG, parsedConfig.verticalPages);
+ storage.set(StorageKeys.LOCALE, parsedConfig.locale);
+ storage.set(StorageKeys.QUERY_SOURCE, parsedConfig.querySource);
+
+ // Check if sessionsOptIn data is stored in the URL. If it is, prefer that over
+ // what is in parsedConfig.
+ const sessionOptIn = storage.get(StorageKeys.SESSIONS_OPT_IN);
+ if (!sessionOptIn) {
+ storage.set(
+ StorageKeys.SESSIONS_OPT_IN,
+ { value: parsedConfig.sessionTrackingEnabled, setDynamically: false });
+ } else {
+ // If sessionsOptIn was stored in the URL, it was stored only as a string.
+ // Parse this value and add it back to storage.
+ storage.set(
+ StorageKeys.SESSIONS_OPT_IN,
+ { value: (/^true$/i).test(sessionOptIn), setDynamically: true });
+ }
- this._onReady = config.onReady || function () {};
+ parsedConfig.noResults && storage.set(StorageKeys.NO_RESULTS_CONFIG, parsedConfig.noResults);
+ const isSuggestQueryTrigger =
+ storage.get(StorageKeys.QUERY_TRIGGER) === QueryTriggers.SUGGEST;
+ if (storage.has(StorageKeys.QUERY) && !isSuggestQueryTrigger) {
+ storage.set(StorageKeys.QUERY_TRIGGER, QueryTriggers.QUERY_PARAMETER);
+ }
- if (config.useTemplates === false || config.templateBundle) {
- if (config.templateBundle) {
- this.renderer.init(config.templateBundle);
- }
+ const context = storage.get(StorageKeys.API_CONTEXT);
+ if (context && !isValidContext(context)) {
+ storage.delete(StorageKeys.API_CONTEXT);
+ console.error(`Context parameter "${context}" is invalid, omitting from the search.`);
+ }
- this._onReady();
- return this;
+ if (storage.get(StorageKeys.REFERRER_PAGE_URL) === undefined) {
+ storage.set(
+ StorageKeys.REFERRER_PAGE_URL,
+ urlWithoutQueryParamsAndHash(document.referrer)
+ );
+ }
+ return storage;
+ }
+
+ /**
+ * Initializes the SDK with the provided configuration. Note that before onReady
+ * is ever called, a check to the relevant Answers Status page is made.
+ *
+ * @param {Object} config The Answers configuration.
+ * @param {Object} statusPage An override for the baseUrl and endpoint of the
+ * experience's Answers Status page.
+ */
+ init (config, statusPage) {
+ window.performance.mark('yext.answers.initStart');
+ const parsedConfig = this.parseConfig(config);
+ this.validateConfig(parsedConfig);
+
+ parsedConfig.search = new SearchConfig(parsedConfig.search);
+ parsedConfig.verticalPages = new VerticalPagesConfig(parsedConfig.verticalPages);
+
+ const storage = this._initStorage(parsedConfig);
+ this._services = parsedConfig.mock
+ ? getMockServices()
+ : getServices(parsedConfig, storage);
+
+ this._eligibleForAnalytics = parsedConfig.businessId != null;
+ // TODO(amullings): Initialize with other services
+ if (this._eligibleForAnalytics && parsedConfig.mock) {
+ this._analyticsReporterService = new NoopAnalyticsReporter();
+ } else if (this._eligibleForAnalytics) {
+ this._analyticsReporterService = new AnalyticsReporter(
+ parsedConfig.experienceKey,
+ parsedConfig.experienceVersion,
+ parsedConfig.businessId,
+ parsedConfig.analyticsOptions,
+ parsedConfig.environment);
+
+ // listen to query id updates
+ storage.registerListener({
+ eventType: 'update',
+ storageKey: StorageKeys.QUERY_ID,
+ callback: id => this._analyticsReporterService.setQueryId(id)
+ });
+
+ this.components.setAnalyticsReporter(this._analyticsReporterService);
+ initScrollListener(this._analyticsReporterService);
+ }
+
+ this.core = new Core({
+ apiKey: parsedConfig.apiKey,
+ storage: storage,
+ experienceKey: parsedConfig.experienceKey,
+ fieldFormatters: parsedConfig.fieldFormatters,
+ experienceVersion: parsedConfig.experienceVersion,
+ locale: parsedConfig.locale,
+ analyticsReporter: this._analyticsReporterService,
+ onVerticalSearch: parsedConfig.onVerticalSearch,
+ onUniversalSearch: parsedConfig.onUniversalSearch,
+ environment: parsedConfig.environment,
+ componentManager: this.components
+ });
+
+ if (parsedConfig.onStateChange && typeof parsedConfig.onStateChange === 'function') {
+ parsedConfig.onStateChange(
+ Object.fromEntries(storage.getAll()),
+ this.core.storage.getCurrentStateUrlMerged());
}
- // Templates are currently downloaded separately from the CORE and UI bundle.
- // Future enhancement is to ship the components with templates in a separate bundle.
- this.templates = new TemplateLoader({
- templateUrl: config.templateUrl
- }).onLoaded((templates) => {
- this.renderer.init(templates);
+ this.components
+ .setCore(this.core)
+ .setRenderer(this.renderer);
+
+ this._setDefaultInitialSearch(parsedConfig.search);
+
+ this.core.init();
+
+ this._onReady = parsedConfig.onReady || function () {};
+ const asyncDeps = this._loadAsyncDependencies(parsedConfig);
+ return asyncDeps.finally(() => {
this._onReady();
+ if (!this.components.getActiveComponent(SearchComponent.type)) {
+ this._initQueryUpdateListener(parsedConfig.search);
+ }
+ this._searchOnLoad();
+ });
+ }
+
+ _initQueryUpdateListener ({ verticalKey, defaultInitialSearch }) {
+ const queryUpdateListener = new QueryUpdateListener(this.core, {
+ defaultInitialSearch,
+ verticalKey
});
+ this.core.setQueryUpdateListener(queryUpdateListener);
+ }
- if (!config.suppressErrorReports) {
- this._errorReporter = new ErrorReporter(config.apiKey, config.answersKey);
- window.addEventListener('error', e => this._errorReporter.report(e.error));
- window.addEventListener('unhandledrejection', e => this._errorReporter.report(e.error));
+ /**
+ * This guarantees that execution of the SearchBar's search on page load occurs only
+ * AFTER all components have been added to the page. Trying to do this with a regular
+ * onCreate relies on the SearchBar having some sort of async behavior to move the execution
+ * of the search to the end of the call stack. For instance, relying on promptForLocation
+ * being set to true, which adds additional Promises that will delay the exeuction.
+ *
+ * We need to guarantee that the searchOnLoad happens after the onReady, because certain
+ * components will update values in storage in their onMount/onCreate, which are then expected
+ * to be applied to this search on page load. For example, filter components can apply
+ * filters on page load, which must be applied before this search is made to affect it.
+ *
+ * If no special search components exist, we still want to search on load if a query has been set,
+ * either from a defaultInitialSearch or from a query in the URL.
+ */
+ _searchOnLoad () {
+ const searchComponents = this.components._activeComponents
+ .filter(c => c.constructor.type === SearchComponent.type);
+ if (searchComponents.length) {
+ searchComponents.forEach(c => c.searchAfterAnswersOnReady && c.searchAfterAnswersOnReady());
+ } else if (this.core.storage.has(StorageKeys.QUERY)) {
+ this.core.triggerSearch(this.core.storage.get(StorageKeys.QUERY_TRIGGER));
}
+ }
- return this;
+ _loadAsyncDependencies (parsedConfig) {
+ const loadTemplates = this._loadTemplates(parsedConfig);
+ const ponyfillCssVariables = this._handlePonyfillCssVariables(parsedConfig.disableCssVariablesPonyfill);
+ return Promise.all([loadTemplates, ponyfillCssVariables]);
+ }
+
+ _loadTemplates ({ useTemplates, templateBundle }) {
+ if (useTemplates === false || templateBundle) {
+ if (templateBundle) {
+ this.renderer.init(templateBundle, this._getInitLocale());
+ return Promise.resolve();
+ }
+ } else {
+ // Templates are currently downloaded separately from the CORE and UI bundle.
+ // Future enhancement is to ship the components with templates in a separate bundle.
+ this.templates = new DefaultTemplatesLoader(templates => {
+ this.renderer.init(templates, this._getInitLocale());
+ });
+ return this.templates.fetchTemplates();
+ }
}
domReady (cb) {
@@ -129,6 +395,62 @@
Source: answers-umd.js
return this;
}
+ /**
+ * Parses the config provided by the user. In the parsed config, any options not supplied by the
+ * user are given default values.
+ * @param {Object} config The user supplied config.
+ */
+ parseConfig (config) {
+ const parsedConfig = Object.assign({}, DEFAULTS, config);
+ let sessionTrackingEnabled = true;
+ if (typeof config.sessionTrackingEnabled === 'boolean') {
+ sessionTrackingEnabled = config.sessionTrackingEnabled;
+ }
+ parsedConfig.sessionTrackingEnabled = sessionTrackingEnabled;
+
+ const sandboxPrefix = `${SANDBOX}-`;
+ parsedConfig.apiKey.includes(sandboxPrefix)
+ ? parsedConfig.environment = SANDBOX
+ : parsedConfig.environment = PRODUCTION;
+ parsedConfig.apiKey = parsedConfig.apiKey.replace(sandboxPrefix, '');
+
+ return parsedConfig;
+ }
+
+ /**
+ * Validates the Answers config object to ensure things like api key and experience key are
+ * properly set.
+ * @param {Object} config The Answers config.
+ */
+ validateConfig (config) {
+ // TODO (tmeyer): Extract this method into it's own class. Investigate the use of JSON schema
+ // to validate these configs.
+ if (typeof config.apiKey !== 'string') {
+ throw new Error('Missing required `apiKey`. Type must be {string}');
+ }
+
+ if (typeof config.experienceKey !== 'string') {
+ throw new Error('Missing required `experienceKey`. Type must be {string}');
+ }
+
+ if (config.onVerticalSearch && typeof config.onVerticalSearch !== 'function') {
+ throw new Error('onVerticalSearch must be a function. Current type is: ' + typeof config.onVerticalSearch);
+ }
+
+ if (config.onUniversalSearch && typeof config.onUniversalSearch !== 'function') {
+ throw new Error('onUniversalSearch must be a function. Current type is: ' + typeof config.onUniversalSearch);
+ }
+ }
+
+ /**
+ * Register a custom component type so it can be created via
+ * addComponent and used as a child component
+ * @param {Component} componentClass
+ */
+ registerComponentType (componentClass) {
+ this.components.register(componentClass);
+ }
+
addComponent (type, opts) {
if (typeof opts === 'string') {
opts = {
@@ -136,18 +458,275 @@
Source: answers-umd.js
};
}
- this.components.create(type, opts).mount();
+ try {
+ this.components.create(type, opts).mount();
+ } catch (e) {
+ throw new AnswersComponentError('Failed to add component', type, e);
+ }
return this;
}
+ /**
+ * Remove the component - and all of its children - with the given name
+ * @param {string} name The name of the component to remove
+ */
+ removeComponent (name) {
+ this.components.removeByName(name);
+ }
+
createComponent (opts) {
return this.components.create('Component', opts).mount();
}
+ /**
+ * Conducts a search in the Answers experience
+ *
+ * @param {string} query
+ */
+ search (query) {
+ this.core.storage.setWithPersist(StorageKeys.QUERY, query);
+ }
+
registerHelper (name, cb) {
this.renderer.registerHelper(name, cb);
return this;
}
+
+ /**
+ * Compile and add a template to the current renderer
+ * @param {string} templateName The unique name for the template
+ * @param {string} template The handlebars template string
+ */
+ registerTemplate (templateName, template) {
+ this.renderer.registerTemplate(templateName, template);
+ }
+
+ /**
+ * Opt in or out of convertion tracking analytics
+ * @param {boolean} optIn
+ */
+ setConversionsOptIn (optIn) {
+ if (this._eligibleForAnalytics) {
+ this._analyticsReporterService.setConversionTrackingEnabled(optIn);
+ }
+ }
+
+ /**
+ * Opt in or out of session cookies
+ * @param {boolean} optIn
+ */
+ setSessionsOptIn (optIn) {
+ this.core.storage.set(
+ StorageKeys.SESSIONS_OPT_IN, { value: optIn, setDynamically: true });
+ }
+
+ /**
+ * Sets a search query on initialization for vertical searchers that have a
+ * defaultInitialSearch provided, if the user hasn't already provided their
+ * own via URL param. A default initial search should not be persisted in the URL,
+ * so we do a regular set instead of a setWithPersist here.
+ *
+ * @param {SearchConfig} searchConfig
+ * @private
+ */
+ _setDefaultInitialSearch (searchConfig) {
+ if (searchConfig.defaultInitialSearch == null) {
+ return;
+ }
+ const prepopulatedQuery = this.core.storage.get(StorageKeys.QUERY);
+ if (prepopulatedQuery != null) {
+ return;
+ }
+ this.core.storage.set(StorageKeys.QUERY_TRIGGER, QueryTriggers.INITIALIZE);
+ this.core.storage.set(StorageKeys.QUERY, searchConfig.defaultInitialSearch);
+ }
+
+ /**
+ * Sets the geolocation tag in storage, overriding other inputs. Do not use in conjunction
+ * with other components that will set the geolocation internally.
+ * @param {number} lat
+ * @param {number} long
+ */
+ setGeolocation (lat, lng) {
+ this.core.storage.set(StorageKeys.GEOLOCATION, {
+ lat, lng, radius: 0
+ });
+ }
+
+ /**
+ * A promise that resolves when ponyfillCssVariables resolves,
+ * or resolves immediately if ponyfill is disabled
+ * @param {boolean} option to opt out of the css variables ponyfill
+ * @return {Promise} resolves after ponyfillCssVariables, or immediately if disabled
+ */
+ _handlePonyfillCssVariables (ponyfillDisabled) {
+ window.performance.mark('yext.answers.ponyfillStart');
+ if (ponyfillDisabled) {
+ window.performance.mark('yext.answers.ponyfillEnd');
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ this.ponyfillCssVariables({
+ onFinally: () => {
+ window.performance.mark('yext.answers.ponyfillEnd');
+ resolve();
+ }
+ });
+ });
+ }
+
+ /*
+ * Updates the css styles with new current variables. This is useful when the css
+ * variables are updated dynamically (e.g. through js) or if the css variables are
+ * added after the ANSWERS.init
+ *
+ * To solve issues with non-zero max-age cache controls for link/script assets in IE11,
+ * we add a cache busting parameter so that XMLHttpRequests succeed.
+ *
+ * @param {Object} config Additional config to pass to the ponyfill
+ */
+ ponyfillCssVariables (config = {}) {
+ cssVars({
+ onlyLegacy: true,
+ onError: config.onError || function () {},
+ onSuccess: config.onSuccess || function () {},
+ onFinally: config.onFinally || function () {},
+ onBeforeSend: (xhr, node, url) => {
+ try {
+ const uriWithCacheBust = new URL(url);
+ const params = new SearchParams(uriWithCacheBust.search);
+ params.set('_', new Date().getTime());
+ uriWithCacheBust.search = params.toString();
+ xhr.open('GET', uriWithCacheBust.toString());
+ } catch (e) {
+ // Catch the error and continue if the URL provided in the asset is not a valid URL
+ }
+ }
+ });
+ }
+
+ /*
+ * Adds context as a parameter for the query API calls.
+ * @param {Object} context The context object passed in the API calls
+ */
+ setContext (context) {
+ const contextString = JSON.stringify(context);
+ if (!isValidContext(contextString)) {
+ console.error(`Context parameter "${context}" is invalid, omitting from the search.`);
+ return;
+ }
+
+ this.core.storage.set(StorageKeys.API_CONTEXT, contextString);
+ }
+
+ /**
+ * Processes a translation which includes performing interpolation, pluralization, or
+ * both
+ * @param {string | Object} translations The translation, or an object containing
+ * translated plural forms
+ * @param {Object} interpolationParams Params to use during interpolation
+ * @param {number} count The count associated with the pluralization
+ * @param {string} language The langauge associated with the pluralization
+ * @returns {string} The translation with any interpolation or pluralization applied
+ */
+ processTranslation (translations, interpolationParams, count, language) {
+ const initLocale = this._getInitLocale();
+ language = language || initLocale.substring(0, 2);
+
+ if (!this.renderer) {
+ console.error('The renderer must be initialized before translations can be processed');
+ return '';
+ }
+
+ const escapeExpression = this.renderer.escapeExpression.bind(this.renderer);
+
+ return TranslationProcessor.process(translations, interpolationParams, count, language, escapeExpression);
+ }
+
+ /**
+ * Gets the locale that ANSWERS was initialized to
+ *
+ * @returns {string}
+ */
+ _getInitLocale () {
+ return this.core.storage.get(StorageKeys.LOCALE);
+ }
+
+ /**
+ * Parses a value from persistent storage, which stores strings,
+ * into the shape the SDK expects.
+ * TODO(SLAP-1111): Move this into a dedicated file/class.
+ *
+ * @param {string} key
+ * @param {string} value
+ * @returns {string|number|Filter}
+ */
+ _parsePersistentStorageValue (key, value) {
+ switch (key) {
+ case StorageKeys.PERSISTED_FILTER:
+ return Filter.from(JSON.parse(value));
+ case StorageKeys.PERSISTED_LOCATION_RADIUS:
+ return parseFloat(value);
+ case StorageKeys.PERSISTED_FACETS:
+ case StorageKeys.SORT_BYS:
+ return JSON.parse(value);
+ default:
+ return value;
+ }
+ }
+}
+
+/**
+ * @param {Object} config
+ * @param {Storage} storage
+ * @returns {Services}
+ */
+function getServices (config, storage) {
+ return {
+ errorReporterService: new ErrorReporter(
+ {
+ apiKey: config.apiKey,
+ experienceKey: config.experienceKey,
+ experienceVersion: config.experienceVersion,
+ printVerbose: config.debug,
+ sendToServer: !config.suppressErrorReports,
+ environment: config.environment
+ },
+ storage)
+ };
+}
+
+/**
+ * @returns {Services}
+ */
+function getMockServices () {
+ return {
+ errorReporterService: new ConsoleErrorReporter()
+ };
+}
+
+/**
+ * Initialize the scroll event listener to send analytics events
+ * when the user scrolls to the bottom. Debounces scroll events so
+ * they are processed after the user stops scrolling
+ */
+function initScrollListener (reporter) {
+ const DEBOUNCE_TIME = 100;
+ let timeout = null;
+
+ const sendEvent = () => {
+ if ((window.innerHeight + window.pageYOffset) >= document.body.scrollHeight) {
+ const event = new AnalyticsEvent('SCROLL_TO_BOTTOM_OF_PAGE');
+ if (reporter.getQueryId()) {
+ reporter.report(event);
+ }
+ }
+ };
+
+ document.addEventListener('scroll', () => {
+ clearTimeout(timeout);
+ timeout = setTimeout(sendEvent, DEBOUNCE_TIME);
+ });
}
const ANSWERS = new Answers();
@@ -162,13 +741,13 @@
/** @module */
-/** The current lib version, reported with errors and analytics */
-export const LIB_VERSION = 'v0.7.3';
+/** The current lib version, reported with errors and analytics, injected by the build process */
+export const LIB_VERSION = '@@LIB_VERSION';
-/** The base url for the api backend */
-export const API_BASE_URL = 'https://liveapi.yext.com';
+/** The current locale, injected by the build process */
+export const LOCALE = '@@LOCALE';
-/** The default url for compiled component templates */
-export const COMPILED_TEMPLATES_URL = 'https://assets.sitescdn.net/answers/answerstemplates.compiled.min.js';
+/** The identifier of the production environment */
+export const PRODUCTION = 'production';
+
+/** The identifier of the sandbox environment */
+export const SANDBOX = 'sandbox';
-/** The base url for the analytics backend */
-export const ANALYTICS_BASE_URL = 'https://realtimeanalytics.yext.com';
+/** The default url for compiled component templates */
+export const COMPILED_TEMPLATES_URL = `https://assets.sitescdn.net/answers/${LIB_VERSION}/answerstemplates.compiled.min.js`;
+
+/** The query source, reported with analytics */
+export const QUERY_SOURCE = 'STANDARD';
+
+export const ENDPOINTS = {
+ UNIVERSAL_SEARCH: '/v2/accounts/me/answers/query',
+ VERTICAL_SEARCH: '/v2/accounts/me/answers/vertical/query',
+ QUESTION_SUBMISSION: '/v2/accounts/me/createQuestion',
+ UNIVERSAL_AUTOCOMPLETE: '/v2/accounts/me/answers/autocomplete',
+ VERTICAL_AUTOCOMPLETE: '/v2/accounts/me/answers/vertical/autocomplete',
+ FILTER_SEARCH: '/v2/accounts/me/answers/filtersearch'
+};
/** @module Core */
-
-import SearchApi from './search/searchapi';
-import AutoCompleteApi from './search/autocompleteapi';
+import { provideCore } from '@yext/answers-core/lib/commonjs';
import SearchDataTransformer from './search/searchdatatransformer';
-import Storage from './storage/storage';
+import VerticalResults from './models/verticalresults';
+import UniversalResults from './models/universalresults';
+import QuestionSubmission from './models/questionsubmission';
+import Navigation from './models/navigation';
+import AlternativeVerticals from './models/alternativeverticals';
+import LocationBias from './models/locationbias';
+import QueryTriggers from './models/querytriggers';
+
import StorageKeys from './storage/storagekeys';
+import AnalyticsEvent from './analytics/analyticsevent';
+import FilterRegistry from './filters/filterregistry';
+import DirectAnswer from './models/directanswer';
+import AutoCompleteResponseTransformer from './search/autocompleteresponsetransformer';
+
+import { PRODUCTION, ENDPOINTS } from './constants';
+import { getCachedLiveApiUrl, getLiveApiUrl, getKnowledgeApiUrl } from './utils/urlutils';
+import { SearchParams } from '../ui';
+import SearchStates from './storage/searchstates';
+
+/** @typedef {import('./storage/storage').default} Storage */
/**
* Core is the main application container for all of the network and storage
- * related behaviors of the application.
+ * related behaviors of the application. It uses an instance of the external Core
+ * library to perform the actual network calls.
*/
export default class Core {
- constructor (opts = {}) {
- if (typeof opts.apiKey !== 'string') {
- throw new Error('Missing required `apiKey`. Type must be {string}');
- }
-
- if (typeof opts.answersKey !== 'string') {
- throw new Error('Missing required `answersKey`. Type must be {string}');
- }
-
+ constructor (config = {}) {
/**
* A reference to the client API Key used for all requests
* @type {string}
* @private
*/
- this._apiKey = opts.apiKey;
+ this._apiKey = config.apiKey;
/**
* A reference to the client Answers Key used for all requests
* @type {string}
* @private
*/
- this._answersKey = opts.answersKey;
+ this._experienceKey = config.experienceKey;
+
+ /**
+ * The answers config version to use for all requests
+ * @type {string}
+ * @private
+ */
+ this._experienceVersion = config.experienceVersion;
/**
* A reference to the client locale used for all requests. If not specified, defaults to "en" (for
@@ -70,61 +86,347 @@
Source: core/core.js
* @type {string}
* @private
*/
- this._locale = opts.locale || 'en';
+ this._locale = config.locale;
+
+ /**
+ * A map of field formatters used to format results, if present
+ * @type {Object<string, function>}
+ * @private
+ */
+ this._fieldFormatters = config.fieldFormatters || {};
/**
* A reference to the core data storage that powers the UI
* @type {Storage}
- * @private
*/
- this.storage = new Storage();
+ this.storage = config.storage;
/**
- * An abstraction containing the integration with the RESTful search API
- * For both vertical and universal search
- * @type {Search}
- * @private
+ * The filterRegistry is in charge of setting, removing, and retrieving filters
+ * and facet filters from storage.
+ * @type {FilterRegistry}
*/
- this._searcher = new SearchApi({
- apiKey: this._apiKey,
- answersKey: this._answersKey,
- locale: this._locale
- });
+ this.filterRegistry = new FilterRegistry(this.storage);
/**
- * An abstraction containing the integration with the RESTful autocomplete API
- * For filter search, vertical autocomplete, and universal autocomplete
- * @type {Autocomplete}
- * @private
+ * A local reference to the analytics reporter, used to report events for this component
+ * @type {AnalyticsReporter}
*/
- this._autoComplete = new AutoCompleteApi({
+ this._analyticsReporter = config.analyticsReporter;
+
+ /**
+ * A user-given function that returns an analytics event to fire after a universal search.
+ * @type {Function}
+ */
+ this.onUniversalSearch = config.onUniversalSearch || function () {};
+
+ /**
+ * A user-given function that returns an analytics event to fire after a vertical search.
+ * @type {Function}
+ */
+ this.onVerticalSearch = config.onVerticalSearch || function () {};
+
+ /**
+ * The environment which determines which URLs the requests use.
+ * @type {string}
+ */
+ this._environment = config.environment || PRODUCTION;
+
+ /**
+ * @type {string}
+ */
+ this._verticalKey = config.verticalKey;
+
+ /**
+ * @type {ComponentManager}
+ */
+ this._componentManager = config.componentManager;
+ }
+
+ /**
+ * Sets a reference in core to the global QueryUpdateListener.
+ *
+ * @param {QueryUpdateListener} queryUpdateListener
+ */
+ setQueryUpdateListener (queryUpdateListener) {
+ this.queryUpdateListener = queryUpdateListener;
+ }
+
+ /**
+ * Initializes the {@link Core} by providing it with an instance of the Core library.
+ */
+ init () {
+ const params = {
apiKey: this._apiKey,
- answersKey: this._answersKey,
- locale: this._locale
- });
+ experienceKey: this._experienceKey,
+ locale: this._locale,
+ experienceVersion: this._experienceVersion,
+ endpoints: this._getServiceUrls()
+ };
+
+ this._coreLibrary = provideCore(params);
+ }
+
+ /**
+ * Get the urls for each service based on the environment.
+ */
+ _getServiceUrls () {
+ return {
+ universalSearch: getLiveApiUrl(this._environment) + ENDPOINTS.UNIVERSAL_SEARCH,
+ verticalSearch: getLiveApiUrl(this._environment) + ENDPOINTS.VERTICAL_SEARCH,
+ questionSubmission: getKnowledgeApiUrl(this._environment) + ENDPOINTS.QUESTION_SUBMISSION,
+ universalAutocomplete: getCachedLiveApiUrl(this._environment) + ENDPOINTS.UNIVERSAL_AUTOCOMPLETE,
+ verticalAutocomplete: getCachedLiveApiUrl(this._environment) + ENDPOINTS.VERTICAL_AUTOCOMPLETE,
+ filterSearch: getCachedLiveApiUrl(this._environment) + ENDPOINTS.FILTER_SEARCH
+ };
+ }
+
+ /**
+ * @returns {boolean} A boolean indicating if the {@link Core} has been
+ * initailized.
+ */
+ isInitialized () {
+ return !!this._coreLibrary;
}
- verticalSearch (queryString, verticalKey, filter) {
- return this._searcher
- .verticalQuery(queryString, verticalKey, filter)
- .then(response => SearchDataTransformer.transformVertical(response))
+ /**
+ * Search in the context of a vertical
+ * @param {string} verticalKey vertical ID for the search
+ * @param {Object} options additional settings for the search.
+ * @param {boolean} options.useFacets Whether to apply facets to this search, or to reset them instead
+ * @param {boolean} options.resetPagination Whether to reset the search offset, going back to page 1.
+ * @param {boolean} options.setQueryParams Whether to persist certain params in the url
+ * @param {string} options.sendQueryId Whether to send the queryId currently in storage.
+ * If paging within a query, the same ID should be used.
+ * @param {Object} query The query details
+ * @param {string} query.input The input to search for
+ * @param {boolean} query.append If true, adds the results of this query to the end of the current results, defaults false
+ */
+ verticalSearch (verticalKey, options = {}, query = {}) {
+ window.performance.mark('yext.answers.verticalQueryStart');
+ if (!query.append) {
+ const verticalResults = this.storage.get(StorageKeys.VERTICAL_RESULTS);
+ if (!verticalResults || verticalResults.searchState !== SearchStates.SEARCH_LOADING) {
+ this.storage.set(StorageKeys.VERTICAL_RESULTS, VerticalResults.searchLoading());
+ }
+ this.storage.set(StorageKeys.SPELL_CHECK, {});
+ this.storage.set(StorageKeys.LOCATION_BIAS, LocationBias.searchLoading());
+ }
+
+ const { resetPagination, useFacets, sendQueryId, setQueryParams } = options;
+ if (resetPagination) {
+ this.storage.delete(StorageKeys.SEARCH_OFFSET);
+ }
+
+ if (!useFacets) {
+ this.filterRegistry.setFacetFilterNodes([], []);
+ }
+
+ const context = this.storage.get(StorageKeys.API_CONTEXT);
+ const referrerPageUrl = this.storage.get(StorageKeys.REFERRER_PAGE_URL);
+
+ const defaultQueryInput = this.storage.get(StorageKeys.QUERY) || '';
+ const parsedQuery = Object.assign({}, { input: defaultQueryInput }, query);
+
+ if (setQueryParams) {
+ if (context) {
+ this.storage.setWithPersist(StorageKeys.API_CONTEXT, context);
+ }
+ if (referrerPageUrl !== undefined) {
+ this.storage.setWithPersist(StorageKeys.REFERRER_PAGE_URL, referrerPageUrl);
+ }
+ }
+
+ const searchConfig = this.storage.get(StorageKeys.SEARCH_CONFIG) || {};
+ if (!searchConfig.verticalKey) {
+ this.storage.set(StorageKeys.SEARCH_CONFIG, {
+ ...searchConfig,
+ verticalKey: verticalKey
+ });
+ }
+ const locationRadius = this._getLocationRadius();
+ const queryTrigger = this.storage.get(StorageKeys.QUERY_TRIGGER);
+ const queryTriggerForApi = this.getQueryTriggerForSearchApi(queryTrigger);
+
+ return this._coreLibrary
+ .verticalSearch({
+ verticalKey: verticalKey || searchConfig.verticalKey,
+ limit: this.storage.get(StorageKeys.SEARCH_CONFIG).limit,
+ location: this._getLocationPayload(),
+ query: parsedQuery.input,
+ queryId: sendQueryId && this.storage.get(StorageKeys.QUERY_ID),
+ retrieveFacets: this._isDynamicFiltersEnabled,
+ facets: this.filterRegistry.getFacetsPayload(),
+ staticFilters: this.filterRegistry.getStaticFilterPayload(),
+ offset: this.storage.get(StorageKeys.SEARCH_OFFSET) || 0,
+ skipSpellCheck: this.storage.get(StorageKeys.SKIP_SPELL_CHECK),
+ queryTrigger: queryTriggerForApi,
+ sessionTrackingEnabled: this.storage.get(StorageKeys.SESSIONS_OPT_IN).value,
+ sortBys: this.storage.get(StorageKeys.SORT_BYS),
+ /** In the SDK a locationRadius of 0 means "unset my locationRadius" */
+ locationRadius: locationRadius === 0 ? undefined : locationRadius,
+ context: context,
+ referrerPageUrl: referrerPageUrl,
+ querySource: this.storage.get(StorageKeys.QUERY_SOURCE)
+ })
+ .then(response => SearchDataTransformer.transformVertical(response, this._fieldFormatters, verticalKey))
.then(data => {
+ this._persistFacets();
+ this._persistFilters();
+ this._persistLocationRadius();
+
+ this.storage.set(StorageKeys.QUERY_ID, data[StorageKeys.QUERY_ID]);
this.storage.set(StorageKeys.NAVIGATION, data[StorageKeys.NAVIGATION]);
- this.storage.set(StorageKeys.VERTICAL_RESULTS, data[StorageKeys.VERTICAL_RESULTS]);
+ this.storage.set(StorageKeys.ALTERNATIVE_VERTICALS, data[StorageKeys.ALTERNATIVE_VERTICALS]);
+
+ if (query.append) {
+ const mergedResults = this.storage.get(StorageKeys.VERTICAL_RESULTS)
+ .append(data[StorageKeys.VERTICAL_RESULTS]);
+ this.storage.set(StorageKeys.VERTICAL_RESULTS, mergedResults);
+ } else {
+ this.storage.set(StorageKeys.VERTICAL_RESULTS, data[StorageKeys.VERTICAL_RESULTS]);
+ }
+
+ if (data[StorageKeys.DYNAMIC_FILTERS]) {
+ this.storage.set(StorageKeys.DYNAMIC_FILTERS, data[StorageKeys.DYNAMIC_FILTERS]);
+ this.storage.set(StorageKeys.RESULTS_HEADER, data[StorageKeys.DYNAMIC_FILTERS]);
+ }
+ if (data[StorageKeys.SPELL_CHECK]) {
+ this.storage.set(StorageKeys.SPELL_CHECK, data[StorageKeys.SPELL_CHECK]);
+ }
+ if (data[StorageKeys.LOCATION_BIAS]) {
+ this.storage.set(StorageKeys.LOCATION_BIAS, data[StorageKeys.LOCATION_BIAS]);
+ }
+ this.storage.delete(StorageKeys.SKIP_SPELL_CHECK);
+ this.storage.delete(StorageKeys.QUERY_TRIGGER);
+
+ const exposedParams = {
+ verticalKey: verticalKey,
+ queryString: parsedQuery.input,
+ resultsCount: this.storage.get(StorageKeys.VERTICAL_RESULTS).resultsCount,
+ resultsContext: data[StorageKeys.VERTICAL_RESULTS].resultsContext
+ };
+ const analyticsEvent = this.onVerticalSearch(exposedParams);
+ if (typeof analyticsEvent === 'object') {
+ this._analyticsReporter.report(AnalyticsEvent.fromData(analyticsEvent));
+ }
+ this.updateHistoryAfterSearch(queryTrigger);
+ window.performance.mark('yext.answers.verticalQueryResponseRendered');
});
}
- search (queryString, urls) {
- return this._searcher
- .query(queryString)
- .then(response => SearchDataTransformer.transform(response, urls))
+ clearResults () {
+ this.storage.set(StorageKeys.QUERY, null);
+ this.storage.set(StorageKeys.QUERY_ID, '');
+ this.storage.set(StorageKeys.RESULTS_HEADER, {});
+ this.storage.set(StorageKeys.SPELL_CHECK, {}); // TODO has a model but not cleared w new
+ this.storage.set(StorageKeys.DYNAMIC_FILTERS, {}); // TODO has a model but not cleared w new
+ this.storage.set(StorageKeys.QUESTION_SUBMISSION, new QuestionSubmission({}));
+ this.storage.set(StorageKeys.NAVIGATION, new Navigation());
+ this.storage.set(StorageKeys.ALTERNATIVE_VERTICALS, new AlternativeVerticals({}));
+ this.storage.set(StorageKeys.DIRECT_ANSWER, new DirectAnswer({}));
+ this.storage.set(StorageKeys.LOCATION_BIAS, new LocationBias({}));
+ this.storage.set(StorageKeys.VERTICAL_RESULTS, new VerticalResults({}));
+ this.storage.set(StorageKeys.UNIVERSAL_RESULTS, new UniversalResults({}));
+ }
+
+ /**
+ * Page within the results of the last query
+ */
+ verticalPage () {
+ this.triggerSearch(QueryTriggers.PAGINATION);
+ }
+
+ search (queryString, options = {}) {
+ const urls = this._getUrls(queryString);
+ window.performance.mark('yext.answers.universalQueryStart');
+ const { setQueryParams } = options;
+ const context = this.storage.get(StorageKeys.API_CONTEXT);
+ const referrerPageUrl = this.storage.get(StorageKeys.REFERRER_PAGE_URL);
+
+ if (setQueryParams) {
+ if (context) {
+ this.storage.setWithPersist(StorageKeys.API_CONTEXT, context);
+ }
+ if (referrerPageUrl !== undefined) {
+ this.storage.setWithPersist(StorageKeys.REFERRER_PAGE_URL, referrerPageUrl);
+ }
+ }
+
+ this.storage.set(StorageKeys.DIRECT_ANSWER, {});
+ const universalResults = this.storage.get(StorageKeys.UNIVERSAL_RESULTS);
+ if (!universalResults || universalResults.searchState !== SearchStates.SEARCH_LOADING) {
+ this.storage.set(StorageKeys.UNIVERSAL_RESULTS, UniversalResults.searchLoading());
+ }
+ this.storage.set(StorageKeys.QUESTION_SUBMISSION, {});
+ this.storage.set(StorageKeys.SPELL_CHECK, {});
+ this.storage.set(StorageKeys.LOCATION_BIAS, LocationBias.searchLoading());
+
+ const queryTrigger = this.storage.get(StorageKeys.QUERY_TRIGGER);
+ const queryTriggerForApi = this.getQueryTriggerForSearchApi(queryTrigger);
+ return this._coreLibrary
+ .universalSearch({
+ query: queryString,
+ location: this._getLocationPayload(),
+ skipSpellCheck: this.storage.get(StorageKeys.SKIP_SPELL_CHECK),
+ queryTrigger: queryTriggerForApi,
+ sessionTrackingEnabled: this.storage.get(StorageKeys.SESSIONS_OPT_IN).value,
+ context: context,
+ referrerPageUrl: referrerPageUrl,
+ querySource: this.storage.get(StorageKeys.QUERY_SOURCE)
+ })
+ .then(response => SearchDataTransformer.transformUniversal(response, urls, this._fieldFormatters))
.then(data => {
+ this.storage.set(StorageKeys.QUERY_ID, data[StorageKeys.QUERY_ID]);
this.storage.set(StorageKeys.NAVIGATION, data[StorageKeys.NAVIGATION]);
this.storage.set(StorageKeys.DIRECT_ANSWER, data[StorageKeys.DIRECT_ANSWER]);
- this.storage.set(StorageKeys.UNIVERSAL_RESULTS, data[StorageKeys.UNIVERSAL_RESULTS], urls);
+ this.storage.set(StorageKeys.UNIVERSAL_RESULTS, data[StorageKeys.UNIVERSAL_RESULTS]);
+ this.storage.set(StorageKeys.SPELL_CHECK, data[StorageKeys.SPELL_CHECK]);
+ this.storage.set(StorageKeys.LOCATION_BIAS, data[StorageKeys.LOCATION_BIAS]);
+
+ this.storage.delete(StorageKeys.SKIP_SPELL_CHECK);
+ this.storage.delete(StorageKeys.QUERY_TRIGGER);
+
+ const exposedParams = this._getOnUniversalSearchParams(
+ data[StorageKeys.UNIVERSAL_RESULTS].sections,
+ queryString);
+ const analyticsEvent = this.onUniversalSearch(exposedParams);
+ if (typeof analyticsEvent === 'object') {
+ this._analyticsReporter.report(AnalyticsEvent.fromData(analyticsEvent));
+ }
+ this.updateHistoryAfterSearch(queryTrigger);
+ window.performance.mark('yext.answers.universalQueryResponseRendered');
});
}
+ /**
+ * Builds the object passed as a parameter to onUniversalSearch. This object
+ * contains information about the universal search's query and result counts.
+ *
+ * @param {Array<Section>} sections The sections of results.
+ * @param {string} queryString The search query.
+ * @return {Object<string, ?>}
+ */
+ _getOnUniversalSearchParams (sections, queryString) {
+ const resultsCountByVertical = sections.reduce(
+ (resultsCountMap, section) => {
+ const { verticalConfigId, resultsCount, results } = section;
+ resultsCountMap[verticalConfigId] = {
+ totalResultsCount: resultsCount,
+ displayedResultsCount: results.length
+ };
+ return resultsCountMap;
+ },
+ {});
+ const exposedParams = {
+ queryString,
+ sectionsCount: sections.length,
+ resultsCountByVertical
+ };
+
+ return exposedParams;
+ }
+
/**
* Given an input, query for a list of similar results and set into storage
*
@@ -132,10 +434,15 @@
/** @module ErrorReporter */
-import { AnswersBaseError } from './errors';
+import { AnswersBaseError, AnswersBasicError } from './errors';
import ApiRequest from '../http/apirequest';
import { LIB_VERSION } from '../constants';
+/** @typedef {import('../services/errorreporterservice').default} ErrorReporterService */
+
/**
- * ErrorReporter is used for reporting errors to the server
+ * ErrorReporter is used for reporting errors to the console and API
+ *
+ * @implements {ErrorReporterService}
*/
export default class ErrorReporter {
- constructor (apiKey, answersKey) {
+ constructor (config, storage) {
/**
* The apiKey to use for reporting
* @type {string}
*/
- this.apiKey = apiKey;
+ this.apiKey = config.apiKey;
+
+ /**
+ * The experienceKey to use when reporting
+ * @type {string}
+ */
+ this.experienceKey = config.experienceKey;
+
+ /**
+ * The answers config version used for api requests
+ * @type {string|number}
+ */
+ this.experienceVersion = config.experienceVersion || 'config1.0';
+
+ /**
+ * If true, print entire error objects to the console for inspection
+ * @type {boolean}
+ */
+ this.printVerbose = config.printVerbose;
+
+ /**
+ * If true, report the error the server for logging and monitoring
+ * @type {boolean}
+ */
+ this.sendToServer = config.sendToServer;
/**
- * The answersKey to use when reporting
+ * The storage instance of the experience
+ * @type {Storage}
+ */
+ if (this.sendToServer && !storage) {
+ throw new AnswersBasicError(
+ 'Must include storage to send errors to server',
+ 'ErrorReporter');
+ }
+ this.storage = storage;
+
+ /**
+ * The environment of the Answers experience
* @type {string}
+ * @private
*/
- this.answersKey = answersKey;
+ this.environment = config.environment;
+
+ // Attach reporting listeners to window
+ window.addEventListener('error', e => this.report(e.error));
+ window.addEventListener('unhandledrejection', e => this.report(e.error));
}
/**
- * report sends a network request to the server to be logged
- * @param {AnswersBaseError} The error to be reported
+ * report pretty prints the error to the console, optionally
+ * prints the entire error if `printVerbose` is true, and sends the
+ * error to the server to be logged if `sendToServer` is true
+ * @param {AnswersBaseError} err The error to be reported
+ * @returns {AnswersBaseError} The reported error
*/
report (err) {
if (!(err instanceof AnswersBaseError) || err.reported) {
@@ -62,22 +109,47 @@
Source: core/errors/errorreporter.js
err.reported = true;
- const request = new ApiRequest({
- endpoint: '/v2/accounts/me/answers/errors',
- apiKey: this.apiKey,
- version: 20190301,
- params: {
- 'error': err.toJson(),
- 'libVersion': LIB_VERSION,
- 'answersKey': this.answersKey
- }
- });
-
- request.get()
- .catch(console.err);
+ this.printError(err);
+
+ if (this.sendToServer) {
+ const requestConfig = {
+ endpoint: '/v2/accounts/me/answers/errors',
+ apiKey: this.apiKey,
+ version: 20190301,
+ environment: this.environment,
+ params: {
+ 'libVersion': LIB_VERSION,
+ 'experienceVersion': this.experienceVersion,
+ 'experienceKey': this.experienceKey,
+ 'error': err.toJson()
+ }
+ };
+ const request = new ApiRequest(requestConfig, this.storage);
+
+ // TODO(amullings): We should probably change this endpoint to POST,
+ // ideally using the beacon API. Stack traces will likely easily hit URL
+ // length limits.
+ request.get()
+ .catch(console.err);
+ }
return err;
}
+
+ /**
+ * prints the given error to the browser console
+ * @param {AnswersBaseError} err The error to be printed
+ */
+ printError (err) {
+ if (this.printVerbose) {
+ console.error(`error: ${err.errorMessage}
+code: ${err.errorCode}
+boundary: ${err.boundary}
+stack: ${err.stack}`);
+ } else {
+ console.error(err.toString());
+ }
+ }
}
}
}
+/**
+ * AnswersUiError used for things like DOM errors.
+ * @extends AnswersBaseError
+ */
+export class AnswersConfigError extends AnswersBaseError {
+ constructor (message, boundary, causedBy) {
+ super(101, message, boundary, causedBy);
+ }
+}
+
/**
* AnswersUiError used for things like DOM errors.
* @extends AnswersBaseError
@@ -111,6 +135,7 @@
/** @module CombinedFilterNode */
+
+import Filter from '../models/filter';
+import FilterCombinators from './filtercombinators';
+import FilterNode from './filternode';
+
+/**
+ * A CombinedFilterNode represents a combined filter.
+ * A combined filter is a set of filters combined with a {@link FilterCombinators}
+ * ($and or $or). Since a combined filter is just a set of other filters,
+ * it does not have its own {@link FilterMetadata}, and its filter is dervied from
+ * its children.
+ */
+export default class CombinedFilterNode extends FilterNode {
+ constructor (filterNode = {}) {
+ super();
+ const { combinator, children } = filterNode;
+
+ /**
+ * @type {string}
+ */
+ this.combinator = combinator;
+
+ /**
+ * @type {Array<FilterNode>}
+ */
+ this.children = children || [];
+ Object.freeze(this);
+ }
+
+ /**
+ * Returns the filter created by combining this node's children.
+ * @type {Filter}
+ */
+ getFilter () {
+ const filters = this.children.map(childNode => childNode.getFilter());
+ switch (this.combinator) {
+ case (FilterCombinators.AND):
+ return Filter.and(...filters);
+ case (FilterCombinators.OR):
+ return Filter.or(...filters);
+ }
+ return Filter.empty();
+ }
+
+ /**
+ * Returns the metadata associated with this node's filter.
+ * Because a combined filter's purpose is solely to join together other filters,
+ * and does not have its own filter, this value is always null.
+ * @returns {null}
+ */
+ getMetadata () {
+ return null;
+ }
+
+ /**
+ * Returns this node's children.
+ * @returns {Array<FilterNode>}
+ */
+ getChildren () {
+ return this.children;
+ }
+
+ /**
+ * Recursively get all of the leaf SimpleFilterNodes.
+ * @returns {Array<SimpleFilterNode>}
+ */
+ getSimpleDescendants () {
+ return this.getChildren().flatMap(fn => fn.getSimpleDescendants());
+ }
+
+ /**
+ * Removes this filter node from the FilterRegistry by calling remove on each of its
+ * child FilterNodes.
+ */
+ remove () {
+ this.children.forEach(child => {
+ child.remove();
+ });
+ }
+}
+
/** @module FilterMetadata */
+
+import FilterType from './filtertype';
+
+/**
+ * FilterMetadata is a container for additional display data for a {@link Filter}.
+ */
+export default class FilterMetadata {
+ constructor (metadata = {}) {
+ const { fieldName, displayValue, filterType } = metadata;
+
+ /**
+ * The display name for the field being filtered on.
+ * @type {string}
+ */
+ this.fieldName = fieldName;
+
+ /**
+ * The display value for the values being filtered on.
+ * Even if there are multiple values within the data of a filter,
+ * there should only be one display value for the whole filter.
+ * @type {string}
+ */
+ this.displayValue = displayValue;
+
+ /**
+ * What type of filter this is.
+ * @type {FilterType}
+ */
+ this.filterType = filterType || FilterType.STATIC;
+ Object.freeze(this);
+ }
+}
+
/** @module FilterNode */
+
+/**
+ * A FilterNode represents a single node in a filter tree.
+ * Each filter node has an associated filter, containing the filter
+ * data to send in a request, any additional filter metadata for display,
+ * and any children nodes.
+ *
+ * Implemented by {@link SimpleFilterNode} and {@link CombinedFilterNode}.
+ */
+export default class FilterNode {
+ /**
+ * Returns this node's filter.
+ * @returns {Filter}
+ */
+ getFilter () {}
+
+ /**
+ * Returns the metadata for this node's filter.
+ * @returns {FilterMetadata}
+ */
+ getMetadata () {}
+
+ /**
+ * Returns the children of this node.
+ * @returns {Array<FilterNode>}
+ */
+ getChildren () {}
+
+ /**
+ * Recursively get all of the leaf SimpleFilterNodes.
+ * @returns {Array<SimpleFilterNode>}
+ */
+ getSimpleDescendants () {}
+
+ /**
+ * Remove this FilterNode from the FilterRegistry.
+ */
+ remove () {}
+}
+
/** @module ApiRequest */
import HttpRequester from './httprequester';
-import { API_BASE_URL } from '../constants';
+import { LIB_VERSION, PRODUCTION } from '../constants';
+import SearchParams from '../../ui/dom/searchparams'; // TODO ideally this would be passed in as a param
+import { AnswersBasicError } from '../errors/errors';
+import StorageKeys from '../storage/storagekeys';
+import { getLiveApiUrl } from '../utils/urlutils';
/**
* ApiRequest is the base class for all API requests.
* It defines all of the core properties required to make a request
*/
export default class ApiRequest {
- constructor (opts = {}) {
+ // TODO (tmeyer): Create an ApiService interface and pass an implementation to the current
+ // consumers of ApiRequest as a dependency.
+ constructor (opts = {}, storage) {
/**
* An abstraction used for making network request and handling errors
* @type {HttpRequester}
@@ -44,12 +50,19 @@
Source: core/http/apirequest.js
*/
this._requester = new HttpRequester();
+ /**
+ * The environment the request should be made to
+ * @type {string}
+ * @private
+ */
+ this._environment = opts.environment || PRODUCTION;
+
/**
* The baseUrl to use for making a request
* @type {string}
* @private
*/
- this._baseUrl = opts.baseUrl || API_BASE_URL;
+ this._baseUrl = opts.baseUrl || getLiveApiUrl(this._environment);
/**
* The endpoint to use in the url (appended to the {baseUrl})
@@ -78,32 +91,63 @@
Source: core/http/apirequest.js
* @private
*/
this._params = opts.params || {};
+
+ if (!storage) {
+ throw new AnswersBasicError('Must include storage', 'ApiRequest');
+ }
+ /**
+ * @type {Storage}
+ * @private
+ */
+ this._storage = storage;
}
/**
* get creates a new `GET` request to the server using the configuration of the request class
- * @returns {Promise}
+ *
+ * @param {Object} opts Any configuration options to use for the GET request.
+ * @returns {Promise<Response>}
*/
- get () {
- return this._requester.get(this._baseUrl + this._endpoint, this.params(this._params));
+ get (opts) {
+ return this._requester.get(
+ this._baseUrl + this._endpoint,
+ Object.assign({}, this.baseParams(), this.sanitizeParams(this._params)),
+ opts
+ );
}
+ /**
+ * @param {Object} opts
+ * @returns {Promise<Response>}
+ */
post (opts) {
- return this._requester.post(this._baseUrl + this._endpoint, this.params(this._params), opts);
+ return this._requester.post(
+ this._baseUrl + this._endpoint,
+ this.baseParams() /* urlParams */,
+ this.sanitizeParams(this._params) /* jsonBody */,
+ opts /* requestConfig */);
}
- params (params) {
- var baseParams = {
+ /**
+ * @returns {Object}
+ * @private
+ */
+ baseParams () {
+ let baseParams = {
'v': this._version,
- 'api_key': this._apiKey
+ 'api_key': this._apiKey,
+ 'jsLibVersion': LIB_VERSION,
+ 'sessionTrackingEnabled': this._storage.get(StorageKeys.SESSIONS_OPT_IN).value
};
-
- const urlParams = new URL(window.location.toString()).searchParams;
-
+ const urlParams = new SearchParams(this._storage.getCurrentStateUrlMerged());
if (urlParams.has('beta')) {
baseParams['beta'] = urlParams.get('beta');
}
+ return baseParams;
+ }
+
+ sanitizeParams (params = {}) {
// Remove any paramaters whos value is `undefined`.
//
// NOTE(billy) Probably better to be explicit about how to handle this at the request building level,
@@ -115,7 +159,7 @@
/** @module HttpRequester */
-/* global fetch */
+/* global fetch, XMLHttpRequest, ActiveXObject */
+
+import { fetch as fetchPolyfill } from 'cross-fetch';
/**
* Types of HTTP requests
@@ -59,28 +61,83 @@
Source: core/http/httprequester.js
/**
* Create a POST HTTP request
* @param {string} url The url to make a request to
- * @param {Object} data The data to provide (gets encoded into the URL)
- * @param {Object} opts Configuration options to use for the request
+ * @param {Object} urlParams The params to encode into the URL
+ * @param {Object} jsonBody The request body (json) to provide with the POST request
+ * @param {Object} requestConfig Configuration options to use for the request
*/
- post (url, data, opts) {
+ post (url, urlParams, jsonBody, requestConfig) {
return this.request(
Methods.POST,
- url,
- Object.assign({
- body: JSON.stringify(data),
+ this.encodeParams(url, urlParams),
+ Object.assign({}, {
+ body: JSON.stringify(jsonBody),
credentials: undefined
- }, opts)
+ }, requestConfig)
);
}
request (method, url, opts) {
- return fetch(url, Object.assign({
- method,
- credentials: 'include'
- }, opts));
+ const reqArgs = Object.assign({}, {
+ 'method': method,
+ 'credentials': 'include'
+ }, opts);
+
+ return this._fetch(url, reqArgs);
+ }
+
+ // TODO (agrow) investigate removing this
+ // Use imported fetchPolyfill if it does not already exist on window
+ _fetch (url, reqArgs) {
+ if (!window.fetch) {
+ return fetchPolyfill(url, reqArgs);
+ }
+ return fetch(url, reqArgs);
+ }
+
+ /**
+ * Send a beacon to the provided url which will send a non-blocking request
+ * to the server that is guaranteed to send before page load. No response is returned,
+ * so beacons are primarily used for analytics reporting.
+ * @param {string} url The url to send the beacon to
+ * @param {object} data The data payload to send in the beacon
+ * @return {boolean} true if the request is successfully queued
+ */
+ beacon (url, data) {
+ return this._sendBeacon(url, JSON.stringify(data));
+ }
+
+ // TODO (agrow) investigate removing this
+ // Navigator.sendBeacon polyfill
+ // Combination of the compact Financial Times polyfill:
+ // https://github.com/Financial-Times/polyfill-library/blob/master/polyfills/navigator/sendBeacon/polyfill.js
+ // with the async-by-default behavior of Miguel Mota's polyfill:
+ // https://github.com/miguelmota/Navigator.sendBeacon/blob/master/sendbeacon.js
+ _sendBeacon (url, data) {
+ if (window.navigator && window.navigator.sendBeacon) {
+ return window.navigator.sendBeacon(url, data);
+ }
+
+ var event = window.event && window.event.type;
+ var sync = event === 'unload' || event === 'beforeunload';
+ var xhr = ('XMLHttpRequest' in window) ? new XMLHttpRequest() : new ActiveXObject('Microsoft.XMLHTTP');
+ xhr.open('POST', url, !sync);
+ xhr.setRequestHeader('Accept', '*/*');
+ if (typeof data === 'string') {
+ xhr.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8');
+ } else if (Object.prototype.toString.call(data) === '[object Blob]') {
+ if (data.type) {
+ xhr.setRequestHeader('Content-Type', data.type);
+ }
+ }
+ xhr.send(data);
+ return true;
}
encodeParams (url, params) {
+ if (typeof params !== 'object') {
+ return;
+ }
+
let hasParam = url.indexOf('?') > -1;
let searchQuery = '';
@@ -107,13 +164,13 @@
/** @module */
-export { default as SearchApi } from './search/searchapi';
export { default as AnalyticsReporter } from './analytics/analyticsreporter';
+export { default as NoopAnalyticsReporter } from './analytics/noopanalyticsreporter';
export { default as ModuleData } from './storage/moduledata';
export { default as Storage } from './storage/storage';
import { AnswersConfigError } from '../errors/errors';
+
+/**
+ * The AlternativeVertical is a model that is used to power the search
+ * suggestions info box. It's initialized through the configuration provided
+ * to the component.
+ */
+export default class AlternativeVertical {
+ constructor (config) {
+ /**
+ * The name of the vertical that is exposed for the link
+ * @type {string}
+ */
+ this.label = config.label;
+ if (typeof this.label !== 'string') {
+ throw new AnswersConfigError(
+ 'label is a required configuration option for verticalPage.',
+ 'AlternativeVertical'
+ );
+ }
+
+ /**
+ * The complete URL, including the params
+ * @type {string}
+ */
+ this.url = config.url;
+ if (typeof this.url !== 'string') {
+ throw new AnswersConfigError(
+ 'url is a required configuration option for verticalPage.',
+ 'AlternativeVertical'
+ );
+ }
+
+ /**
+ * name of an icon from the default icon set
+ * @type {string}
+ */
+ this.iconName = config.iconName;
+
+ /**
+ * URL of an icon
+ * @type {string}
+ */
+ this.iconUrl = config.iconUrl;
+
+ /**
+ * Whether the vertical has an icon
+ * @type {string}
+ */
+ this.hasIcon = this.iconName || this.iconUrl;
+
+ /**
+ * The number of results to display next to each alternative
+ * vertical
+ * @type {number}
+ */
+ this.resultsCount = config.resultsCount;
+ }
+}
+
/** @module Filter */
+import FilterCombinators from '../filters/filtercombinators';
+import Matcher from '../filters/matcher';
+
+const RANGE_MATCHERS = new Set([
+ Matcher.GreaterThan,
+ Matcher.GreaterThanOrEqualTo,
+ Matcher.LessThanOrEqualTo,
+ Matcher.LessThan
+]);
+
/**
* Represents an api filter and provides static methods for easily constructing Filters.
* See https://developer.yext.com/docs/api-reference/#operation/listEntities for structure details
@@ -38,6 +48,64 @@
Source: core/models/filter.js
Object.freeze(this);
}
+ /**
+ * A filter should have exactly ONE key. That key is EITHER the field name to filter by, or
+ * a special string such as $or or $and.
+ * @type {string}
+ */
+ getFilterKey () {
+ if (Object.keys(this).length > 0) {
+ return Object.keys(this)[0];
+ }
+ }
+
+ /**
+ * Whether this filter is a range filter.
+ *
+ * @returns {boolean}
+ */
+ isRangeFilter () {
+ const filterKey = this.getFilterKey();
+ if (!filterKey) {
+ return false;
+ }
+ const matchers = Object.keys(this[filterKey]);
+ return matchers.every(m => RANGE_MATCHERS.has(m));
+ }
+
+ /**
+ * Create an empty filter
+ */
+ static empty () {
+ return new Filter();
+ }
+
+ /**
+ * Wrap filter data in a Filter class
+ * @param {Object} filter
+ */
+ static from (filter) {
+ return new Filter(filter);
+ }
+
+ /**
+ * Constructs an SDK Filter model from an answers-core SimpleFilter model
+ *
+ * @param {SimpleFilter} filter from answers-core
+ * @returns {Filter}
+ */
+ static fromCoreSimpleFilter (filter) {
+ if (!filter) {
+ return this.empty();
+ }
+
+ return new Filter({
+ [filter.fieldId]: {
+ [filter.matcher]: filter.value
+ }
+ });
+ }
+
/**
* Parse a JSON format filter returned from the server into a Filter
* @param {*} responseFilter A filter in JSON format returned from the backend
@@ -54,7 +122,7 @@
* @returns {Filter}
*/
static greaterThanEqual (field, value) {
- return Filter._fromMatcher(field, '$ge', value);
+ return Filter._fromMatcher(field, Matcher.GreaterThanOrEqualTo, value);
+ }
+
+ /**
+ * Create a new inclusive range filter
+ * @param {string} field The subject field of the filter
+ * @param {*} min The minimum value
+ * @param {*} max The maximum value
+ * @returns {Filter}
+ */
+ static inclusiveRange (field, min, max) {
+ return new Filter({
+ [field]: {
+ [Matcher.GreaterThanOrEqualTo]: min,
+ [Matcher.LessThanOrEqualTo]: max
+ }
+ });
}
/**
- * Create a new inclusive range filter for a field
+ * Create a new exclusive range filter
* @param {string} field The subject field of the filter
- * @param {*} min The minimum value of the range
- * @param {*} max The maximum value of the ranger
+ * @param {*} min The minimum value
+ * @param {*} max The maximum value
* @returns {Filter}
*/
- static range (field, min, max) {
- return Filter.and(Filter.greaterThanEqual(field, min), Filter.lessThanEqual(field, max));
+ static exclusiveRange (field, min, max) {
+ return new Filter({
+ [field]: {
+ [Matcher.GreaterThan]: min,
+ [Matcher.LessThan]: max
+ }
+ });
+ }
+
+ /**
+ * Create a new position filter
+ * @param {number} lat The latitude of the position
+ * @param {number} lng The longitude of the position
+ * @param {number} radius The search radius (in meters)
+ */
+ static position (lat, lng, radius) {
+ return Filter._fromMatcher('builtin.location', Matcher.Near, { lat, lng, radius });
}
/**
@@ -179,13 +286,13 @@
/** @module Result */
+import AppliedHighlightedFields from './appliedhighlightedfields';
+import HighlightedFields from './highlightedfields';
+import { truncate } from '../utils/strings';
+import { AnswersCoreError } from '../errors/errors';
+
export default class Result {
constructor (data = {}) {
- Object.assign(this, data);
+ /**
+ * The raw profile data
+ * @type {Object}
+ * @private
+ */
+ this._raw = data.raw || null;
+
+ /**
+ * The formatted profile data
+ * @type {Object}
+ * @private
+ */
+ this._formatted = data.formatted;
+
+ /**
+ * The highlighted profile data with highlights applied to applicable fields
+ * @type {Object}
+ * @private
+ */
+ this._highlighted = data.highlighted;
+
+ /**
+ * An object that lists the substrings to highlight for each applicable field.
+ * @type {Object}
+ */
+ this.highlightedFields = data.highlightedFields;
+
+ /**
+ * The index number of the result
+ * @type {Number}
+ */
+ this.ordinal = data.ordinal || null;
+
+ /**
+ * The title of the result card
+ * @type {string|null}
+ */
+ this.title = data.title || null;
+
+ /**
+ * The body of the details section of the result card, can contain HTML
+ * @type {string| null}
+ */
+ this.details = data.details || null;
+
+ /**
+ * The destination link for the title of the result card
+ * @type {string|null}
+ */
+ this.link = data.link || null;
+
+ /**
+ * The Entity ID, or other unique identifier, used for to power interactivity
+ * @type {string|null}
+ */
+ this.id = data.id || null;
+
+ /**
+ * The subtitle on the result card
+ * @type {string|null}
+ */
+ this.subtitle = data.subtitle || null;
+
+ /**
+ * The class modifier, usually derived from the vertical configuration ID
+ * Used to apply different styling to different result card types
+ * @type {string|null}
+ */
+ this.modifier = data.modifier || null;
+
+ /**
+ * A large date, of the format { month: 'Jan', day: '01' }
+ * @type {Object|null}
+ */
+ this.bigDate = data.bigDate || null;
+
+ /**
+ * An image profile object, expected to have a url property
+ * @type {Object|null}
+ */
+ this.image = data.image || null;
+
+ /**
+ * An array of calls to action, of the format:
+ * { icon: '', url: '', text: '', eventType: '', eventOptions: {}}
+ * @type {Array}
+ */
+ this.callsToAction = data.callsToAction || [];
+
+ /**
+ * Determines if an accordian result should be collapsed by default
+ * @type {boolean}
+ */
+ this.collapsed = data.collapsed === undefined ? true : data.collapsed;
+
+ /**
+ * @type {number}
+ */
+ this.distance = data.distance || null;
+
+ /**
+ * @type {number}
+ */
+ this.distanceFromFilter = data.distanceFromFilter || null;
}
/**
- * resultsData expected format: { data: { ... }, highlightedFields: { ... }}
+ * Constructs an SDK Result from an answers-core Result
+ *
+ * @param {Result} result from answers-core
+ * @param {Object<string, function>} formatters applied to the result fields
+ * @param {string} verticalKey the verticalKey associated with the result
+ * @returns {@link Result}
*/
- static from (resultsData) {
- let results = [];
- for (let i = 0; i < resultsData.length; i++) {
- // TODO use resultData.highlightedFields to
- // transform resultData.data into html-friendly strings that highlight values.
-
- // Check for new data format, otherwise fallback to legacy
- results.push(new Result(resultsData[i].data || resultsData[i]));
+ static fromCore (result, formatters, verticalKey) {
+ const highlightedFields = new HighlightedFields(result.highlightedFields);
+ const appliedHighlightedFields = AppliedHighlightedFields.fromCore(result.highlightedFields);
+ const details = appliedHighlightedFields.description || result.description;
+ const truncatedDetails = truncate(details);
+
+ const resultData = {
+ raw: result.rawData,
+ ordinal: result.index,
+ title: result.name,
+ details: truncatedDetails,
+ link: result.link,
+ id: result.id,
+ distance: result.distance,
+ distanceFromFilter: result.distanceFromFilter,
+ highlighted: appliedHighlightedFields,
+ highlightedFields
+ };
+
+ if (result.source !== 'KNOWLEDGE_MANAGER') {
+ return new Result(resultData);
+ }
+
+ const formattedData = this._getFormattedData(resultData, formatters, verticalKey);
+ resultData.formatted = formattedData;
+
+ if (formattedData.description !== undefined) {
+ resultData.details = formattedData.description;
}
- return results;
+ return new Result(resultData);
+ }
+
+ /**
+ * Returns an object which contains formatted fields
+ *
+ * @param {Object} resultData the same shape as the input to the Result constructor
+ * @param {Object<string, function>} formatters to apply to the result fields
+ * @param {string} verticalKey the verticalKey associated with the result
+ * @returns {Object<string, string>} keys are field names and values are the formatted data
+ */
+ static _getFormattedData (resultData, formatters, verticalKey) {
+ const formattedData = {};
+
+ if (!formatters || !resultData.raw) {
+ return formattedData;
+ }
+
+ if (Object.keys(formatters).length === 0) {
+ return formattedData;
+ }
+
+ Object.entries(resultData.raw).forEach(([fieldName, fieldVal]) => {
+ // check if a field formatter exists for the current entity profile field
+ if (formatters[fieldName] === undefined) {
+ return;
+ }
+ // verify the field formatter provided is a formatter function as expected
+ if (typeof formatters[fieldName] !== 'function') {
+ throw new AnswersCoreError('Field formatter is not of expected type function', 'Result');
+ }
+
+ // if highlighted version of field value is available, make it available to field formatter
+ let highlightedFieldVal = null;
+ if (resultData.highlighted && resultData.highlighted[fieldName]) {
+ highlightedFieldVal = resultData.highlighted[fieldName];
+ }
+
+ // call formatter function associated with the field name
+ // the input object defines the interface that field formatter functions work with
+ formattedData[fieldName] = formatters[fieldName]({
+ entityProfileData: resultData.raw,
+ entityFieldValue: fieldVal,
+ highlightedEntityFieldValue: highlightedFieldVal,
+ verticalId: verticalKey,
+ isDirectAnswer: false
+ });
+ });
+
+ return formattedData;
}
}
/** @module VerticalPagesConfig */
+
+export class VerticalPageConfig {
+ constructor (config = {}) {
+ /**
+ * The name of the tab that is exposed for the link
+ * @type {string}
+ */
+ this.label = config.label || null;
+
+ /**
+ * The complete URL, including the params
+ * @type {string}
+ */
+ this.url = config.url || null;
+
+ /**
+ * The serverside vertical config id that this is referenced to.
+ * By providing this, enables dynamic sorting based on results.
+ * @type {string}
+ */
+ this.verticalKey = config.verticalKey || null;
+
+ /**
+ * Determines whether to show this tab in the navigation component
+ * @type {boolean}
+ */
+ this.hideInNavigation = config.hideInNavigation || false;
+
+ /**
+ * Determines whether to show this tab first in the order
+ * @type {boolean}
+ */
+ this.isFirst = config.isFirst || false;
+
+ /**
+ * Determines whether or not to apply a special class to the
+ * markup to determine if it's an active tab
+ * @type {boolean}
+ */
+ this.isActive = config.isActive || false;
+
+ /**
+ * URL of an icon
+ * @type {string}
+ */
+ this.iconUrl = config.iconUrl;
+
+ /**
+ * name of an icon from the default icon set
+ * @type {string}
+ */
+ this.icon = config.icon;
+ Object.freeze(this);
+ }
+
+ validate () {
+ }
+}
+
+export default class VerticalPagesConfig {
+ constructor (pages = []) {
+ this.verticalPagesConfig = VerticalPagesConfig.from(pages);
+ }
+
+ /**
+ * Using a getter that copies the data instead of providing a reference prevents it from being mutated.
+ * This is important for global configuration.
+ * @returns {Array<VerticalPageConfig>}
+ */
+ get () {
+ return this.verticalPagesConfig.map(page => ({ ...page }));
+ }
+
+ static from (pages) {
+ return pages.map(page => new VerticalPageConfig(page));
+ }
+}
+
import DirectAnswer from '../models/directanswer';
import Navigation from '../models/navigation';
import VerticalResults from '../models/verticalresults';
+import SpellCheck from '../models/spellcheck';
import StorageKeys from '../storage/storagekeys';
+import DynamicFilters from '../models/dynamicfilters';
+import LocationBias from '../models/locationbias';
+import AlternativeVerticals from '../models/alternativeverticals';
+import ResultsContext from '../storage/resultscontext';
/**
* A Data Transformer that takes the response object from a Search request
@@ -40,21 +45,56 @@
Source: core/search/searchdatatransformer.js
* component library and core storage understand.
*/
export default class SearchDataTransformer {
- static transform (data, urls = {}) {
- let response = data.response;
+ static transformUniversal (data, urls = {}, formatters) {
return {
- queryId: response.queryId,
- [StorageKeys.NAVIGATION]: Navigation.from(response.modules),
- [StorageKeys.DIRECT_ANSWER]: new DirectAnswer(response.directAnswer),
- [StorageKeys.UNIVERSAL_RESULTS]: UniversalResults.from(response, urls)
+ [StorageKeys.QUERY_ID]: data.queryId,
+ [StorageKeys.NAVIGATION]: Navigation.fromCore(data.verticalResults),
+ [StorageKeys.DIRECT_ANSWER]: DirectAnswer.fromCore(data.directAnswer, formatters),
+ [StorageKeys.UNIVERSAL_RESULTS]: UniversalResults.fromCore(data, urls, formatters),
+ [StorageKeys.SPELL_CHECK]: SpellCheck.fromCore(data.spellCheck),
+ [StorageKeys.LOCATION_BIAS]: LocationBias.fromCore(data.locationBias)
};
}
- static transformVertical (data) {
+ static transformVertical (coreResponse, formatters, verticalKey) {
+ const hasResults = coreResponse.verticalResults &&
+ coreResponse.verticalResults.results &&
+ coreResponse.verticalResults.resultsCount > 0;
+
+ let resultsContext = ResultsContext.NORMAL;
+ let response = coreResponse;
+ if (!hasResults) {
+ resultsContext = ResultsContext.NO_RESULTS;
+ response = SearchDataTransformer._reshapeForNoResults(coreResponse);
+ }
+
+ return {
+ [StorageKeys.QUERY_ID]: response.queryId,
+ [StorageKeys.NAVIGATION]: new Navigation(), // Vertical doesn't respond with ordering, so use empty nav.
+ [StorageKeys.VERTICAL_RESULTS]: VerticalResults.fromCore(
+ response.verticalResults, {}, formatters, resultsContext, verticalKey),
+ [StorageKeys.DYNAMIC_FILTERS]: DynamicFilters.fromCore(response.facets, resultsContext),
+ [StorageKeys.SPELL_CHECK]: SpellCheck.fromCore(response.spellCheck),
+ [StorageKeys.ALTERNATIVE_VERTICALS]: AlternativeVerticals.fromCore(response.alternativeVerticals, formatters),
+ [StorageKeys.LOCATION_BIAS]: LocationBias.fromCore(response.locationBias)
+ };
+ }
+
+ /**
+ * Form response as if the results from `allResultsForVertical` were the actual
+ * results in `results`
+ *
+ * @param {Object} response The server response
+ */
+ static _reshapeForNoResults (response) {
+ const allResultsForVertical = response.allResultsForVertical || {};
+ const { results, resultsCount } = allResultsForVertical.verticalResults || {};
return {
- queryId: data.response.queryId,
- [StorageKeys.NAVIGATION]: new Navigation(), // Veritcal doesn't respond with ordering, so use empty nav.
- [StorageKeys.VERTICAL_RESULTS]: VerticalResults.from(data.response)
+ ...response,
+ results: results || [],
+ resultsCount: resultsCount || 0,
+ verticalResults: allResultsForVertical.verticalResults,
+ facets: allResultsForVertical.facets
};
}
}
@@ -68,13 +108,13 @@
/** @typedef {import('../errors/errors').AnswersBaseError} AnswersBaseError */
+
+/**
+ * ErrorReporterService exposes an interface for reporting errors to the console
+ * and to a backend
+ *
+ * @interface
+ */
+export default class ErrorReporterService {
+ /**
+ * Reports an error to backend servers for logging
+ * @param {AnswersBaseError} err The error to be reported
+ */
+ report (err) {} // eslint-disable-line handle-callback-err
+}
+
/** @module ResultsContext */
+
+/**
+ * ResultsContext is an ENUM that provides context
+ * for the results that we are storing from server
+ * data
+ * @enum {string}
+ */
+export default {
+ NORMAL: 'normal',
+ NO_RESULTS: 'no-results'
+};
+
/** @module Storage */
-
-import ModuleData from './moduledata';
+
import DefaultPersistentStorage from '@yext/answers-storage';
import { AnswersStorageError } from '../errors/errors';
+import SearchParams from '../../ui/dom/searchparams';
+
+/** @typedef {import('./storagelistener').default} StorageListener */
/**
- * Storage is a container around application state.
- * It exposes an interface for CRUD operations as well as listening
+ * Storage is a container around application state. It
+ * exposes an interface for CRUD operations as well as listening
* for stateful changes.
+ *
+ * @param {Function} callback for state (persistent store) updates
+ * @param {Function} callback for state (persistent store) reset
*/
export default class Storage {
- constructor () {
- this._moduleDataContainer = {};
- this._futureListeners = {};
+ constructor (config = {}) {
+ /**
+ * The listeners for changes in state (persistent storage changes)
+ */
+ this.persistedStateListeners = {
+ update: config.updateListener || function () {},
+ reset: config.resetListener || function () {}
+ };
+
+ /**
+ * A hook for parsing values from persistent storage on init.
+ *
+ * @type {Function}
+ */
+ this.persistedValueParser = config.persistedValueParser;
+
+ /**
+ * The listener for window.pop in the persistent storage
+ *
+ * @param {Map<string, string>} queryParamsMap A Map containing the persisted state,
+ * for example a map of 'query' => 'virginia'
+ * @param {string} queryParamsString the url params of the persisted state
+ * for the above case 'query=virginia'
+ */
+ this.popListener = (queryParamsMap, queryParamsString) => {
+ this.persistedStateListeners.update(queryParamsMap, queryParamsString);
+ this.persistedStateListeners.reset(queryParamsMap, queryParamsString);
+ };
+
+ /**
+ * The core data for the storage
+ *
+ * @type {Map<string, *>}
+ */
+ this.storage = new Map();
+
+ /**
+ * The persistent storage implementation to store state
+ * across browser sessions and URLs
+ *
+ * @type {DefaultPersistentStorage}
+ */
+ this.persistentStorage = new DefaultPersistentStorage(this.popListener);
+
+ /**
+ * The listeners to apply on changes to storage
+ *
+ * @type {StorageListener[]}
+ */
+ this.listeners = [];
}
/**
- * Set the data in storage with the given key to the provided data,
- * completely overwriting any existing data.
- * @param {string} key the storage key to set
- * @param {*} data the data to set
+ * Decodes the initial state from the query params. This could be a
+ * direct mapping from query param to storage keys in the storage or
+ * could fetch a sessionId from some backend
+ *
+ * @param {string} url The starting URL
+ * @returns {Storage}
*/
- set (key, data) {
- this._initDataContainer(key, data);
- this._moduleDataContainer[key].set(data);
+ init (url) {
+ this.persistentStorage.init(url);
+ this.persistentStorage.getAll().forEach((value, key) => {
+ const parsedValue = this.persistedValueParser
+ ? this.persistedValueParser(key, value)
+ : value;
+ this.set(key, parsedValue);
+ });
+ return this;
}
- _initDataContainer (key, data) {
+ /**
+ * Set the data in storage with the given key to the provided
+ * data, completely overwriting any existing data.
+ *
+ * @param {string} key The storage key to set
+ * @param {*} data The data to set
+ */
+ set (key, data) {
if (key === undefined || key === null || typeof key !== 'string') {
- throw new AnswersStorageError('Invalid storage key provided', key, data);
- }
- if (data === undefined || data === null) {
- throw new AnswersStorageError('No data provided', key, data);
+ throw new AnswersStorageError('Storage key must be of type string', key, data);
}
- if (this._moduleDataContainer[key] === undefined) {
- this._moduleDataContainer[key] = new ModuleData(key);
- this._applyFutureListeners(key);
+ if (typeof data === 'undefined') {
+ throw new AnswersStorageError('Data cannot be of type undefined', key, data);
}
+
+ this.storage.set(key, data);
+ this._callListeners('update', key);
}
- getState (moduleId) {
- if (this._moduleDataContainer[moduleId]) {
- return this._moduleDataContainer[moduleId].raw();
+ /**
+ * Updates the storage with a new entry of [key, data]. The entry
+ * is not added to the URL until the history is updated.
+ *
+ * @param {string} key The storage key to set
+ * @param {*} data The data to set
+ */
+ setWithPersist (key, data) {
+ this.set(key, data);
+
+ let serializedData = data;
+ if (typeof data !== 'string') {
+ serializedData = JSON.stringify(data);
}
- return {};
+
+ this.persistentStorage.set(key, serializedData);
}
- getAll (key) {
- const data = [];
- for (const dataKey of Object.keys(this._moduleDataContainer)) {
- if (dataKey.startsWith(key) && this._moduleDataContainer[dataKey].raw() !== null) {
- data.push(this._moduleDataContainer[dataKey].raw());
- }
- }
- return data;
+ /**
+ * Adds all entries of the persistent storage to the URL,
+ * replacing the current history state.
+ */
+ replaceHistoryWithState () {
+ this.persistentStorage.replaceHistoryWithState();
+ this.persistedStateListeners.update(
+ this.persistentStorage.getAll(),
+ this.getCurrentStateUrlMerged()
+ );
}
- on (evt, moduleId, cb) {
- let moduleData = this._moduleDataContainer[moduleId];
- if (moduleData === undefined) {
- if (this._futureListeners[moduleId] === undefined) {
- this._futureListeners[moduleId] = [];
- }
+ /**
+ * Adds all entries of the persistent storage to the URL.
+ */
+ pushStateToHistory () {
+ this.persistentStorage.pushStateToHistory();
+ this.persistedStateListeners.update(
+ this.persistentStorage.getAll(),
+ this.getCurrentStateUrlMerged()
+ );
+ }
- this._futureListeners[moduleId].push({
- event: evt,
- cb: cb
- });
+ /**
+ * Get the current state for the provided key
+ *
+ * @param {string} key The storage key to get
+ * @return {*} The state for the provided key, undefined if key doesn't exist
+ */
+ get (key) {
+ return this.storage.get(key);
+ }
+
+ /**
+ * Get the current state for all key/value pairs in storage
+ *
+ * @return {Map<string, *>} mapping from key to value representing the current state
+ */
+ getAll () {
+ return new Map(this.storage);
+ }
- return;
+ /**
+ * Remove the data in storage with the given key
+ *
+ * @param {string} key The storage key to delete
+ */
+ delete (key) {
+ if (key === undefined || key === null || typeof key !== 'string') {
+ throw new AnswersStorageError('Storage key must be of type string', key);
}
- this._moduleDataContainer[moduleId].on(evt, cb);
- return this;
+ this.storage.delete(key);
+ this.persistentStorage.delete(key);
}
- off (evt, moduleId, cb) {
- let moduleData = this._moduleDataContainer[moduleId];
- if (moduleData === undefined) {
- if (this._futureListeners[moduleId] !== undefined) {
- this._futureListeners[moduleId].pop();
- }
+ /**
+ * Whether the specified key exists or not
+ *
+ * @param {string} key the storage key
+ * @return {boolean}
+ */
+ has (key) {
+ return this.storage.has(key);
+ }
- return this;
- }
+ /**
+ * Returns the url representing the current persisted state, merged
+ * with any additional query params currently in the url.
+ *
+ * @returns {string}
+ */
+ getCurrentStateUrlMerged () {
+ const searchParams = new SearchParams(window.location.search.substring(1));
+ this.persistentStorage.getAll().forEach((value, key) => {
+ searchParams.set(key, value);
+ });
+ return searchParams.toString();
+ }
- this._moduleDataContainer[moduleId].off(evt, cb);
- return this;
+ /**
+ * Returns the query parameters to encode the current state
+ *
+ * @return {string} The query parameters for a page link with the
+ * current state encoded
+ * e.g. query=all&context=%7Bkey:'hello'%7D
+ */
+ getUrlWithCurrentState () {
+ return this.persistentStorage.getUrlWithCurrentState();
}
- _applyFutureListeners (moduleId) {
- let futures = this._futureListeners[moduleId];
- if (!futures) {
- return;
+ /**
+ * Adds a listener to the given module for a given event
+ *
+ * @param {StorageListener} listener the listener to add
+ */
+ registerListener (listener) {
+ if (!listener.eventType || !listener.storageKey ||
+ !listener.callback || typeof listener.callback !== 'function') {
+ throw new AnswersStorageError(`Invalid listener applied in storage: ${listener}`);
}
+ this.listeners.push(listener);
+ }
- for (let i = 0; i < futures.length; i++) {
- let future = futures[i];
- this.on(future.event, moduleId, future.cb);
- }
- delete this._futureListeners[moduleId];
+ /**
+ * Removes a given listener from the set of listeners
+ *
+ * @param {StorageListener} listener the listener to remove
+ */
+ removeListener (listener) {
+ this.listeners = this.listeners.filter(l => l !== listener);
+ }
+
+ /**
+ * @param {string} eventType
+ * @param {string} storageKey
+ */
+ _callListeners (eventType, storageKey) {
+ this.listeners.forEach((listener) => {
+ if (listener.storageKey === storageKey && listener.eventType === eventType) {
+ listener.callback(this.get(storageKey));
+ }
+ });
}
}
/** @module */
+
+/**
+ * StorageIndexes is an ENUM are considered the root context
+ * for how data is stored and scoped in the storage.
+ *
+ * @enum {string}
+ */
+export default {
+ /**
+ * The global index that should contain all application
+ * specific globals for the application (e.g. search params)
+ */
+ GLOBAL: 'global',
+
+ /**
+ * The Navigation index contains all data to power the navigation component.
+ * Sometimes other components might depend directly on this as well, but
+ * we've opted to try to store some of that data in the global index instead.
+ */
+ NAVIGATION: 'navigation',
+
+ /**
+ * The Universal Results index for all data related to search results
+ * for universal search
+ */
+ UNIVERSAL_RESULTS: 'universal-results',
+
+ /**
+ * The Vertical Results index for all data related to search results
+ * for vertical search
+ */
+ VERTICAL_RESULTS: 'vertical-results',
+
+ /**
+ * The Autocomplete index contains state to power the auto complete component
+ * This data is powered by network requests for both vertical and universal.
+ */
+ AUTOCOMPLETE: 'autocomplete',
+
+ /**
+ * The direct answer index contains all the data to power the Direct Answer component
+ * Typically this index is powered from universal results, in the response to a search query
+ */
+ DIRECT_ANSWER: 'direct-answer',
+
+ /**
+ * The Filter index is the global source of truth for all filters on a page.
+ * It should contain all the latest state that is used for search.
+ */
+ FILTER: 'filter'
+};
+
/**
+ * The storage listener is a listener on changes to the SDK storage
+ */
+export default class StorageListener {
+ /**
+ * @param {string} eventType The type of event to listen for e.g. 'update'
+ * @param {string} storageKey The key to listen for e.g. 'Pagination'
+ * @param {Function} callback The callback to call when the event is emitted on the storage key
+ */
+ constructor (eventType, storageKey, callback) {
+ this.eventType = eventType;
+ this.storageKey = storageKey;
+ this.callback = callback;
+ }
+}
+
+/**
+ * Groups an array into an object using a given key and value function, and an initial object
+ * to add to. By default the key and value functions will not perform any transformations
+ * on the array elements.
+ * @param {Array<any>} arr array to be grouped
+ * @param {Function} keyFunc function that evaluates what key to give an array element.
+ * @param {Function} valueFunc function that evaluates what value to give an array element.
+ * @param {Object} intitial the initial object to add to, defaulting to {}
+ * @returns {Object}
+ */
+export function groupArray (arr, keyFunc, valueFunc, initial) {
+ keyFunc = keyFunc || (key => key);
+ valueFunc = valueFunc || (value => value);
+ return arr.reduce((groups, element, idx) => {
+ const key = keyFunc(element, idx);
+ const value = valueFunc(element, idx);
+ if (!groups[key]) {
+ groups[key] = [ value ];
+ } else {
+ groups[key].push(value);
+ }
+ return groups;
+ }, initial || {});
+}
+
/**
+ * Used to parse config options, defaulting to different synonyms and
+ * finally a default value. Option names with periods will be parsed
+ * as multiple child object accessors, i.e. trying to access 'first.second.option'
+ * will first look for config['first']['second']['option'].
+ *
+ * This is mostly needed for boolean config values, since boolean operators,
+ * which we commonly use for defaulting config options, do not work properly
+ * in those cases.
+ * @param {Object} config
+ * @param {Array<string>}
+ * @param {any} defaultValue
+ */
+export function defaultConfigOption (config, synonyms, defaultValue) {
+ for (let name of synonyms) {
+ const accessors = name.split('.');
+ let parentConfig = config;
+ let skip = false;
+ for (let childConfigAccessor of accessors.slice(0, -1)) {
+ if (!(childConfigAccessor in parentConfig)) {
+ skip = true;
+ break;
+ }
+ parentConfig = parentConfig[childConfigAccessor];
+ }
+ const configName = accessors[accessors.length - 1];
+ if (!skip && configName in parentConfig) {
+ return parentConfig[configName];
+ }
+ }
+ return defaultValue;
+}
+
import iterator from 'markdown-it-for-inline';
+import RtfConverter from '@yext/rtf-converter';
+import { AnswersCoreError } from '../errors/errors';
+
+/**
+ * This class leverages the {@link RtfConverter} library to perform Rich Text to
+ * HTML conversions.
+ */
+class RichTextFormatterImpl {
+ /**
+ * Generates an HTML representation of the provided Rich Text field value. Note that
+ * the HTML will contain a wrapper div. This is to support click analytics for Rich Text
+ * links.
+ *
+ * @param {string} fieldValue A Rich Text field value.
+ * @param {string} fieldName The name of the field, to be included in the payload of a click
+ * analytics event. This parameter is optional.
+ * @param {Object|string} targetConfig Configuration object specifying the 'target' behavior for
+ * the various types of links. If a string is provided, it is assumed that
+ * is the 'target' behavior across all types of links. This parameter is optional.
+ * @returns {string} The HTML representation of the field value, serialized as a string.
+ */
+ format (fieldValue, fieldName, targetConfig) {
+ if (typeof fieldValue !== 'string') {
+ throw new AnswersCoreError(
+ `Rich text "${fieldValue}" needs to be a string. Currently is a ${typeof fieldValue}`
+ );
+ }
+
+ const pluginName = this._generatePluginName();
+ RtfConverter.addPlugin(
+ iterator,
+ pluginName,
+ 'link_open',
+ (tokens, idx) => this._urlTransformer(tokens, idx, targetConfig));
+
+ fieldName = fieldName || '';
+ const html =
+ `<div class="js-yxt-rtfValue" data-field-name="${fieldName}">\n` +
+ `${RtfConverter.toHTML(fieldValue)}` +
+ '</div>';
+
+ // Because all invocations of this method share the same {@link RtfConverter}, we must make sure to
+ // disable the plugin added above. Otherwise, it will be applied in all subsequent conversions.
+ RtfConverter.disablePlugin(pluginName);
+
+ return html;
+ }
+
+ /**
+ * An inline token parser for use with the {@link iterator} Markdown-it plugin.
+ * This token parser adds a cta-type data attribute to any link it encounters.
+ */
+ _urlTransformer (tokens, idx, targetConfig) {
+ targetConfig = targetConfig || {};
+ let target;
+ if (typeof targetConfig === 'string') {
+ target = targetConfig;
+ }
+
+ const href = tokens[idx].attrGet('href');
+ let ctaType;
+ if (href.startsWith('mailto')) {
+ ctaType = 'EMAIL';
+ target = target || targetConfig.email;
+ } else if (href.startsWith('tel')) {
+ ctaType = 'TAP_TO_CALL';
+ target = target || targetConfig.phone;
+ } else {
+ ctaType = 'VIEW_WEBSITE';
+ target = target || targetConfig.url;
+ }
+
+ tokens[idx].attrSet('data-cta-type', ctaType);
+ target && tokens[idx].attrSet('target', target);
+ }
+
+ /**
+ * A function that generates a unique UUID to serve as the name for a
+ * Markdown-it plugin.
+ *
+ * @returns {string} the UUID.
+ */
+ _generatePluginName () {
+ function s4 () {
+ return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
+ }
+ return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
+ }
+}
+
+const RichTextFormatter = new RichTextFormatterImpl();
+export default RichTextFormatter;
+
+ Used to parse config options, defaulting to different synonyms and
+finally a default value. Option names with periods will be parsed
+as multiple child object accessors, i.e. trying to access 'first.second.option'
+will first look for config['first']['second']['option'].
+
+This is mostly needed for boolean config values, since boolean operators,
+which we commonly use for defaulting config options, do not work properly
+in those cases.
+
+ Checks whether a filter is equal to or included somewhere within the persistedFilter.
+Assumes the given filter is a simple filter, i.e. does not have any child filters.
+The persistedFilter can be either combined or simple.
+
+ Removes parameters for filters, facets, sort options, and pagination
+from the provided SearchParams. This is useful for constructing
+inter-experience answers links.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
params
+
+
+
+
+
+SearchParams
+
+
+
+
+
+
+
+
+
+
The parameters to remove from
+
+
+
+
+
+
+
getComponentNamesForComponentTypes
+
+
+
+
+
+function
+
+
+
+
+
+
+
+
+
+
Given string[]
+ component types, returns string[] component names for those types
+ Any calls of this method will be removed during a preprocessing step during SDK
+bundling.
+
+To support cases where someone may want to bundle without using our
+bundling tasks, this function attempts to return the same-language interpolated
+and pluralized value based on the information given.
+
+ Groups an array into an object using a given key and value function, and an initial object
+to add to. By default the key and value functions will not perform any transformations
+on the array elements.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
arr
+
+
+
+
+
+Array.<any>
+
+
+
+
+
+
+
+
+
+
array to be grouped
+
+
+
+
+
+
+
keyFunc
+
+
+
+
+
+function
+
+
+
+
+
+
+
+
+
+
function that evaluates what key to give an array element.
+
+
+
+
+
+
+
valueFunc
+
+
+
+
+
+function
+
+
+
+
+
+
+
+
+
+
function that evaluates what value to give an array element.
+ Decodes the initial state from the query params. This could be a
+direct mapping from query param to storage keys in the storage or
+could fetch a sessionId from some backend
+
+ Nest a value inside an object whose structure is defined by an array of keys
+
+Example: if `value` is 'Hello, world!', and `keys` is ['a', 'b'],
+the function will return the object:
+
+{
+ a: {
+ b: 'Hello, world!'
+ }
+}
+
+ Returns the passed in url with the passed in params appended as query params
+Note: query parameters in the url are stripped, you should include those query parameters
+in `params` if you want to keep them
+
The Answers Javascript API Library does not need to be installed locally. Instead, it can be used
+with script tags on a webpage. The instructions below explain how to do this; they will walk you through
+adding the Answers stylesheet, JS library, and an intialization script to an HTML page.
+After doing this, you can view your page in the browser.
Add an initialization script with an apiKey, experienceKey and onReady function. In the example below, we've initialized two
+basic components: SearchBar and UniversalResults.
The configuration provided here is configuration that is shared across components.
+
function initAnswers() {
+ ANSWERS.init({
+ // Required, your Yext Answers API key
+ apiKey: '<API_KEY_HERE>',
+ // Required, the key used for your Answers experience
+ experienceKey: '<EXPERIENCE_KEY_HERE>',
+ // Optional, initialize components here, invoked when the Answers component library is loaded/ready.
+ // If components are not added here, they can also be added when the init promise resolves
+ onReady: function() {},
+ // Optional*, Yext businessId, *required to send analytics events
+ businessId: 'businessId',
+ // Optional, if false, the library will not fetch pre-made templates. Only use change this to false if you provide a
+ // template bundle in the `templateBundle` config option or implement custom renders for every component
+ useTemplates: true,
+ // Optional, additional templates to register with the renderer
+ templateBundle: {},
+ // Optional, provide configuration for each vertical that is shared across components, see Vertical Pages Configuration below
+ verticalPages: [],
+ // Optional, search specific settings, see Search Configuration below
+ search: {},
+ // Optional, vertical no results settings, see Vertical No Results below
+ noResults: {},
+ // Optional, the locale will affect how queries are interpreted and the results returned. Defaults to 'en'.
+ locale: 'en',
+ // Optional, the Answers Experience version to use for api requests
+ experienceVersion: 'PRODUCTION',
+ // Optional, prints full Answers error details when set to `true`. Defaults to false.
+ debug: false,
+ // Optional, If true, the search session is tracked. If false, there is no tracking. Defaults to true.
+ sessionTrackingEnabled: true,
+ // Optional, invoked when the state of any component changes
+ onStateChange: function() {},
+ // Optional, analytics callback after a vertical search, see onVerticalSearch Configuration for additional details
+ onVerticalSearch: function() {},
+ // Optional, analytics callback after a universal search, see onUniversalSearch Configuration for additional details
+ onUniversalSearch: function() {},
+ // Optional, opt-out of automatic css variable resolution on init for legacy browsers
+ disableCssVariablesPonyfill: false,
+ // Optional, the analytics key describing the Answers integration type. Accepts 'STANDARD' or 'OVERLAY', defaults to 'STANDARD'
+ querySource: 'STANDARD',
+ })
+}
+
+
Vertical Pages Configuration
+
Below is a list of configuration options related to vertical pages in navigation and no results, used in the base configuration above.
+
verticalPages: [
+ {
+ // Required, the label for this page
+ label: 'Home',
+ // Required, the link to this page
+ url: './index.html',
+ // Optional*, the verticalKey, *required for vertical pages (must omit this property for universal)
+ verticalKey: 'locations',
+ // Optional, the icon name to use in no results, defaults to no icon
+ icon: 'star',
+ // Optional, the URL icon to use in no results, defaults to no icon
+ iconUrl: '',
+ // Optional, if true, will show this page first in the Navigation Component, defaults to false
+ isFirst: false,
+ // Optional, if true, will add a special styling to this page in the Navigation Component, defaults to false
+ isActive: false,
+ // Optional, if true, hide this tab in the Navigation Component, defaults to false
+ hideInNavigation: false,
+ },
+ ...
+]
+
+
Search Configuration
+
Below is a list of configuration options related to search, used in the base configuration above.
+
search: {
+ // Optional, the vertical key to use for searches
+ verticalKey: 'verticalKey',
+ // Optional, the number of results to display per page, defaults to 20. Maximum is 50.
+ limit: '20',
+ // Optional, Vertical Pages only, a default search to use on page load when the user hasn't provided a query
+ defaultInitialSearch: 'What is Yext Answers?',
+ },
+
+
Vertical No Results Configuration
+
Below is a list of configuration options related to no results on Vertical Pages, used in the base configuration above.
+
noResults: {
+ // Optional, whether to display all results for the Vertical when a query has no results, defaults to false
+ displayAllResults: false,
+ // Optional, a custom template for the no results card
+ template: '',
+ },
+
+
onVerticalSearch Configuration
+
The onVerticalSearch Configuration is a function, used in the base configuration above.
+
It allows you to send an analytics event each time a search is run on a Vertical page. This function should take in one parameter, searchParams, which contains information about the search, and return the desired analytics event.
+
Like all Answers Javascript API Library analytics, this will only work if there is a businessId in the ANSWERS.init.
+
The search information exposed in searchParams is shown below.
+
function (searchParams) => {
+ /**
+ * Vertical key used for the search.
+ * @type {string}
+ */
+ const verticalKey = searchParams.verticalKey;
+
+ /**
+ * The string being searched for.
+ * @type {string}
+ */
+ const queryString = searchParams.queryString;
+
+ /**
+ * The total number of results found.
+ * @type {number}
+ */
+ const resultsCount = searchParams.resultsCount;
+
+ /**
+ * Either 'normal' or 'no-results'.
+ * @type {string}
+ */
+ const resultsContext = searchParams.resultsContext;
+
+ let analyticsEvent = new ANSWERS.AnalyticsEvent('ANALYTICS_EVENT_TYPE');
+ analyticsEvent.addOptions({
+ label: 'Sample analytics event',
+ searcher: 'VERTICAL',
+ query: queryString,
+ resultsCount: resultsCount,
+ resultsContext: resultsContext,
+ });
+ return analyticsEvent;
+ },
+}),
+
+
onUniversalSearch Configuration
+
The onUniversalSearch Configuration is a function, used in the base configuration above.
+
It allows you to send an analytics event each time a search is run
+on a Universal page. This function should take in one parameter, searchParams, which contains information about the
+search, and return the desired analytics event.
+
Like all Answers Javascript API Library analytics, this will only work if there is a businessId in the ANSWERS.init.
+
The search information exposed in searchParams is shown below.
+
function (searchParams) => {
+ /**
+ * The string being searched for.
+ * @type {string}
+ */
+ const queryString = searchParams.queryString;
+
+ /**
+ * The total number of results found.
+ * @type {number}
+ */
+ const sectionsCount = searchParams.sectionsCount;
+
+ /**
+ * A map containing entries of the form:
+ * { totalResultsCount: 150, displayedResultsCount: 10}
+ * for each returned vertical. The totalResultsCount indicates how many results
+ * are present in the vertical. The displayResultsCount indicates how many of
+ * those results are actually displayed.
+ * @type {Object<string,Object>}
+ */
+ const resultsCountByVertical = searchParams.resultsCountByVertical;
+
+ let analyticsEvent = new ANSWERS.AnalyticsEvent('ANALYTICS_EVENT_TYPE');
+ analyticsEvent.addOptions({
+ type: 'ANALYTICS_EVENT_TYPE',
+ label: 'Sample analytics event',
+ searcher: 'UNIVERSAL',
+ query: queryString
+ sectionsCount: sectionsCount,
+ });
+ return analyticsEvent;
+ },
+}),
-
You can learn more about the interface for registering helpers by taking a look at the Handlebars Block Helpers documentation.
Component Usage
The Answers Component Library exposes an easy to use interface for adding and customizing various types of UI components on your page.
-
Every component requires a containing HTML element.
+
What is a Component?
+
At a high level, components are the individual pieces of an Answers page. The Answers Javascript API Library comes with many types of components. Each component is an independent, reusable piece of code. A component fills an HTML element container that the implementer provides on the page. Components are updated from their config, the config from the ANSWERS.init, and potentially an API response.
+
Each type of Component has its own custom configurations. Additionally, all components share the base configuration options defined above. We will provide a brief description below of what each component does, along with describing how it can be configured.
Base Component Configuration
Every component has the same base configuration options.
-
-
-
-
option
-
type
-
description
-
required
-
-
-
-
-
name
-
string
-
a unique name, if using multiple components of the same type
-
optional
-
-
-
container
-
string
-
the CSS selector to append the component.
-
required
-
-
-
class
-
string
-
a custom class to apply to the component
-
not required
-
-
-
template
-
string
-
override internal handlebars template
-
not required
-
-
-
render
-
function
-
override render function. data provided
-
not required
-
-
-
transformData
-
function
-
A hook for transforming data before it gets sent to render
-
not required
-
-
-
onMount
-
function
-
invoked when the HTML is mounted to the DOM
-
not required
-
-
-
-
Adding a Component
+
{
+ // Required, the selector for the container element where the component will be injected
+ container: 'container',
+ // Optional, a unique name for the component
+ name: 'name',
+ // Optional, an additional, custom HTML classname for the component. The component will also
+ // have a classname of 'yxt-Answers-component' applied.
+ class: 'class',
+ // Optional, handlebars template or HTML to override built-in handlebars template for the component
+ template: 'template',
+ // Optional, override render function
+ render: function(data) {},
+ // Optional, a hook for transforming data before it gets sent to render
+ transformData: function(data) {},
+ // Optional, invoked when the HTML is mounted to the DOM, this will not override any built-in onMount function for a component
+ onMount: function(data) {},
+ // Optional, invoked when the HTML is mounted to the DOM, this will override any built-in onMount function for a component
+ onMountOverride: function(data) {},
+ // Optional, additional properties to send with every analytics event
+ analyticsOptions: {},
+ }
+
+
Adding a Component to Your Page
Adding a component to your page is super easy!
-You can add many different types of components to your page.
+You can add many different types of components to your page.
Each component supports the base configuration options above, as well as their own unique configurations.
To start, every component requires an HTML container.
<div class="search-container"></div>
-
Then, you can add a component to your page through the ANSWERS add interface.
All component templates are written using handlebars.
-
It's easy to override these templates with your own templates.
-Keep in mind, that you must provide valid handlebars syntax here.
-
// Use handlebars syntax to create a template string
-let customTemplate = `<div class="my-search">{{title}}</div>`
-
-ANSWERS.addComponent('SearchBar', {
- container: '.search-container',
- template: customTemplate
-})
-
-
Using a Custom Renderer
-
If you want to use a use your own template language (e.g. soy, mustache, groovy, etc),
-you should NOT use the template argument. Instead, you can provide a custom render function to the component.
+
Removing Components
+
If you'd like to remove a component and all of its children, you can do it. Simply ANSWERS.removeComponent(<component name>):
ANSWERS.addComponent('SearchBar', {
container: '.search-container',
- render: function(data) {
- // Using native ES6 templates -- but you can replace this with soy,
- // or any other templating language as long as it returns a string.
- return `<div class="my-search">${data.title}</div>`
- }
+ name: 'MySearchBar'
})
+
+ANSWERS.removeComponent('MySearchBar');
-
Custom Data Transforms
-
If you want to mutate the data thats provided to the render/template before it gets rendered,
-you can use the transformData hook.
-
All properties and values that you return from here will be accessible from templates.
+
Types of Built-in Components
+
SearchBar Component
+
The SearchBar component is the main entry point for search querying. It provides the input box, where the user
+types their query, as well as the autocomplete behavior.
+
<div class="search-query-container"></div>
+
+
If the verticalKey config option is omitted, the SearchBar will perform Universal searches. Universal
+searches return results across multiple Verticals; Vertical searches search within one Vertical. Additionally, Universal search and Vertical search provide a different way of auto complete.
ANSWERS.addComponent('SearchBar', {
- container: '.search-container',
- transformData: (data) => {
- // Extend/overide the data object
- return Object.assign({}, data, {
- title: data.title.toLowerCase()
- })
+ // Required, the selector for the container element where the component will be injected
+ container: '.search-query-container',
+ // Required* for Vertical pages, omit for Universal pages
+ verticalKey: '<VERTICAL_KEY>',
+ // Optional, title is not present by default
+ title: 'Search my Brand',
+ // Optional, the initial query string to use for the input box
+ query: 'query',
+ // Optional, defaults to 'Conduct a search'
+ labelText: 'What are you looking for?',
+ // Optional, used for labeling the submit button, also provided to the template
+ submitText: 'Submit',
+ // Optional, used for labeling the clear button, also provided to the template
+ clearText: 'Clear',
+ // Optional, used to specify a different built-in icon for the submit button. Defaults to Animated Magnifying glass when CSS is included.
+ submitIcon: 'iconName',
+ // Optional, a url for a custom icon for the submit button. Defaults to Animated Magnifying glass when CSS is included.
+ customIconUrl: 'path/to/icon',
+ // Optional, the query text to show as the first item for auto complete
+ promptHeader: 'Header',
+ // Optional, no default
+ placeholderText: 'Start typing...',
+ // Optional, auto focuses the search bar. Defaults to false
+ autoFocus: false,
+ // Optional, opens the autocomplete suggestions on page load. Defaults to false. Requires autoFocus to be set to true
+ autocompleteOnLoad: false,
+ // Optional, allows a user to conduct an empty search. Should be set to true if the defaultInitialSearch is "".
+ allowEmptySearch: false,
+ // Optional, defaults to 300ms (0.3 seconds)
+ searchCooldown: 2000,
+ // Optional, asks the user for their geolocation when "near me" intent is detected
+ promptForLocation: true,
+ // Optional, displays an "x" button to clear the current query when true
+ clearButton: true,
+ // Optional, redirect search query to url
+ redirectUrl: 'path/to/url',
+ // Optional, target frame for the redirect url, defaults to current frame. Expects a valid target: "_blank", "_self", "_parent", "_top" or the name of a frame
+ redirectUrlTarget: '_self',
+ // Optional, defaults to native form node within container
+ formSelector: 'form',
+ // Optional, defaults to true. When true, a form is used as the query submission context.
+ // Note that WCAG compliance is not guaranteed if a form is not used as the context.
+ useForm: 'true',
+ // Optional, the input element used for searching and wires up the keyboard interaction
+ inputEl: '.js-yext-query',
+ // Optional, options to pass to the geolocation api, which is used to fetch the user's current location.
+ // https://developer.mozilla.org/en-US/docs/Web/API/PositionOptions
+ geolocationOptions: {
+ // Optional, whether to improve accuracy at the cost of response time and/or power consumption, defaults to false.
+ enableHighAccuracy: false,
+ // Optional, the maximum amount of time (in ms) a geolocation call is allowed to take before defaulting, defaults to 1 second.
+ timeout: 1000,
+ // Optional, the maximum amount of time (in ms) to cache a geolocation call, defaults to 5 minutes.
+ maximumAge: 300000,
},
- render: function(data) {
- // Using native ES6 templates -- but you can replace this with soy,
- // or any other templating language as long as it returns a string.
- return `<div class="my-search">${data.title}</div>`
+ // Optional, options for an alert when the geolocation call fails.
+ geolocationTimeoutAlert: {
+ // Optional, whether to display a window.alert() on the page, defaults to false.
+ enabled: false,
+ // Optional, the message in the alert. Defaults to the below
+ message: "We are unable to determine your location"
+ },
+ // Optional, functions invoked when certain events occur
+ customHooks: {
+ // Optional, a callback invoked when the clear search button is clicked
+ onClearSearch: function() {},
+ // Optional, a function invoked when a search is conducted. The search terms are passed in as a string
+ onConductSearch: function(searchTerms) {}
+ },
+ // Optional, options to pass to the autocomplete component
+ autocomplete: {
+ // Optional, boolean used to hide the autocomplete when the search input is empty (even if the
+ // input is focused). Defaults to false.
+ shouldHideOnEmptySearch: false,
+ // Optional, callback invoked when the autocomplete component changes from open to closed.
+ onClose: function() {},
+ // Optional, callback invoked when the autocomplete component changes from closed to open.
+ onOpen: function() {},
}
})
-
Types of Components
-
Each type of Component has it's own custom configurations. However, all components share the
-base configuration options defined above.
-
Navigation Component
-
The Navigation Component adds a dynamic experience to your pages navigation experience.
-When using multiple veritcal searches in a universal search, the navigation ordering will be automatically
-updated based on the search results.
-
<nav class="navigation-container"></nav>
+
Direct Answer Component
+
This component is for Universal pages only.
+
The Direct Answer Component will render the BEST result, if found, based on the query.
ANSWERS.addComponent('DirectAnswer', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.direct-answer-container',
+ // Optional, a custom direct answer card to use, which is the default when there are no matching card overrides.
+ // See the Custom Direct Answer Card section below.
+ defaultCard: 'MyCustomDirectAnswerCard',
+ // Optional, the selector for the form used for submitting the feedback
+ formEl: '.js-directAnswer-feedback-form',
+ // Optional, the selector to bind ui interaction to for reporting
+ thumbsUpSelector: '.js-directAnswer-thumbUp',
+ // Optional, the selector to bind ui interaction to for reporting
+ thumbsDownSelector: '.js-directAnswer-thumbDown',
+ // Optional, the display text for the View Details click to action link
+ viewDetailsText: 'View Details',
+ // Optional, the screen reader text for positive feedback on the answer
+ positiveFeedbackSrText: 'This answered my question',
+ // Optional, the screen reader text for negative feedback on the answer
+ negativeFeedbackSrText: 'This did not answer my question',
+ // Optional, the footer text to display on submission of feedback
+ footerTextOnSubmission: 'Thank you for your feedback!',
+ // Optional, specify card types and overrides based on the direct answer type. The first matching cardOverride will be used, otherwise the cardType is used
+ types: {
+ 'FEATURED_SNIPPET': {
+ cardType: "documentsearch-standard",
+ cardOverrides: [
+ {
+ fieldName: 'description',
+ entityType: 'ce_menuItem',
+ cardType: 'MenuItemDescriptionDirectAnswer'
+ },
+ {
+ fieldName: 'description',
+ entityType: 'ce_menuItem',
+ fieldType: 'rich_text'
+ cardType: 'MenuItemDescriptionDirectAnswer'
+ }
+ ]
+ },
+ 'FIELD_VALUE': {
+ cardType: "allfields-standard",
+ cardOverrides: [
+ {
+ cardType: 'MenuItemDescriptionDirectAnswer',
+ fieldName: 'description',
+ entityType: 'ce_menuItem',
+ fieldType: 'rich_text'
+ }
+ ]
+ }
+ }
+ // DEPRECATED: use the types option instead
+ // Optional, card overrides that allow you to specify a specific direct answers card depending on the fieldName, entityType, and fieldType of the direct answer. The first matching card will be used, otherwise defaultCard will be used.
+ cardOverrides: [
{
- label: 'Home', // The label used for the navigation element
- url: './index.html', // The link for the navigation element
- isFirst: true, // optional, will always show this item first
- isActive: true // optional, will add a special class to the item
+ cardType: 'MenuItemDescriptionDirectAnswer',
+ fieldName: 'description',
+ entityType: 'ce_menuItem',
+ fieldType: 'rich_text'
},
{
- configId: 'locations' // optional, the vertical search config id
- label: 'Location' // The label used for the navigation element
- url: 'locations.html' // The link for the navigation element
+ cardType: 'DeliveryHoursDirectAnswer',
+ fieldName: 'c_deliveryHours'
},
{
- configId: 'employees' // optional, the vertical search config id
- label: 'Employees' // The label used for the navigation element
- url: 'employees.html' // The link for the navigation element
+ cardType: 'PhoneDirectAnswer',
+ fieldType: 'phone'
}
]
})
-
SearchBar Component
-
The SearchBar component is the main entry point for search querying. It provides the input box, where the user
-types their query, as well as the autocomplete behavior.
-
<div class="search-query-container"></div>
+
Creating a Custom Direct Answer Card
+
You can customize the look and behavior of your Direct Answer by creating a custom Direct Answer card.
+
A custom Direct Answer card is given the same data as the built-in card.
+That data will look something like the below:
This is the javascript class for our custom Direct Answer card.
+It applies custom formatting to the Direct Answer, registers analytics events
+to the thumbs up/down icons, and passes custom event options into the template.
+
class CustomDirectAnswerClass extends ANSWERS.Component {
+ constructor(config, systemConfig) {
+ // If you need to override the constructor, make sure to call super(config, systemConfig) first.
+ super(config, systemConfig);
+
+ // For simplicity's sake, we set this card's template using setTemplate(), as opposed to
+ // a custom template bundle.
+ this.setTemplate(`<div> your template here </div>`)
+ }
+
+ /**
+ * setState() lets you pass variables directly into your template.
+ * Here, data is the directAnswer data from the query.
+ * Below, we pass through a custom direct answers value, customValue.
+ * @param {Object} data
+ * @returns {Object}
+ */
+ setState(data) {
+ const { type, answer, relatedItem } = data;
+ const associatedEntityId = data.relatedItem && data.relatedItem.data && data.relatedItem.data.id;
+ const verticalConfigId = data.relatedItem && data.relatedItem.verticalConfigId;
+ return super.setState({
+ ...data,
+ customValue: this.getCustomValue(answer),
+ eventType: 'CUSTOM_EVENT',
+ eventOptions: {
+ searcher: 'UNIVERSAL',
+ verticalConfigId: verticalConfigId,
+ entityId: associatedEntityId,
+ }
+ });
+ }
+
+ /**
+ * onMount() lets you register event listeners. Here, we register the thumbs up and thumbs
+ * down buttons to fire an analytics event on click.
+ */
+ onMount() {
+ const thumbsUpIcon = this._container.querySelector('.js-customDirectAnswer-thumbsUpIcon');
+ const thumbsDownIcon = this._container.querySelector('.js-customDirectAnswer-thumbsDownIcon');
+ thumbsUpIcon.addEventListener('click', () => this.reportQuality(true));
+ thumbsDownIcon.addEventListener('click', () => this.reportQuality(false));
+ }
+
+ /**
+ * reportQuality() sends an analytics event (either THUMBS_UP or THUMBS_DOWN).
+ * @param {boolean} isGood true if the answer is what you were looking for
+ */
+ reportQuality(isGood) {
+ const eventType = isGood === true ? 'THUMBS_UP' : 'THUMBS_DOWN';
+ const event = new ANSWERS.AnalyticsEvent(eventType).addOptions({
+ directAnswer: true
+ });
+ this.analyticsReporter.report(event);
+ }
+
+ /**
+ * Formats a Direct Answer value based on its fieldType.
+ * @param {Object} answer the answer property in the directAnswer model
+ * @returns {string}
+ */
+ formatValue(answer) {
+ const { fieldType, value } = answer;
+ switch (fieldType) {
+ case 'phone':
+ return {
+ url: 'http://myCustomWebsite.com/?mainPhone=' + value,
+ displayText: value,
+ };
+ case 'rich_text':
+ return ANSWERS.formatRichText(value);
+ case 'single_line_text':
+ case 'multi_line_text':
+ default:
+ return value;
+ }
+ }
+
+ /**
+ * Computes a custom Direct Answer. If answer.value is an array, this method
+ * formats every value in the array and returns it, otherwise it just formats the single
+ * given value.
+ * @param {Object} answer
+ * @returns {Array<string>}
+ */
+ getCustomValue(answer) {
+ if (Array.isArray(answer.value)) {
+ return answer.value.map(value => this.formatValue(answer))
+ } else {
+ return [ this.formatValue(answer) ];
+ }
+ }
+
+ /**
+ * The name of your custom direct answer card. THIS is the value you will use in any config,
+ * such as defaultCard, when you want to specify this custom Direct Answer card.
+ * @returns {string}
+ */
+ static get type() {
+ return 'MyCustomDirectAnswerCard';
+ }
+ }
+
+ // Don't forget to register your Direct Answer card within the SDK. Otherwise the SDK won't recognize your card name!
+ ANSWERS.registerComponentType(CustomDirectAnswerClass);
+
+
Universal Results Component
+
The Universal Results component will render the results of a query,
+across all configured verticals, with one section per vertical.
+
<div class="universal-results-container"></div>
+
+
ANSWERS.addComponent('UniversalResults', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.universal-results-container',
+ // Settings for the applied filters bar in the results header. These settings can be overriden in the
+ // "config" option below on a per-vertical basis.
+ appliedFilters: {
+ // If true, show any applied filters that were applied to the universal search. Defaults to true
+ show: true,
+ // If appliedFilters.show is true, whether to display the field name of an applied filter, e.g. "Location: Virginia" vs just "Virginia". Defaults to false.
+ showFieldNames: false,
+ // If appliedFilters.show is true, this is list of filters that should not be displayed.
+ // By default, builtin.entityType will be hidden
+ hiddenFields: ['builtin.entityType'],
+ // The character that separates the count of results (e.g. “1-6”) from the applied filter bar. Defaults to '|'
+ resultsCountSeparator: '|',
+ // Whether to display the change filters link in universal results. Defaults to false.
+ showChangeFilters: false,
+ // The text for the change filters link. Defaults to 'change filters'.
+ changeFiltersText: 'change filters',
+ // The character that separates each field (and its associated filters) within the applied filter bar. Defaults to '|'
+ delimiter: '|',
+ // The aria-label given to the applied filters bar. Defaults to 'Filters applied to this search:'.
+ labelText: 'Filters applied to this search:',
+ },
+ // Optional, configuration for each vertical's results
+ config: {
+ people: { // The verticalKey
+ card: {
+ // Configuration for the cards in this vertical, see Cards
+ },
+ // Optional: A custom handlebars template for this section
+ template: '<div> Custom section template </div>',
+ // The title of the vertical
+ // Defaults to the vertical key, in this example 'people'
+ title: 'People',
+ // Icon to display to the left of the title. Must be one of our built-in icons, defaults to 'star'
+ icon: 'star',
+ // The url for both the viewMore link and the change-filters link. Defaults to '{{VERTICAL_KEY}}.html',
+ // in this case that is 'people.html'
+ url: 'people.html',
+ // Whether to display a view more link. Defaults to true
+ viewMore: true,
+ // The text for the view more link, if viewMore is true. Defaults to 'View More'
+ viewMoreLabel: 'View More!',
+ // Config for the applied filters bar in the results header.
+ appliedFilters: {
+ // Same as appliedFilters settings above. Settings specified here will override any top level settings.
+ },
+ // If true, display the count of results at the very top of the results. Defaults to false.
+ showResultCount: true,
+ // If true, display the total number of results. Defaults to true
+ // Optional, whether to use the AccordionResults component instead of VerticalResults for this vertical
+ useAccordion: false,
+ // Optional, whether to include a map with this vertical's results, defaults to false
+ includeMap: true,
+ // Optional*, if includeMap is true, this is required
+ mapConfig: {
+ // Required, either 'mapbox' or 'google', not case sensitive
+ mapProvider: 'google',
+ // Required, API key for the map provider
+ apiKey: '<<< enter your api key here >>>',
+ // ... Optional, any other config for the Map Component, find more info in the section "Map Component"
+ },
+ // Optional, override the render function for each result in this vertical
+ renderItem: function(data) {},
+ // Optional, override the handlebars template for each item in this vertical
+ itemTemplate: `my item {{name}}`,
+ // DEPRECATED, please use viewMoreLabel instead. viewAllText is a synonym for viewMoreLabel, where viewMoreLabel takes precedence over viewAllText. Defaults to 'View More'.
+ viewAllText: 'View All Results For Vertical'
+ }
+ },
+ // Optional, override the render function for each item in the result list
+ renderItem: function(data) {},
+ // Optional, override the handlebars template for each item in the result list
+ itemTemplate: `my item {{name}}`,
})
The Vertical Results component shares all the same configurations from Universal Results, but you don't need to specifiy a config or context. You may limit the number of search results returned, with a maximum of 50.
+
You define all the options at the top level object.
+
<div class="vertical-results-container"></div>
+
+
ANSWERS.addComponent('VerticalResults', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.vertical-results-container',
+ // Optional, function to give each result item custom rendering
+ renderItem: () => {},
+ // Optional, string to give custom template to result item
+ itemTemplate: `<div> Custom template </div>`,
+ // Optional, set a max number of columns to display at the widest breakpoint. Possible values are 1, 2, 3 or 4, defaults to 1
+ maxNumberOfColumns: 1,
+ // Optional, a modifier that will be appended to a class on the results list like this `yxt-Results--{modifier}`
+ modifier: '',
+ // Optional, whether to hide the default results header that VerticalResults provides. Defaults to false.
+ hideResultsHeader: false,
+ // Optional, the card used to display each individual result, see the Cards section for more details,
+ card: {
+ // Optional, The type of card, built-in types are: 'Standard', 'Accordion', and 'Legacy'. Defaults to 'Standard'
+ cardType: 'Standard',
+ // Optional, see Data Mappings for more details
+ dataMappings: () => {},
+ // Optional, see Calls To Action for more details
+ callsToAction: () => []
+ },
+ // Optional, configuration for what to display when no results are found.
+ noResults: {
+ // Optional, used to specify a custom template for the no results card, defaults to a built-in template.
+ template: '<div> <em>No results found!</em> Try again? </div>',
+ // Optional, whether to display all results in the vertical when no results are found. Defaults to false, in which case only the no results card will be shown.
+ displayAllResults: false
+ },
+
+ /**
+ * NOTE: The config options below are DEPRECATED.
+ * They will still work as expected, and the defaults will still be applied,
+ * but future major versions of the SDK will remove them.
+ * We recommend setting hideResultsHeader to true, and using the VerticalResultsCount and AppliedFilters components instead.
+ */
+ // Optional, whether to display the total number of results, default true
+ showResultCount: true,
+ // Optional, a custom template for the results count. You can specify the variables resultsCountStart, resultsCountEnd, and resultsCount.
+ resultsCountTemplate: '<div>{{resultsCountStart}} - {{resultsCountEnd}} of {{resultsCount}}</div>',
+ // Configuration for the applied filters bar in the header.
+ appliedFilters: {
+ // If true, show any applied filters that were applied to the vertical search. Defaults to true
+ show: true,
+ // If appliedFilters.show is true, whether to display the field name of an applied filter, e.g. "Location: Virginia" vs just "Virginia". Defaults to false.
+ showFieldNames: false,
+ // If appliedFilters.show is true, this is list of filters that should not be displayed.
+ // By default, builtin.entityType will be hidden
+ hiddenFields: ['builtin.entityType'],
+ // The character that separates the count of results (e.g. “1-6”) from the applied filter bar. Defaults to '|'
+ resultsCountSeparator: '|',
+ // If the filters are shown, whether or not they should be removable buttons. Defaults to false.
+ removable: false,
+ // The character that separates each field (and its associated filters) within the applied filter bar. Defaults to '|'
+ delimiter: '|',
+ // The aria-label given to the applied filters bar. Defaults to 'Filters applied to this search:'.
+ labelText: 'Filters applied to this search:',
+ // The aria-label given to the removable filter buttons.
+ removableLabelText: 'Remove this filter'
+ }
})
-
FilterSearch Component
-
The FilterSearch component provides a text input box for users to type a query and select a preset matching filter. When a filter is selected, a vertical search is performed. If multiple FilterSearch components are on the page, the search will include all selected filters across all of the components.
-
<div class="filter-search-container"></div>
+
Vertical Results Count Component
+
The results count component displays the current results count on a vertical page.
+
ANSWERS.addComponent('VerticalResultsCount', {
+ container: '.results-count-container',
+ noResults: {
+ // Optional, whether the results count should be visible when displaying no results.
+ // Defaults to false.
+ visible: false
+ }
+});
Cards are used in Universal/Vertical Results for configuring the UI for a result on a per-item basis.
+
Cards take in a dataMappings attribute, which contains configuration for the card, and a callsToAction
+attribute, which contains config for any callToAction buttons in the card.
+
callsToAction config is common throughout all cards, whereas different cards such as Standard vs BigImage
+have specialized configuration depending on the card. See Calls To Action
callsToActions are specified as either an array of CTA configs, or a function that returns
+an array of CTA configs. An array of CTA configs is an object of either static config options
+or functions that return the desired config option.
+
Examples are detailed below.
+
Note: A CTA without both a label and icon will not be rendered.
+
+
an array of static CTA config objects
+
+
const callsToAction = [{
+ // Label below the CTA icon, default null
+ label: 'cta label',
+ // Icon name for the CTA that is one of the built-in icons, defaults to undefined (no icon). If your icon
+ // is not recognized it will default to 'star'.
+ icon: 'star',
+ // URL to a custom icon for the cta. This takes priority over icon if both are present, default is
+ // no icon url.
+ iconUrl: 'https://urltomyicon.com/customicon.gif',
+ // Click through url for the icon and label
+ // Note, a protocol like https:// is required here.
+ url: 'https://yext.com',
+ // Analytics event that should fire, defaults to 'CTA_CLICK'. Other events outlined in the Analytics section.
+ analytics: 'CTA_CLICK',
+ // The target attribute for the CTA link, defaults to '_blank'. To open in a new window use '_blank'
+ target: '_blank',
+ // The eventOptions needed for the event to fire. Either a valid json string, an object, or a function that
+ // takes in the result data response.
+ // By default, if no event options are specified the SDK will try to add verticalKey, entityId, and searcher options
+ // to the analytics event.
+ eventOptions: result => {
+ return {
+ // The vertical key for the CTA. If unspecified, this defaults to the vertical key this cta is a part of
+ verticalKey: 'people',
+ // The entity id of the result this cta is a part of, defaults to the entityId field in Knowledge Graph
+ entityId: result.id,
+ // If the CTA is inside a vertical search, defaults to the value "VERTICAL",
+ // if is inside a universal search, defaults to the value "UNIVERSAL"
+ searcher: 'VERTICAL'
+ };
+ }
+}]
+
+
+
as a function that returns a cta config object.
+NOTE: we do not allow multiple nested functions, to avoid messy user configurations.
The dataMappings config option define how a card's attributes, such as title and details, will be rendered.
+They can be configured either through a function that returns a dataMappings object
+or a static dataMappings object.
+
Each attribute of a dataMappings object is also either a function or a static value.
The data mappings for a Standard Card has these attributes
+
const dataMappings = item => {
+ return {
+ // Title for the card, defaults to the name of the entity
+ title: item.title,
+ // Subtitle, defaults to null
+ subtitle: `Department: ${item.name} `,
+ // Details, defaults to the entity's description
+ details: item.description,
+ // Image to display, defaults to null
+ image: item.headshot ? item.headshot.url : '',
+ // Url for the title/subtitle, defaults to the entity's website url
+ // Note, a protocol like https://yext.com is required, as opposed to just yext.com
+ url: item.link || item.website,
+ // Character limit to hide remaining details and display a show more button, defaults to no limit.
+ showMoreLimit: 350,
+ // Text for show more button, defaults to 'Show More'
+ showMoreText: 'show more',
+ // Text for show less button, defaults to 'Show Less'
+ showLessText: 'put it back',
+ // The target attribute for the title link, defaults to '_self'. To open in a new window use '_blank'
+ target: '_blank',
+ // Whether to show the ordinal of this card in the results, i.e. first card is 1 second card is 2,
+ // defaults to false
+ showOrdinal: false,
+ // A tag to display on top of an image, always overlays the image, default no tag
+ tagLabel: 'On Sale!'
+ };
+}
+
+
Accordion Card
+
The data mappings for an Accordion Card has these attributes
+
const dataMappings = item => {
+ return {
+ // Title for the card, defaults to the name of the entity
+ title: item.title,
+ // Subtitle, defaults to null
+ subtitle: `Department: ${item.name} `,
+ // Details, defaults to the entity's description
+ details: item.description,
+ // Whether the first Accordion Card shown in vertical/universal results should be open on page load, defaults to false
+ expanded: false
+ };
+}
+
+
Legacy Card
+
The Legacy Card is very similar to the Standard Card, but with the legacy DOM structure and class names
+from before v0.13.0. New users should not use the Legacy Card; instead, use the Standard Card. Features
+added after v0.13.0 may not work with the Legacy Card.
+
The data mappings for a legacy card has these attributes
+
const dataMappings = item => {
+ return {
+ // Title for the card, defaults to the name of the entity
+ title: item.title,
+ // Subtitle, defaults to null
+ subtitle: `Department: ${item.name} `,
+ // Details, defaults to the entity's description
+ details: item.description,
+ // Image to display, defaults to null
+ image: item.headshot ? item.headshot.url : '',
+ // Url for the title/subtitle, defaults to the entity's website url
+ url: item.link || item.website,
+ // The target attribute for the title link, defaults to '_self'. To open in a new window use '_blank'
+ target: '_blank',
+ // Whether to show the ordinal of this card in the results, i.e. first card is 1 second card is 2,
+ // defaults to false
+ showOrdinal: false
+ };
+}
+
+
Pagination Component
+
This component is only for Vertical pages.
+
The Pagination component allows users to page through vertical search results.
+
<div class="pagination-container"></div>
+
+
ANSWERS.addComponent('Pagination', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.pagination-component',
+ // Required*, the vertical for pagination, *if omitted, will fall back to the search base config
+ verticalKey: 'verticalKey',
+ // Optional, the maximum number of pages visible to non-mobile users. Defaults to 1.
+ maxVisiblePagesDesktop: 1,
+ // Optional, the maximum number of pages visible to mobile users. Defaults to 1.
+ maxVisiblePagesMobile: 1,
+ // Optional, ensure that the page numbers for first and last page are always shown. Not recommended to use with showFirstAndLastButton. Defaults to false.
+ pinFirstAndLastPage: false,
+ // Optional, display double-arrows allowing users to jump to the first and last page of results. Defaults to true.
+ showFirstAndLastButton: true,
+ // Optional, label for a page of results. Defaults to 'Page'.
+ pageLabel: 'Page',
+ // Optional, configuration for the pagination behavior when a query returns no results
+ noResults: {
+ // Optional, whether pagination should be visible when displaying no results.
+ // Defaults to false.
+ visible: false
+ },
+ // Function invoked when a user clicks to change pages. By default, scrolls the user to the top of the page.
+ onPaginate: (newPageNumber, oldPageNumber, totalPages) => {},
+ // DEPRECATED, please use showFirstAndLastButton instead.
+ // Display a double arrow allowing users to jump to the first page of results. Defaults to showFirstAndLastButton.
+ showFirst: true,
+ // DEPRECATED, please use showFirstAndLastButton instead.
+ // Display a double arrow allowing users to jump to the last page of results. Defaults to showFirstAndLastButton.
+ showLast: true,
+});
+
FilterBox Component
+
This component is only for Vertical pages.
The FilterBox component shows a list of filters to apply to a search.
<div class="filters-container"></div>
ANSWERS.addComponent('FilterBox', {
+ // Required, the selector for the container element where the component will be injected
container: '.filters-container',
- // List of filter component configurations
+ // Required, list of filter component configurations
filters: [
{
type: 'FilterOptions',
@@ -355,12 +1171,12 @@
}
]
}
- ]
+ ],
+ // Required, the vertical key for the search, default null
+ verticalKey: 'verticalKey',
+ // Optional, title to display above the filter
+ title: 'Filters',
+ // Optional, show number of results for each filter
+ showCount: true,
+ // Optional, execute a new search whenever a filter selection changes. If true, the Apply and Reset buttons will not display
+ searchOnChange: false,
+ // Optional, show a reset button per filter group, this will only display if searchOnChange is false
+ resetFilter: false,
+ // Optional, the label to use for the reset button above, this will only display if searchOnChange is false
+ resetFilterLabel: 'reset',
+ // Optional, show a reset-all button for the filter control. Defaults to displaying a reset button if searchOnChange is false.
+ resetFilters: true,
+ // Optional, the label to use for the reset-all button above, this will only display if resetFilters is true.
+ resetFiltersLabel: 'reset-all',
+ // Optional, allow collapsing excess filter options after a limit
+ showMore: true,
+ // Optional, the max number of filter to show before collapsing extras
+ showMoreLimit: 5,
+ // Optional, the label to show for displaying more filter
+ showMoreLabel: 'show more',
+ // Optional, the label to show for displaying less filter
+ showLessLabel: 'show less',
+ // Optional, allow expanding and collapsing entire groups of filters
+ expand: true,
+ // Optional, show the number of applied filter when a group is collapsed
+ showNumberApplied: true,
+ // Optional, the label to show on the apply button, this will only display if searchOnChange is false
+ applyLabel: 'apply',
+ // Optional, whether or not this filterbox contains dynamic filters, default false
+ isDynamic: true
});
+
Facets Component
+
This component is only for Vertical pages.
+
The Facets component displays filters relevant to the current search, configured on the server, automatically. The Facets component will be hidden when a query returns no results. The selected options in a facets component will float to the top.
+
<div class="facets-container"></div>
+
+
ANSWERS.addComponent('Facets', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.facets-container',
+ // Required
+ verticalKey: '<VERTICAL_KEY>',
+ // Optional, title to display above the facets
+ title: 'Filters',
+ // Optional, show number of results for each facet
+ showCount: true,
+ // Optional, execute a new search whenever a facet selection changes
+ searchOnChange: false,
+ // Optional, show a reset button per facet group
+ resetFacet: false,
+ // Optional, the label to use for the reset button above
+ resetFacetLabel: 'reset',
+ // Optional, show a reset-all button for the facets control. Defaults to showing a reset-all button if searchOnChange is false.
+ resetFacets: true,
+ // Optional, the label to use for the reset-all button above
+ resetFacetsLabel: 'reset-all',
+ // Optional, allow collapsing excess facet options after a limit
+ showMore: true,
+ // Optional, the max number of facets to show before collapsing extras
+ showMoreLimit: 5,
+ // Optional, the label to show for displaying more facets
+ showMoreLabel: 'show more',
+ // Optional, the label to show for displaying less facets
+ showLessLabel: 'show less',
+ // Optional, allow expanding and collapsing entire groups of facets
+ expand: true,
+ // Optional, show the number of applied facets when a group is collapsed
+ showNumberApplied: true,
+ // Optional, the placeholder text used for the filter option search input
+ placeholderText: 'Search here...',
+ // Optional, if true, display the filter option search input
+ searchable: false,
+ // Optional, the form label text for the search input, defaults to 'Search for a filter option'
+ searchLabelText: 'Search for a filter option',
+ // Optional, a transform function which is applied to an array of facets
+ // See the "Transforming Facets" section below for more info
+ transformFacets: (facets, config => facets),
+ // DEPRECATED, please use transformFacets instead. This option is disabled if transformFacets is supplied
+ // Optional, field-specific overrides for a filter
+ fields: {
+ 'c_customFieldName': { // Field id to override e.g. c_customFieldName, builtin.location
+ // Optional, the placeholder text used for the filter option search input
+ placeholderText: 'Search here...',
+ // Optional, show a reset button per facet group
+ showReset: false,
+ // Optional, the label to use for the reset button above
+ resetLabel: 'reset',
+ // Optional, if true, display the filter option search input
+ searchable: false,
+ // Optional, the form label text for the search input, defaults to 'Search for a filter option'
+ searchLabelText: 'Search for a filter option',
+ // Optional, control type, singleoption or multioption
+ control: 'singleoption',
+ // Optional, override the field name for this facet
+ label: 'My custom field'
+ // Optional, allow collapsing excess facet options after a limit
+ showMore: true,
+ // Optional, the max number of facets to show before collapsing extras
+ showMoreLimit: 5,
+ // Optional, the label to show for displaying more facets
+ showMoreLabel: 'show more',
+ // Optional, the label to show for displaying less facets
+ showLessLabel: 'show less',
+ // Optional, allow expanding and collapsing entire groups of facets
+ expand: true,
+ // Optional, callback function for when a facet is changed
+ onChange: function() { console.log('Facet changed'); },
+ // Optional, the selector used for options in the template, defaults to '.js-yext-filter-option'
+ optionSelector: '.js-yext-filter-option',
+ }
+ },
+ // Optional, the label to show on the apply button
+ applyLabel: 'apply'
+});
+
+
Transforming Facets
+
The transformFacets option of the Facets component allows facets data to be fully customized. The function takes in and returns an array of the answers-core DisplayableFacet which is described here. The function also has access to the Facets config as the second parameter.
+
Here's an example of using this option to customize a boolean facet.
The FilterSearch component provides a text input box for users to type a query and select a preset matching filter. When a filter is selected, a vertical search is performed, and the filter and query are stored in the url. If multiple FilterSearch components are on the page, the search will include all selected filters across all of the components.
+
<div class="filter-search-container"></div>
+
+
ANSWERS.addComponent('FilterSearch', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.filter-search-container',
+ // Required
+ verticalKey: '<VERTICAL_KEY>',
+ // Optional, no default
+ placeholderText: 'Start typing...',
+ // Optional, if true, the selected filter is saved and used for the next search,
+ // but does not trigger a search itself. Defaults to false.
+ storeOnChange: true,
+ // Optional, defaults to native form node within container
+ formSelector: '.js-form',
+ // Optional, the input element used for searching and wires up the keyboard interaction
+ inputEl: '.js-query',
+ // Optional, provided to the template as a data point
+ title: 'title',
+ // Optional, the search text used for labeling the input box, also provided to template
+ searchText: 'What do you want to search',
+ // Optional, the query text to show as the first item for auto complete
+ promptHeader: 'Header',
+ // Optional, auto focuses the input box if set to true, default false
+ autoFocus: true,
+ // Optional, redirect search query to url
+ redirectUrl: 'path/to/url',
+ // Optional, the query displayed on load. Defaults to the query stored in the url (if any).
+ query: 'Green Ice Cream Flavor',
+ // Optional, the filter for filtersearch to apply on load, defaults to the filter stored in the url (if any).
+ // An example filter is shown below. For more information see the filter section of
+ // https://developer.yext.com/docs/api-reference/#operation/KnowledgeApiServer.listEntities
+ filter: {
+ c_iceCreamFlavors: {
+ $eq: 'pistachio'
+ }
+ },
+ // Optional, the search parameters for autocompletion
+ searchParameters: {
+ // List of fields to query for
+ fields: [{
+ // Field id to query for e.g. c_customFieldName, builtin.location
+ fieldId: 'builtin.location',
+ // Entity type api name e.g. healthcareProfessional, location, ce_person
+ entityTypeId: 'ce_person',
+ }]
+ // Optional, if true sections search results by search filter, default false
+ sectioned: false,
+ }
+})
+
Filter Components
Filter components can be used in a FilterBox or on their own to affect a search.
FilterOptions
-
FilterOptions displays a set of filters with either checkboxes or radio buttons.
+
FilterOptions displays a set of filters with either checkboxes or radio buttons.
+As a user interacts with FilterOptions, information on which options are selected
+is stored in the url. Returning to that same url will load the page with those saved
+options already selected.
<div class="filter-container"></div>
ANSWERS.addComponent('FilterOptions', {
+ // Required, the selector for the container element where the component will be injected
container: '.filter-container',
- // Control type, singleoption or multioption
+ // Required, control type: 'singleoption' or 'multioption'
control: 'singleoption',
- // List of options
+ // The type of options to filter by, either 'STATIC_FILTER' or 'RADIUS_FILTER'.
+ // Defaults to 'STATIC_FILTER'.
+ optionType: 'STATIC_FILTER',
+ // Required, list of options
+ options: [
+ /** Depends on the above optionType, either 'STATIC_FILTER' or 'RADIUS_FILTER', see below. **/
+ ],
+ // Optional, if true, the filter value is saved on change and sent with the next search. Defaults to true.
+ storeOnChange: true,
+ // Optional, the selector used for options in the template, defaults to '.js-yext-filter-option'
+ optionSelector: '.js-yext-filter-option',
+ // Optional, if true, show a reset button
+ showReset: false,
+ // Optional, the label to use for the reset button, defaults to 'reset'
+ resetLabel: 'reset',
+ // Optional, allow collapsing excess filter options after a limit, defaults to true
+ showMore: true,
+ // Optional, the max number of filter options to show before collapsing extras, defaults to 5
+ showMoreLimit: 5,
+ // Optional, the label to show for displaying more options, defaults to 'show more'
+ showMoreLabel: 'show more',
+ // Optional, the label to show for displaying less options, defaults to 'show less'
+ showLessLabel: 'show less',
+ // Optional, allow expanding and collapsing the filter, defaults to true
+ showExpand: true,
+ // Optional, show the number of applied options when a group is collapsed, defaults to true
+ showNumberApplied: true,
+ // Optional, the callback function to call when changed
+ onChange: function() {},
+ // Optional, the label to be used in the legend, defaults to 'Filters'
+ label: 'Filters',
+ // Optional, the placeholder text used for the filter option search input
+ placeholderText: 'Search here...',
+ // Optional, if true, display the filter option search input
+ searchable: false,
+ // Optional, the form label text for the search input, defaults to 'Search for a filter option'
+ searchLabelText: 'Search for a filter option',
+});
+
+
The options config varies depending on whether the optionType is 'STATIC_FILTER' or 'RADIUS_FILTER'.
+A STATIC_FILTER allows you to filter on a specified field, while a RADIUS_FILTER allows you to filter
+results based on their distance from the user.
+
STATIC_FILTER
+
{
options: [
{
- // Label to show next to the filter option
- label: 'Open Now',
- // The api field to filter on, configured on the Yext platform
+ // Required, the api field to filter on, configured on the Yext platform.
field: 'c_openNow',
- // The value for the above field to filter by
- value: 'true'
+ // Required, the value for the above field to filter by.
+ value: true,
+ // Optional, the label to show next to the filter option.
+ label: 'Open Now',
+ // Optional, whether this option will be selected on page load. Selected options stored in the url
+ // take priority over this. Defaults to false.
+ selected: false
},
{
- label: 'Dog Friendly',
field: 'c_dogFriendly',
- value: 'true'
+ value: true,
+ label: 'Dog Friendly',
+ selected: true
},
{
- label: 'Megastores',
field: 'c_storeType',
- value: 'Megastore'
+ value: 'Megastore',
+ label: 'Megastores'
}
]
+}
+
+
RADIUS_FILTER
+
{
+ options: [
+ {
+ // Required, the value of the radius to apply (in meters). If this value is 0, the SDK will not add explicit radius filtering to the request. The backend may still perform its own filtering depending on the query given.
+ value: 8046.72,
+ // Optional, the label to show next to the filter option.
+ label: '5 miles',
+ // Optional, whether this option will be selected on page load. Selected options stored in the url
+ // take priority over this. Defaults to false.
+ selected: false
+ },
+ {
+ value: 16093.4,
+ label: '10 miles',
+ selected: true
+ },
+ {
+ value: 40233.6,
+ label: '25 miles'
+ },
+ {
+ value: 80467.2,
+ label: '50 miles'
+ },
+ {
+ value: 0,
+ label: "Do not filter by radius"
+ }
+ ],
+}
+
+
RangeFilter
+
Displays two numeric inputs for selecting a number range.
+
<div class="range-filter-container"></div>
+
+
ANSWERS.addComponent('RangeFilter', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.range-filter-container',
+ // Required, the API name of the field to filter on
+ field: 'outdoorPoolCount',
+ // Optional, title to display for the range control, defaults to empty legend
+ title: 'Number of Outdoor Pools',
+ // Optional, the label to show next to the min value, defaults to no label
+ minLabel: 'At Least',
+ // Optional, the placeholder text for the min value, defaults to 'Min'
+ minPlaceholderText: 'Min',
+ // Optional, the label to show next to the max value, defaults to no label
+ maxLabel: 'Not More Than',
+ // Optional, the placeholder text for the max value, defaults to 'Max'
+ maxPlaceholderText: 'Max',
+ // Optional, the initial min value to show, defaults to 0. Set this to null to clear the value.
+ initialMin: 1,
+ // Optional, the initial max value to show, defaults to 10. Set this to null to clear the value.
+ initialMax: 5,
+ // Optional, the callback function to call when changed
+ onChange: function() {}
});
-
Direct Answer Component
-
The Direct Answer Component will render the BEST result, if found,
-based on the query.
-
<div class="direct-answer-container"></div>
+
DateRangeFilter
+
Displays two date inputs for selecting a range of dates.
ANSWERS.addComponent('DateRangeFilter', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.date-range-filter-container',
+ // Required, the API name of the field to filter on
+ field: 'time.start',
+ // Optional, title to display for the range, defaults to empty legend
+ title: 'Event Start Date',
+ // Optional, the label to show next to the min date, defaults to no label
+ minLabel: 'Earliest',
+ // Optional, the label to show next to the max date, defaults to no label
+ maxLabel: 'Latest',
+ // Optional, the initial min date to show in yyyy-mm-dd format, defaults to today. Set this to null to clear the value.
+ initialMin: '2019-08-01',
+ // Optional, the initial max date to show in yyyy-mm-dd format, defaults to today. Set this to null to clear the value.
+ initialMax: '2019-09-01',
+ // Optional, whether to store the filter on change to input
+ storeOnChange: true,
+ // Optional, if true, this filter represents an exclusive range, rather than an inclusive one, defaults to false
+ isExclusive: false,
+ // Optional, the callback function to call when changed
+ onChange: function() {}
+});
-
Universal Results Component
-
The Universal Results component will render the results of a query,
-across all configured verticals, seperated by sections.
-
The most complex component has a ton of overridable configuration options.
-
<div class="universal-results-container"></div>
+
GeoLocationFilter
+
Displays a "Use My Location" button that filters results to a radius around the user's current position.
For all optional config in the example, unless otherwise specified, the default is the example value.
+
ANSWERS.addComponent('GeoLocationFilter', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.geolocation-filter-container',
+ // Optional, the vertical key to use
+ verticalKey: 'verticalKey',
+ // Optional, radius around the user, in miles, to find results, default 50
+ radius: 50,
+ // Optional, the text to show when enabled
+ enabledText: 'Disable My Location',
+ // Optional, the text to show ehn loading the user's location
+ loadingText: 'Loading',
+ // Optional, The label to show when unable to get the user's location
+ errorText: 'Unable To Use Location',
+ // Optional, CSS selector of the button
+ buttonSelector: '.js-yxt-GeoLocationFilter-button',
+ // Optional, Css selector of the query input
+ inputSelector: '.js-yxt-GeoLocationFilter-input',
+ // Optional, if true, triggers a search on each change to a filter, default false
+ searchOnChange: true,
+ // Optional, the icon url to show in the geo button
+ geoButtonIcon: 'path/to/url',
+ // Optional, the alt text to use with the geo button's icon
+ geoButtonIconAltText: 'Use My Location',
+ // Optional, the text to show in the geo button
+ geoButtonText: 'Use my location',
+ // Optional, Search parameters for the geolocation autocomplete
+ searchParameters: {
+ // List of fields to query for
+ fields: [{
+ // Field id to query for e.g. c_customFieldName, builtin.location
+ fieldId: 'builtin.location',
+ // Entity type api name e.g. healthcareProfessional, location, ce_person
+ entityTypeId: 'ce_person',
+ // Optional, if true sections search results by search filter, default false
+ sectioned: false,
+ }]
+ },
+ // Optional, options to pass to the geolocation api, which is used to fetch the user's current location.
+ // https://developer.mozilla.org/en-US/docs/Web/API/PositionOptions
+ geolocationOptions: {
+ // Optional, whether to improve accuracy at the cost of response time and/or power consumption, defaults to false.
+ enableHighAccuracy: false,
+ // Optional, the maximum amount of time (in ms) a geolocation call is allowed to take before defaulting, defaults to 6 seconds.
+ timeout: 6000,
+ // Optional, the maximum amount of time (in ms) to cache a geolocation call, defaults to 5 minutes.
+ maximumAge: 300000,
+ },
+ // Optional, options for an alert when the geolocation call fails.
+ geolocationTimeoutAlert: {
+ // Optional, whether to display a window.alert() on the page, defaults to false.
+ enabled: false,
+ // Optional, the message in the alert. Defaults to the below
+ message: "We are unable to determine your location"
+ }
+});
+
+
Applied Filters Component
+
The Applied Filters Component displays your currently applied filters as a row of text tags, labeled
+by filter display value. If the "removable" config option is set to true, these text tags will instead
+be "removable filters", which, when clicked, will remove the clicked filter from the search.
+Only intended for vertical pages.
+
ANSWERS.addComponent('AppliedFilters', {
+ container: '.applied-filters-container',
+ // Optional, The vertical key of your search. Defaults to the vertical key specified in the search config.
+ verticalKey: 'aVerticalKey',
+ // Optional, Whether to display the field name of each group of applied filters. e.g. "Location: Virginia, New York" vs just "Virginia, New York". Defaults to false.
+ showFieldNames: false,
+ // Optional, This is list of filters that should not be displayed. Defaults to hiding ['builtin.entityType'].
+ hiddenFields: ['builtin.entityType'],
+ // Optional, Whether or not the displayed filters should be removable filters, or just simple text tags. Defaults to false (text tags).
+ removable: false,
+ // Optional, The character that separates each group of filters (grouped by field name). Defaults to '|'.
+ delimiter: '|',
+ // Optional, The aria-label given to the component. Defaults to 'Filters applied to this search:'.
+ labelText: 'Filters applied to this search:',
+ // Optional, The aria-label given to the removable filters. Defaults to 'Remove this filter'.
+ removableLabelText: 'Remove this filter'
+});
+
+
Navigation Component
+
The Navigation Component adds a dynamic experience to your pages navigation experience.
+
When using multiple vertical searches in a universal search, the navigation ordering will be automatically updated based on the search results. By default, tabs that do not fit in the container will go inside a dropdown menu.
+
Vertical configurations should be provided the ANSWERS.init's verticalPages configuration. Find more info in the Vertical Pages Configuration section.
+
<div class="navigation-container"></nav>
+
+
ANSWERS.addComponent('Navigation', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.navigation-container',
+ // Optional, controls if navigation shows a scroll bar or dropdown for mobile. Options are COLLAPSE and INNERSCROLL
+ mobileOverflowBehavior: 'COLLAPSE',
+ // Optional, the aria-label to set on the navigation, defaults to 'Search Page Navigation'
+ ariaLabel: 'Search Page Navigation',
+ // Optional, the label to display on the dropdown menu button when it overflows, defaults to 'More'
+ overflowLabel: 'More',
+ // Optional, name of the icon to show on the dropdown button instead when it overflows
+ overflowIcon: null,
})
-
Custom Render for ALL Result Items
-
You can override the render function for EACH item in the result list,
-as apposed to the entire component.
ANSWERS.addComponent('QASubmission', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.question-submission-container',
+ // Required. Set this to the Entity ID of the organization entity in the Knowledge Graph
+ entityId: 123,
+ // Required. Defaults to ''
+ privacyPolicyUrl: 'https://mybiz.com/policy',
+ // Optional, defaults to native form node within container
+ formSelector: '.js-form',
+ // Optional, ;abel for name input
+ nameLabel: 'Name',
+ // Optional, label for email input
+ emailLabel: 'Email',
+ // Optional, label for question input
+ questionLabel: 'Question',
+ // Optional, title displayed for the form
+ sectionTitle: 'Ask a question',
+ // Optional, teaser displayed for the form, next to the title
+ teaser: 'Can\'t find what you’re looking for? Ask a question below.',
+ // Optional, description for the form
+ description: 'Enter your question and contact information, and we\'ll get back to you with a response shortly.'
+ // Optional, text before the privacy policy link
+ privacyPolicyText: 'By submitting my email address, I consent to being contacted via email at the address provided.',
+ // Optional, label for the privacy policy url
+ privacyPolicyUrlLabel: 'Learn more here.',
+ // Optional, error message displayed when the privacy policy is not selected
+ privacyPolicyErrorText: '* You must agree to the privacy policy to submit feedback.',
+ // Optional, error message displayed when an invalid email is not submitted
+ emailFormatErrorText: '* Please enter a valid email address.'
+ // Optional, placeholder displayed in all required fields
+ requiredInputPlaceholder: '(required)',
+ // Optional, confirmation displayed once a question is submitted
+ questionSubmissionConfirmationText: 'Thank you for your question!',
+ // Optional, label displayed on the button to submit a question
+ buttonLabel: 'Submit',
+ // Optional, set this to whether or not the form is expanded by default when a user arrives on the page
+ expanded: true,
+ // Optional, error message displayed when there is an issue with the QA Submission request
+ networkErrorText: 'We\'re sorry, an error occurred.'
})
-
Custom Template for ALL Result Items
-
You can override the handlebars template for EACH item in the result list,
-as apposed to the entire component.
The spell check component shows spell check suggestions/autocorrect.
+
<div class="spell-check-container"></div>
+
+
ANSWERS.addComponent('SpellCheck', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.spell-check-container',
+ // Optional, the help text to display when suggesting a query
+ suggestionHelpText: 'Did you mean:',
})
-
Custom Render For Specific Vertical Result Items
-
You can override the render function for a particular section within the results list,
-by providing a vertical search config id as the context, and using the same options as above.
The location bias component shows location that used for location bias and allow user to improve accuracy with HTML5 geolocation.
+
<div class="location-bias-container"></div>
+
+
ANSWERS.addComponent('LocationBias', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.location-bias-container',
+ // Optional, the vertical key for the search, default null
+ verticalKey: 'verticalKey',
+ // Optional, the element used for updating location
+ updateLocationEl: '.js-locationBias-update-location',
+ // Optional, help text to inform someone their IP was used for location
+ ipAccuracyHelpText: 'based on your internet address',
+ // Optional, help text to inform someone their device was used for location
+ deviceAccuracyHelpText: 'based on your device',
+ // Optional, text used for the button to update location
+ updateLocationButtonText: 'Update your location',
+ // Optional, options to pass to the geolocation api, which is used to fetch the user's current location.
+ // https://developer.mozilla.org/en-US/docs/Web/API/PositionOptions
+ geolocationOptions: {
+ // Optional, whether to improve accuracy at the cost of response time and/or power consumption, defaults to false.
+ enableHighAccuracy: false,
+ // Optional, the maximum amount of time (in ms) a geolocation call is allowed to take before defaulting, defaults to 6 seconds.
+ timeout: 6000,
+ // Optional, the maximum amount of time (in ms) to cache a geolocation call, defaults to 5 minutes.
+ maximumAge: 300000,
+ },
+ // Optional, options for an alert when the geolocation call fails.
+ geolocationTimeoutAlert: {
+ // Optional, whether to display a window.alert() on the page, defaults to false.
+ enabled: false,
+ // Optional, the message in the alert. Defaults to the below
+ message: "We are unable to determine your location"
}
})
-
Custom Template For Specific Vertical Result Items
-
You can override the handlebars template for a particular section within the results list,
-by providing a vertical search config id as the context, and using the same options as above.
The sort options component displays a list of radio buttons that allows users to sort the results of a vertical search. When a query returns no results, the component will not be rendered on the page.
+Currently, there may be only one sort options component per page.
+
<div class='sort-options-container'></div>
+
+
// note: showExpand and showNumberApplied options are explicitly not included:
+// sorting will always be exposed to the user if added.
+ANSWERS.addComponent('SortOptions', {
+ // Required, the selector for the container element where the component will be injected
+ container: '.sort-options-container',
+ // Optional: The label used for the “default” sort (aka sort the order provided by the config), defaults to 'Best Match'
+ defaultSortLabel: 'Best Match',
+ // Required: List of component configurations
+ options: [
+ {
+ // Required: Either FIELD, ENTITY_DISTANCE, or RELEVANCE
+ type: 'FIELD',
+ // Required only if type is FIELD, field name to sort by
+ field: 'c_popularity',
+ // Direction to sort by, either 'ASC' or 'DESC'
+ // Required only if type is FIELD
+ direction: 'ASC',
+ // Required: Label for the sort option's radio button
+ label: 'Popularity',
+ },
+ {
+ type: "ENTITY_DISTANCE",
+ label: 'Distance'
+ },
+ {
+ type: 'RELEVANCE',
+ label: 'Relevance'
}
+ ],
+ // Required: the vertical key used
+ verticalKey: 'KM',
+ // Optional: the selector used for options in the template, defaults to '.yxt-SortOptions-optionSelector'
+ optionSelector: '.yxt-SortOptions-optionSelector',
+ // Optional: if true, triggers a resorts on each change to the sort options,
+ // if false the component also renders an apply button that applies the sort, defaults to false
+ searchOnChange: false,
+ // Optional: Show a reset button, defaults to false
+ showReset: false,
+ // Optional: The label to use for the reset button, defaults to 'reset'
+ resetLabel: 'reset',
+ // Optional: Allow collapsing excess filter options after a limit, defaults to true
+ // Note: screen readers will not read options hidden by this flag, without clicking show more first
+ showMore: true,
+ // Optional: The max number of filter options to show before collapsing extras, defaults to 5
+ showMoreLimit: 5,
+ // Optional: The label to show for displaying more options, defaults to 'Show more'
+ showMoreLabel: 'Show more',
+ // Optional: The label to show for displaying less options, defaults to 'Show less'
+ showLessLabel: 'Show less',
+ // Optional, the callback function to call when changed, defaults to function() => {}
+ // runs BEFORE search triggered by searchOnChange if searchOnChange is true
+ onChange: function() {},
+ // Optional, the label to be used in the legend, defaults to 'Sorting'
+ label: 'Sorting',
+ // Optional, the label to be used on the apply button
+ // only appears if searchOnChange is false, defaults to 'Apply'
+ applyLabel: 'Apply'
+});
+
+
Map Component
+
The Map component displays a map with a pin for each result that has Yext display coordinates.
+
<div class='map-container'></div>
+
+
ANSWERS.addComponent('Map', {
+ // Required. This is the class of the target HTML element the component will be mounted to
+ container: '.map-container',
+ // Required. Supported map providers include: `google` or `mapBox`, not case-sensitive
+ mapProvider: 'mapBox',
+ // Required*. The API Key used for interacting with the map provider; (*except for Google Maps if provided `clientId`)
+ apiKey: '',
+ // Optional, can be used for Google Maps in place of the API key
+ clientId: '',
+ // Optional, used to determine the language of the map. Defaults to the locale specified in the ANSWERS init.
+ // Refer to the section "Supported Map Locales" below for the list of supported locales.
+ locale: 'en',
+ // Optional, determines whether or not to collapse pins at the same lat/lng
+ collapsePins: false,
+ // Optional, the zoom level of the map, defaults to 14
+ zoom: 14,
+ // Optional, the default coordinates to display if there are no results returned used if showEmptyMap is set to true
+ defaultPosition: { lat: 37.0902, lng: -95.7129 },
+ // Optional, determines if an empty map should be shown when there are no results. Defaults to false.
+ showEmptyMap: false,
+ // Optional, callback to invoke when a pin is clicked. The clicked item(s) are passed to the callback
+ onPinClick: null,
+ // Optional, callback to invoke when a pin is hovered. The clicked item(s) are passed to the callback
+ onPinMouseOver: null,
+ // Optional, callback to invoke when a pin is no longer hovered after being hovered. The clicked item(s) are passed to the callback
+ onPinMouseOut: null,
+ // Optional, callback to invoke once the Javascript is loaded
+ onLoaded: function () {},
+ // Optional, configuration for the map's behavior when a query returns no results
+ noResults: {
+ // Optional, whether to display map pins for all possible results when no results are found. Defaults to false.
+ displayAllResults: false,
+ // Optional, whether to display the map when no results are found, taking priority over showEmptyMap. If unset, a map will be visible if showEmptyMap is true OR if displayAllResults is true and alternative results are returned.
+ visible: false
+ },
+ // Optional, the custom configuration override to use for the map markers, function
+ pin: function () {
+ return {
+ icon: {
+ anchor: null, // e.g. { x: 1, y: 1 }
+ svg: null,
+ url: null,
+ scaledSize: null // e.g. { w: 20, h: 20 }
+ },
+ labelType: 'numeric'
+ };
+ },
+};
+
+
Supported Map Locales
+
When using MapBox (mapBox) as the mapProvider, the valid locale options
+are the ones listed here:
+
+
Arabic: ar
+
Chinese: zh
+
English: en
+
French: fr
+
German: de
+
Japanese: ja
+
Korean: ko
+
Portuguese: pt
+
Russian: ru
+
Spanish: es
+
+
When using Google Maps (google) as the mapProvider, the valid locale options can be found here.
+
Icon Component
+
The Icon Component will typically be created by other components, but it can be used as a standalone component as well.
+
<div class='icon-container'></div>
+
+
ANSWERS.addComponent('IconComponent', {
+ // Required. This is the class of the target HTML element the component will be mounted to.
+ container: '.icon-container',
+ // Optional. Can be used to access an icon defined within the Answers system. See below for default icon names.
+ iconName: 'default',
+ // Optional. Sets the icon to reference an image URL. Overrides icon name.
+ iconUrl: '',
+ // Optional. Adds class names to the icon. Multiple classnames should be space-delimited.
+ classNames: '',
+});
+
+
The following is a list of names for the icons that are supported by default.
+
+
briefcase
+
calendar
+
callout
+
chevron
+
close
+
directions
+
document
+
elements
+
email
+
gear
+
info
+
kabob
+
light_bulb
+
link
+
magnifying_glass
+
mic
+
office
+
pantheon
+
person
+
phone
+
pin
+
receipt
+
star
+
support
+
tag
+
thumb
+
window
+
yext_animated_forward
+
yext_animated_reverse
+
yext
+
+
Customizing Components
+
Using a Custom Renderer
+
If you want to use a use your own template language (e.g. soy, mustache, groovy, etc),
+you should NOT use the template argument. Instead, you can provide a custom render function to the component.
+
ANSWERS.addComponent('SearchBar', {
+ container: '.search-container',
+ render: function(data) {
+ // Using native ES6 templates -- but you can replace this with soy,
+ // or any other templating language as long as it returns a string.
+ return `<div class="my-search">${data.title}</div>`
}
})
-
Vertical Results Component
-
The Vertical Results component shares all the same configurations from Universal Results, but you don't need to specifiy a config or context.
-
You define all the options at the top level object.
-
<div class="results-container"></div>
+
Custom Data Formatting
+
You can format specific entity fields using fieldFormatters.
+These formatters are applied before the transformData step.
+
Each formatter takes in an object with the following properties :
If you want to mutate the data thats provided to the render/template before it gets rendered,
+you can use the transformData hook.
+
All properties and values that you return from here will be accessible from templates.
+
ANSWERS.addComponent('SearchBar', {
+ container: '.search-container',
+ transformData: (data) => {
+ // Extend/overide the data object
+ return Object.assign({}, data, {
+ title: data.title.toLowerCase()
+ })
+ },
+ render: function(data) {
+ // Using native ES6 templates -- but you can replace this with soy,
+ // or any other templating language as long as it returns a string.
+ return `<div class="my-search">${data.title}</div>`
+ }
})
-
QA Submission Component
-
The QA Submission component provides a form for submitting a QA question,
-when a search query is run.
It's easy to override these templates with your own templates.
+Keep in mind, that you must provide valid handlebars syntax here.
+
// Use handlebars syntax to create a template string
+let customTemplate = `<div class="my-search">{{title}}</div>`
+
+ANSWERS.addComponent('SearchBar', {
+ container: '.search-container',
+ template: customTemplate
+})
-
ANSWERS.addComponent('QASubmission', {
- container: '.question-submission-container',
- nameLabel: 'Your Name:', // Optional, defaults to 'Name:'
- emailLabel: '*Email:', // Optional, defaults to '*Email:'
- questionLabel: 'Ask us anything!:', // Optional, defaults to 'What is your question?'
- privacyPolicyLabel: 'I agree!', // Optional, defaults to 'I agree to our policy:',
- buttonLabel: 'Submit' // Optional, defaults to 'Submit:'
+
The SDK also offers an ANSWERS.registerTemplate function. This will map
+a template string to an entry in the Answers handlebars renderer.
+
/**
+ * Compile and add a template
+ * @param {string} templateName The unique name for the template
+ * @param {string} template The handlebars template string
+ */
+ registerTemplate (templateName, template)
+
+
The default handlebars renderer uses a mapping from template name strings to
+handlebars template strings. If, while trying to register a template, the
+template name does not exist, a new template entry is created. If the name
+already exists, the current template for the template name is overriden. This
+allows you override default template names.
+
For example:
+
// override current SpellCheck template
+ ANSWERS.registerTemplate(
+ 'search/spellcheck',
+ '<div class="Mine">Did you mean {{correctedQuery}}?</div>'
+ );
+
+ // create new Card template
+ ANSWERS.registerTemplate(
+ 'cards/custom',
+ '<p>Card content</p>'
+ );
+
+
Creating Custom Components
+
You can create custom Answers components with the same power of the builtin components. First, create
+a subtype of ANSWERS.Component and register it.
You can learn more about the interface for registering helpers by taking a look at the Handlebars Block Helpers documentation.
Analytics
-
Answers will track some basic interaction analytics automatically, such as search bar impressions and Call-To-Action clicks. You may add additional, custom analytic events to templates using certain data attributes, explained below.
-
Click Analytics
-
Click analytics can be attached to an element by adding the data-eventtype attribute to the element you want to track clicks for. The provided string should be the type of the analytics event. You can optionally include metadata inside the data-eventoptions attribute, in a JSON format. Whenever the element is clicked, an analtyics event with that data will be sent to the server.
+
If a businessId is supplied in the config, Answers will track some basic interaction analytics automatically, such as search bar impressions and Call-To-Action clicks.
+
If you would like to add custom analytics on top of the built-in ones, use the following:
+
Custom Analytics Using JavaScript
+
You may send analytics from external code with the below interface.
You may add additional, custom analytic events to templates using certain data attributes. Click analytics can be attached to an element by adding the data-eventtype attribute to the element you want to track clicks for. The provided string should be the type of the analytics event. You can optionally include metadata inside the data-eventoptions attribute, in a JSON format. Whenever the element is clicked, an analtyics event with that data will be sent to the server.
These types are accepted as the analytics attribute in Calls To Action.
+
Conversion Tracking
+
By default, Answers does not perform conversion tracking for analytics. To opt-in to this behavior, use the setConversionsOptIn method after initialization:
The Answers SDK exposes a formatRichText function which translates CommonMark to HTML. This function will
+ensure that a Rich Text Formatted value is shown properly on the page. To use this function, call it like so:
For instance, this function can be used in the dataMappings of a Card to display an RTF attribute.
+
When clicking any link in the resultant HTML, an AnalyticsEvent will be fired. If the eventOptionsFieldName has been
+specified, the eventOptions will include a fieldName attribute with the given value.
+
The targetConfig parameter dictates where the link is opened: the current window, a new tab, etc. It can have the following forms:
When targetConfig is a string, it is assumed that any link, regardless of type, has the specified target behavior. This parameter, like eventOptionsFieldName, is optional. When not provided, no target attribute is supplied to the links.
+
Note that when using this function, you must ensure that the relevant Handlebars template correctly unescapes the output HTML.
+
CSS Variable Styling
+
The Answers SDK supports styling specific elements with CSS variables for runtime styling.
+
+
All sdk variables are exposed in the :root ruleset
+
All overridden variables must be included the :root ruleset
+
All overrides should be defined after Answers CSS is imported and before ANSWERS.init is called
+
To see available variables, see the scss modules in the SDK
Most browsers have native css variable support. In legacy browsers,
+we use the css-vars-ponyfill.
+
+
The SDK will do automatic resolution of CSS variables on initialization. Variables should be loaded
+before initialization. Overrided variables should be loaded after sdk variables are loaded.
+
You can opt-out with the disableCssVariablesPonyfill flag in ANSWERS.init.
+
If you opt-out of automatic resolution of variables, you should call ANSWERS.ponyfillCssVariables()
+after css variables are loaded and before components are added.
+
If you change a css variable value after initialization and wish to see the change in variable
+value in a legacy browser, you should call ANSWERS.ponyfillCssVariables() after the value is changed.
The Answers SDK provides functionality to perform translation interpolation, pluralization, or both.
+
Interpolation
+
Interpolation allows the use of dynamic values in translations. Interpolation parameters are defined
+inside double brackets, and they must also be defined in an object in the second parameter.
+
The following example will return 'Bonjour Howard' provided the variable myName equals 'Howard':
The translation processor is also available though a Handlebars helper:
+
{{ processTranslation phrase='Bonjour [[name1]] et [[name2]]' name1=name1 name2=name2}}
+
+
Pluralization
+
Pluralization makes it possible to select the plural form of a translation depending on a
+qualifying count. Different languages have different plural rules, which is the method of
+selecting a plural form based on a count. An optional third parameter 'count' and an optional
+fourth parameter 'locale' may be used for pluralization. The locale parameter determines the
+plural forms and the plural rule used. If locale is not defined but the first
+parameter is an object containing pluralizations, the locale supplied in the ANSWERS.init() will be
+used. For more information on plural forms, see this doc:
+
The following example will use the singular form keyed by '0' for a count of 1, and the plural
+form keyed by '1' for any other count. A count of 0 will return '0 results':
French is different than English in that a count of zero uses the same plural form as a count
+of one. For example, a count of 0 will return '0 résultat':
The SDK uses the Performance API, via window.performance.mark(), to create performance metrics regarding ANSWERS.init(), vertical search, and universal search. These marks can be viewed through browser developer tools, or programmatically through window.performance.getEntries().
+
ANSWERS.init()
+
+
+
'yext.answers.initStart' called when ANSWERS.init beings
+
+
+
'yext.answers.ponyfillStart' called when css-variables ponyfill starts
+
+
+
'yext.answers.ponyfillEnd' called when css-variables ponyfill ends.
+
+
+
'yext.answers.statusStart' called when the ANSWERS status call is made
+
+
+
'yext.answers.statusEnd' called when the ANSWERS status check is done
+
+
+
Vertical Search
+
+
+
'yext.answers.verticalQueryStart' called when a vertical query begins
+
+
+
'yext.answers.verticalQuerySent' called right before the vertical query API call is made
+
+
+
'yext.answers.verticalQueryResponseReceived' called immediately after a vertical query response is received
+
+
+
'yext.answers.verticalQueryResponseRendered' called after a vertial query is finished, and all components have finished rendering
+
+
+
Universal Search
+
+
+
'yext.answers.universalQueryStart' called when a universal query starts
+
+
+
'yext.answers.universalQuerySent' called right before the universal query API call is made
+
+
+
'yext.answers.universalQueryResponseReceived' called immediately after a universal query response is received
+
+
+
'yext.answers.universalQueryResponseRendered' called after a universal query is finished and all components have finished rendering
+
+
+
License
+
Yext Answers-Search-UI is an open-sourced library licensed under the BSD-3 License.
+
Third Party Licenses
+
The licenses of our 3rd party dependencies are collected here: THIRD-PARTY-NOTICES.
+ Click handler for the accordion toggle button
+This is used over set state because it's a lot smoother, since
+it doesn't rip the whole component off of the page and remount it.
+Also reports an analytics event.
+
+ The alternative vertical search suggestions, parsed from alternative verticals and
+the global verticals config.
+This gets updated based on the server results
+
+ Pulls applied filter nodes from FilterRegistry, then retrives an array of
+the leaf nodes, and then removes hidden or empty FilterNodes. Then appends
+the currently applied nlp filters.
+
+ Pulls applied filter nodes from FilterRegistry, then retrives an array of
+the leaf nodes, and then removes hidden or empty FilterNodes. Then appends
+the currently applied nlp filters.
+
+ Returns an array of object the handlebars can understand and render
+the applied filters bar from. Our handlebars can only loop through arrays,
+not objects, so we need to reformat the grouped applied filters.
+
+ Returns an array of object the handlebars can understand and render
+the applied filters bar from. Our handlebars can only loop through arrays,
+not objects, so we need to reformat the grouped applied filters.
+
+ Returns the currently applied nlp filter nodes, with nlp filter nodes that
+are duplicates of other filter nodes removed or filter on hiddenFields removed.
+
+ Returns the currently applied nlp filter nodes, with nlp filter nodes that
+are duplicates of other filter nodes removed or filter on hiddenFields removed.
+
+ Combine all of the applied filters into a format the handlebars
+template can work with.
+Keys are the fieldName of the filter. Values are an array of objects with a
+displayValue and dataFilterId.
+
+ Combine all of the applied filters into a format the handlebars
+template can work with.
+Keys are the fieldName of the filter. Values are an array of objects with a
+displayValue and dataFilterId.
+TODO (SPR-2350): give every node a unique id, and use that instead of index for
+dataFilterId.
+
+ Pulls applied filter nodes from FilterRegistry, then retrives an array of
+the leaf nodes, and then removes hidden or empty FilterNodes. Then appends
+the currently applied nlp filters.
+
+ Pulls applied filter nodes from FilterRegistry, then retrives an array of
+the leaf nodes, and then removes hidden or empty FilterNodes. Then appends
+the currently applied nlp filters.
+
+ Returns an array of object the handlebars can understand and render
+the applied filters bar from. Our handlebars can only loop through arrays,
+not objects, so we need to reformat the grouped applied filters.
+
+ Returns an array of object the handlebars can understand and render
+the applied filters bar from. Our handlebars can only loop through arrays,
+not objects, so we need to reformat the grouped applied filters.
+
+ Returns the currently applied nlp filter nodes, with nlp filter nodes that
+are duplicates of other filter nodes removed or filter on hiddenFields removed.
+
+ Returns the currently applied nlp filter nodes, with nlp filter nodes that
+are duplicates of other filter nodes removed or filter on hiddenFields removed.
+
+ Combine all of the applied filters into a format the handlebars
+template can work with.
+Keys are the fieldName of the filter. Values are an array of objects with a
+displayValue and dataFilterId.
+
+ Combine all of the applied filters into a format the handlebars
+template can work with.
+Keys are the fieldName of the filter. Values are an array of objects with a
+displayValue and dataFilterId.
+TODO (SPR-2350): give every node a unique id, and use that instead of index for
+dataFilterId.
+
+ Callback invoked when keys are used to navigate through the auto complete. Note that this is
+not called when either the `Enter` key is pressed or the mouse is used to select an
+autocomplete option.
+
- A reference to the input el selector for auto complete
+ An internal reference to the input value when typing.
+We use this for resetting the state of the input value when other interactions (e.g. result navigation)
+change based on interactions. For instance, hitting escape should reset the value to the original typed query.
- Callback invoked when the `Enter` key is pressed on auto complete.
+ Used for keyboard navigation through results.
+An internal reference to the current result index we're navigating on.
- An internal reference to the input value when typing.
-We use this for resetting the state of the input value when other interactions (e.g. result navigation)
-change based on interactions. For instance, hitting escape should reset the value to the original typed query.
+ Used for keyboard navigation through results.
+An internal reference to the current section we're navigating in.
- Used for keyboard navigation through results.
-An internal reference to the current result index we're navigating on.
+ Whether to hide the autocomplete when the search input is empty
- Used for keyboard navigation through results.
-An internal reference to the current section we're navigating in.
+ The `verticalKey` of the vertical search to use for auto-complete
- An internal reference for the data-storage to listen for updates from the server
+ The query text to show as the first item for auto complete.
+Optionally provided
A Data Transformer that takes the response object from a AutoComplete request
-And transforms in to a front-end oriented data structure that our
-component library and core storage understand.
-
-TODO(billy) Create our own front-end data models
+ Handles resolving ctas from a cta mapping which are either
+1. a function that returns a cta's config
+2. an object that has a per-attribute mapping of either a
+ a) static value
+ b) function that takes in resut data and returns the given attributes value
+Note: Intentionally does not allow nesting functions.
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/module-CombinedFilterNode.html b/docs/module-CombinedFilterNode.html
new file mode 100644
index 000000000..aa7bf3027
--- /dev/null
+++ b/docs/module-CombinedFilterNode.html
@@ -0,0 +1,815 @@
+
+
+
+
+ JSDoc: Class: module:CombinedFilterNode
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: module:CombinedFilterNode
+
+
+
+
+
+
+
+
+
+
+
module:CombinedFilterNode()
+
+
A CombinedFilterNode represents a combined filter.
+A combined filter is a set of filters combined with a FilterCombinators
+($and or $or). Since a combined filter is just a set of other filters,
+it does not have its own FilterMetadata, and its filter is dervied from
+its children.
+ Returns the metadata associated with this node's filter.
+Because a combined filter's purpose is solely to join together other filters,
+and does not have its own filter, this value is always null.
+
- A custom class to be applied to {this._container} node
+ A custom class to be applied to {this._container} node. Note that the class
+'yxt-Answers-component' will be included as well.
name
- Unique name of this component instance
-Used to distinguish between other components of the same type
+ Name of this component instance.
@@ -1207,7 +1279,7 @@
- The component registry is an internal map, that contains
-all available component CLASSES used for creation or override.
-Each component class has a unique TYPE, which is used as the key for the registry
+ A counter for the id the give to the next component that is created.
register
- registers a component to be eligible for creation and override.
+ Returns a concatenated list of all names associated with the given component types
@@ -712,13 +824,13 @@
+ Builds the object passed as a parameter to onUniversalSearch. This object
+contains information about the universal search's query and result counts.
+
+ Sets the filter nodes used for the current facet filters.
+
+Because the search response only sends back one
+set of facet filters, there can only be one active facet filter node
+at a time.
+
- Given an input, provide a list of suitable filters for autocompletion
+ Sets the specified FilterNode under the given key.
+Will replace a preexisting node if there is one.
- Given an input, query for a list of similar results and set into storage
+ Submits a question to the server and updates the underlying question model
@@ -400,7 +4366,72 @@
Parameters:
-
input
+
question
+
+
+
+
+
+object
+
+
+
+
+
+
+
+
+
+
The question object to submit to the server
+
Properties
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
entityId
+
+
+
+
+
+number
+
+
+
+
+
+
+
+
+
+
The entity to associate with the question (required)
- Given an input, query for a list of similar results in the provided vertical
-and set into storage
+ Depending on the QUERY_TRIGGER, either replaces the history state
+for searches on load/back navigation (INITIALIZE, SUGGEST, QUERY_PARAMETER),
+or pushes a new state.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/module-DefaultTemplatesLoader.html b/docs/module-DefaultTemplatesLoader.html
new file mode 100644
index 000000000..7e39c1c5d
--- /dev/null
+++ b/docs/module-DefaultTemplatesLoader.html
@@ -0,0 +1,361 @@
+
+
+
+
+ JSDoc: Class: module:DefaultTemplatesLoader
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: module:DefaultTemplatesLoader
+
+
+
+
+
+
+
+
+
+
+
module:DefaultTemplatesLoader()
+
+
DefaultTemplatesLoader exposes an interface for loading the default set of compiled templates
+asynchronously from the server. Note that this class cannot be repurposed to fetch custom
+templates hosted by a client.
+ register the templates internally so that they can be later consumed
+(e.g. by components and renderers) with convienience.
+
+This is called from inside handlebarswrapper.txt.
+
+ report pretty prints the error to the console, optionally
+prints the entire error if `printVerbose` is true, and sends the
+error to the server to be logged if `sendToServer` is true
+
+ The display value for the values being filtered on.
+Even if there are multiple values within the data of a filter,
+there should only be one display value for the whole filter.
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/module-FilterNode.html b/docs/module-FilterNode.html
new file mode 100644
index 000000000..11933a6b1
--- /dev/null
+++ b/docs/module-FilterNode.html
@@ -0,0 +1,691 @@
+
+
+
+
+ JSDoc: Class: module:FilterNode
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: module:FilterNode
+
+
+
+
+
+
+
+
+
+
+
module:FilterNode()
+
+
A FilterNode represents a single node in a filter tree.
+Each filter node has an associated filter, containing the filter
+data to send in a request, any additional filter metadata for display,
+and any children nodes.
+
+Implemented by SimpleFilterNode and CombinedFilterNode.
+ Returns this component's filter node when it is a STATIC_FILTER.
+This method is exposed so that components like FilterBoxComponent
+can access them.
+
- submitURL will force the search query submission to get
-redirected to the URL provided.
-Optional, defaults to null.
-
-If no redirectUrl provided, we keep the page as a single page app.
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/docs/module-FilterRegistry.html b/docs/module-FilterRegistry.html
new file mode 100644
index 000000000..a2f719061
--- /dev/null
+++ b/docs/module-FilterRegistry.html
@@ -0,0 +1,2499 @@
+
+
+
+
+ JSDoc: Class: module:FilterRegistry
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: module:FilterRegistry
+
+
+
+
+
+
+
+
+
+
+
module:FilterRegistry()
+
+
FilterRegistry is a structure that manages static Filters and Facet filters.
+
+Static filters and facet filters are stored within storage using FilterNodes.
+ Transforms a SimpleFilterNode to answers-core's Filter or CombinedFilter
+if there are multiple matchers.
+TODO(SLAP-1183): remove the parsing for multiple matchers.
+
+ Sets the filter nodes used for the current facet filters.
+
+Because the search response only sends back one
+set of facet filters, there can only be one active facet filter node
+at a time.
+
- The input key for the vertical search configuration
+ Query submission is based on a form as context.
+Optionally provided, otherwise defaults to native form node within container
- Query submission is based on a form as context.
-Optionally provided, otherwise defaults to native form node within container
+ The input element used for searching and wires up the keyboard interaction
+Optionally provided.
- The input element used for searching and wires up the keyboard interaction
-Optionally provided.
+ The vertical key for vertical search configuration
- Auto focuses the input box if set to true.
-Optionally provided, defaults to false.
+ The query text to show as the first item for auto complete.
+Optionally provided
- The query text to show as the first item for auto complete.
-Optionally provided
+ submitURL will force the search query submission to get
+redirected to the URL provided.
+Optional, defaults to null.
+
+If no redirectUrl provided, we keep the page as a single page app.
- The query string to use for the input box, provided to template for rendering.
+ The search text used for labeling the input box, also provided to template.
Optionally provided
- submitURL will force the search query submission to get
-redirected to the URL provided.
-Optional, defaults to null.
-
-If no redirectUrl provided, we keep the page as a single page app.
+ The title used, provided to the template as a data point
+Optionally provided.
search
Perform the vertical search with all saved filters and query,
-optionally redirecting based on config
+optionally redirecting based on config. Uses window.setTimeout to allow
+other filters to finish rendering before searching.
@@ -1232,7 +1195,7 @@
+ Google Maps supports some language codes that are longer than two characters. If the
+locale matches one of these edge cases, use it. Otherwise, fallback on the first two
+characters of the locale.
+
+ introduces highlighting to input data according to highlighting specifiers
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
val
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
input object to apply highlighting to
+
+ example object :
+ {
+ name: 'ATM',
+ featuredMessage: {
+ description: 'Save time & bank on your terms at over 1,800 ATMs'
+ }
+ }
+
+
+
+
+
+
+
highlightedSubstrings
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
highlighting specifiers to apply to input object
+
+ example object :
+ {
+ name: {
+ matchedSubstrings: [{
+ length: 3,
+ offset: 0
+ }],
+ value: 'ATM'
+ },
+ featuredMessage: {
+ description: {
+ matchedSubstrings: [{
+ length: 4,
+ offset: 45
+ }],
+ value: 'Save time & bank on your terms at over 1,800 ATMs'
+ }
+ }
+ }
+
+
+
+
+
+
+
transformFunction
+
+
+
+
+
+function
+
+
+
+
+
+
+
+
+
+
function to apply to strings in between highlighting markup
+
+ example function :
+ function (string) {
+ return handlebars.escapeExpression(string);
+ }
+ copy of input value with highlighting applied
+
+ example object :
+ {
+ name: 'ATM',
+ featuredMessage: {
+ description: 'Save time & bank on your terms at over 1,800 ATMs'
+ }
+ }
+
+ Send a beacon to the provided url which will send a non-blocking request
+to the server that is guaranteed to send before page load. No response is returned,
+so beacons are primarily used for analytics reporting.
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/module-LegacyCardComponent.html b/docs/module-LegacyCardComponent.html
new file mode 100644
index 000000000..f0eb2d415
--- /dev/null
+++ b/docs/module-LegacyCardComponent.html
@@ -0,0 +1,319 @@
+
+
+
+
+ JSDoc: Class: module:LegacyCardComponent
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: module:LegacyCardComponent
+
+
+
+
+
+
+
+
+
+
+
module:LegacyCardComponent()
+
+
Card components expect to receive a data config option, containing data regarding entity result
+each card is assigned to, including all field data in data._raw.
A MapProvider is an interface that represents that should be implemented
-in order to integrate with a Third Party Map provider for both
-static and interactive maps. MapProviders are used by the MapComponent.
+in order to integrate with a Third Party Map provider for
+interactive maps. MapProviders are used by the MapComponent.
Implementations should extend this interface.
- The custom configuration override to use for the map markers
+ Callback to invoke when a pin is clicked. The clicked item(s) are passed to the callback
- The width of the map to append to the DOM, defaults to 100%
+ Callback to invoke when a pin is no longer hovered after being hovered.
+The hovered item is passed to the callback
- The serverside vertical config id that this is referenced to.
-By providing this, enables dynamic sorting based on results.
+ Determines whether or not to apply a special class to the
+markup to determine if it's an active tab
- Determines whether or not to apply a special class to the
-markup to determine if it's an active tab
+ Determines whether to show this tab first in the order
- The complete URL, including the params
+ The serverside vertical config id that this is referenced to.
+By providing this, enables dynamic sorting based on results.
(static) from
- from will construct a map of configId to {Tab} from
+ from will construct a map of verticalKey to {Tab} from
a configuration file
@@ -691,7 +691,7 @@
- getDefaultTabOrder will compute the initial tab ordering based
-on a combination of the configuration provided directly to the component
-and the url params.
+ The data storage key
setState
Since the server data only provides a list of
-VS configIds, we need to compute and transform
+VS verticalKeys, we need to compute and transform
the data into the proper format for rendering.
@@ -639,7 +908,7 @@
+ Pagination should evenly add page numbers in the "forward" and "backward" directions, unless
+one side has reached the max/min value, in which case the remaining side should be the only
+one to get more pages.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
pageNumber
+
+
+
+
+
+number
+
+
+
+
+
+
+
+
+
+
the current page's number
+
+
+
+
+
+
+
maxPage
+
+
+
+
+
+number
+
+
+
+
+
+
+
+
+
+
the highest page number, acts as the upper bound
+
+
+
+
+
+
+
limit
+
+
+
+
+
+number
+
+
+
+
+
+
+
+
+
+
the maximum total number of pages that are allocated
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/module-QuestionSubmissionComponent.html b/docs/module-QuestionSubmissionComponent.html
index 48522f274..7a0c2a2ad 100644
--- a/docs/module-QuestionSubmissionComponent.html
+++ b/docs/module-QuestionSubmissionComponent.html
@@ -2,7 +2,7 @@
- JSDoc: Module: QuestionSubmissionComponent
+ JSDoc: Class: module:QuestionSubmissionComponent
@@ -17,7 +17,7 @@
-
Module: QuestionSubmissionComponent
+
Class: module:QuestionSubmissionComponent
@@ -28,6 +28,11 @@
Module: QuestionSubmissionComponent
+
module:QuestionSubmissionComponent()
+
+
QuestionSubmissionComponent is a component that creates a form
+thats displayed whenever a query is run. It enables the user
+to submit questions that they cant find the answer for.
- The label to use for the e-mail address input
-Optionally provided, otherwise defaults to `Email Address`
+ Reference to the locale as set in the global config
- Question submission is based on a form as context.
-Optionally provided, otherwise defaults to native form node within container
+ Reference to the storage model
- The label to use for the Question
-Optionally provided, otherwise defaults to `What is your question?`
+ bindFormFocus will wire up the DOM focus event to serverside reporting
(static) from
- resultsData expected format: { data: { ... }, highlightedFields: { ... }}
+ Constructs an SDK Result from an answers-core Result
@@ -83,6 +1318,101 @@
- The optional input key for the vertical search configuration
-If not provided, auto-complete and search will be based on universal
+ Options to pass to the autocomplete component
- Query submission is based on a form as context.
-Optionally provided, otherwise defaults to native form node within container
+ The default initial search query, can be an empty string
- The input element used for searching and wires up the keyboard interaction
-Optionally provided.
+ Query submission is based on a form as context.
+Optionally provided, otherwise defaults to native form node within container
- The optional vertical key for vertical search configuration
-If not provided, auto-complete and search will be based on universal
+ Options for the geolocation timeout alert.
- The query text to show as the first item for auto complete.
-Optionally provided
+ The input element used for searching and wires up the keyboard interaction
+Optionally provided.
- The query string to use for the input box, provided to template for rendering.
-Optionally provided
+ true if there is another search bar present on the page.
+Twins only update the query, and do not search
- submitURL will force the search query submission to get
-redirected to the URL provided.
-Optional, defaults to null.
-
-If no redirectUrl provided, we keep the page as a single page app.
+ Controls showing and hiding the search clear button
- The search text used for labeling the input box, also provided to template.
-Optionally provided
+ Query submission can optionally be based on a form as context. Note that if
+a form is not used, the component has no guarantee of WCAG compliance.
- The title used, provided to the template as a data point
-Optionally provided.
+ The optional vertical key for vertical search configuration
+If not provided, auto-complete and search will be based on universal
+ submitURL will force the search query submission to get
+redirected to the URL provided.
+Optional, defaults to null.
+
+If no redirectUrl provided, we keep the page as a single page app.
+
+ redirectUrlTarget will force the search query submission to open in the frame specified if
+redirectUrl is also supplied.
+Optional, defaults to current frame.
+
+ A helper method that computes the intents of the provided query. If the query was entered
+manually into the search bar or selected via autocomplete, its intents will have been stored
+already in storage. Otherwise, a new API call will have to be issued to determine
+intent.
+
+ Registers the different event handlers that can issue a search. Note that
+different handlers are used depending on whether or not a form is used as
+context.
+
+ If _promptForLocation is enabled, we will compute the query's intent and, from there,
+determine if it's necessary to prompt the user for their location information. It will
+be unnecessary if the query does not have near me intent or we already have their location
+stored.
+
+ true if the query param is in the params object, false o/w
+
+
+
+
+
+
+ Type
+
+
+
+boolean
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
parse(url) → {Object}
+
+
+
+
+
+
+
+ parse creates a mapping of all query params in a given url
+The query param values are decoded before being put in the map
+Three types of input are supported
+ (1) full URL e.g. http://www.yext.com/?q=hello
+ (2) params with ? e.g. ?q=hello
+ (1) params without ? e.g. q=hello
+
A SimpleFilterNode represents a single, atomic filter.
+An atomic filter is a filter that filters on a single field id,
+and does not contain any children filters.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/module-SortOptionsComponent.html b/docs/module-SortOptionsComponent.html
new file mode 100644
index 000000000..2c6a0b75c
--- /dev/null
+++ b/docs/module-SortOptionsComponent.html
@@ -0,0 +1,686 @@
+
+
+
+
+ JSDoc: Class: module:SortOptionsComponent
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: module:SortOptionsComponent
+
+
+
+
+
+
+
+
+
+
+
module:SortOptionsComponent()
+
+
Renders configuration options for sorting Vertical Results.
+TODO: how to deal with multiple instances of this component (and filters in general),
+ideally "identical" filters/sorts would be synced up.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/module-StandardCardComponent.html b/docs/module-StandardCardComponent.html
new file mode 100644
index 000000000..a28681956
--- /dev/null
+++ b/docs/module-StandardCardComponent.html
@@ -0,0 +1,319 @@
+
+
+
+
+ JSDoc: Class: module:StandardCardComponent
+
+
+
+
+
+
+
+
+
+
+
+
+
Class: module:StandardCardComponent
+
+
+
+
+
+
+
+
+
+
+
module:StandardCardComponent()
+
+
Card components expect to receive a data config option, containing data regarding entity result
+each card is assigned to, including all field data in data._raw.
TemplateLoader exposes an interface for loading templates asynchronously
-from the server and registers them with the proper renderer.
-It also allows you to assign them synchronously.
- register the templates internally so that they can be later consumed
-(e.g. by components and renderers) with convienience.
-
-`fetchTemplates` will automatically call this, providing the compiled templates from the server.
-
_onReady
- A callback function to invoke once the library is ready.
-Typically fired after templates are fetched from server for rendering.
+ Gets the locale that ANSWERS was initialized to
@@ -352,7 +611,2326 @@
+ Parses a value from persistent storage, which stores strings,
+into the shape the SDK expects.
+TODO(SLAP-1111): Move this into a dedicated file/class.
+
+ This guarantees that execution of the SearchBar's search on page load occurs only
+AFTER all components have been added to the page. Trying to do this with a regular
+onCreate relies on the SearchBar having some sort of async behavior to move the execution
+of the search to the end of the call stack. For instance, relying on promptForLocation
+being set to true, which adds additional Promises that will delay the exeuction.
+
+We need to guarantee that the searchOnLoad happens after the onReady, because certain
+components will update values in storage in their onMount/onCreate, which are then expected
+to be applied to this search on page load. For example, filter components can apply
+filters on page load, which must be applied before this search is made to affect it.
+
+If no special search components exist, we still want to search on load if a query has been set,
+either from a defaultInitialSearch or from a query in the URL.
+
+ Initializes the SDK with the provided configuration. Note that before onReady
+is ever called, a check to the relevant Answers Status page is made.
+
+
+
+
+
+
+
+
+
+
+
Parameters:
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
config
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
The Answers configuration.
+
+
+
+
+
+
+
statusPage
+
+
+
+
+
+Object
+
+
+
+
+
+
+
+
+
+
An override for the baseUrl and endpoint of the
+ experience's Answers Status page.
+ Sets the geolocation tag in storage, overriding other inputs. Do not use in conjunction
+with other components that will set the geolocation internally.
+
+ Initialize the scroll event listener to send analytics events
+when the user scrolls to the bottom. Debounces scroll events so
+they are processed after the user stops scrolling
+
+ StorageIndexes is an ENUM are considered the root context
+for how data is stored and scoped in the storage.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Properties:
+
+
+
+
+
+
+
+
Name
+
+
+
Type
+
+
+
+
+
+
Description
+
+
+
+
+
+
+
+
+
GLOBAL
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The global index that should contain all application
+specific globals for the application (e.g. search params)
+
+
+
+
+
+
+
NAVIGATION
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The Navigation index contains all data to power the navigation component.
+Sometimes other components might depend directly on this as well, but
+we've opted to try to store some of that data in the global index instead.
+
+
+
+
+
+
+
UNIVERSAL_RESULTS
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The Universal Results index for all data related to search results
+for universal search
+
+
+
+
+
+
+
VERTICAL_RESULTS
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The Vertical Results index for all data related to search results
+for vertical search
+
+
+
+
+
+
+
AUTOCOMPLETE
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The Autocomplete index contains state to power the auto complete component
+This data is powered by network requests for both vertical and universal.
+
+
+
+
+
+
+
DIRECT_ANSWER
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The direct answer index contains all the data to power the Direct Answer component
+Typically this index is powered from universal results, in the response to a search query
+
+
+
+
+
+
+
FILTER
+
+
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
The Filter index is the global source of truth for all filters on a page.
+It should contain all the latest state that is used for search.
+ The Autocomplete index contains state to power the auto complete component
+This data is powered by network requests for both vertical and universal.
+
+ The direct answer index contains all the data to power the Direct Answer component
+Typically this index is powered from universal results, in the response to a search query
+
+ The Navigation index contains all data to power the navigation component.
+Sometimes other components might depend directly on this as well, but
+we've opted to try to store some of that data in the global index instead.
+
+ The component registry is a map that contains
+all available component classes used for creation or extension.
+Each component class has a unique type, which is used as the key for the registry
+
/** @module Component */
+import cloneDeep from 'lodash.clonedeep';
+
import { Renderers } from '../rendering/const';
import DOM from '../dom/dom';
import State from './state';
import { AnalyticsReporter } from '../../core'; // eslint-disable-line no-unused-vars
import AnalyticsEvent from '../../core/analytics/analyticsevent';
+import { AnswersComponentError } from '../../core/errors/errors';
/**
* Component is an abstraction that encapsulates state, behavior,
@@ -43,28 +46,26 @@
Source: ui/components/component.js
* mounted, created, etc.
*/
export default class Component {
- constructor (type, opts = {}) {
- // Simple facade pattern to enable the user to pass a single object
- // containing all the necessary options and settings
- if (typeof type === 'object') {
- opts = type;
- type = opts.type;
- }
-
+ constructor (config = {}, systemConfig = {}) {
this.moduleId = null;
/**
- * Unique name of this component instance
- * Used to distinguish between other components of the same type
+ * A unique id number for the component.
+ * @type {number}
+ */
+ this.uniqueId = systemConfig.uniqueId;
+
+ /**
+ * Name of this component instance.
* @type {String}
*/
- this.name = opts.name || this.constructor.name;
+ this.name = config.name || this.constructor.type;
/**
* Cache the options so that we can propogate properly to child components
* @type {Object}
*/
- this._opts = opts;
+ this._config = config;
/**
* An identifier used to classify the type of component.
@@ -77,7 +78,7 @@
Source: ui/components/component.js
* A local reference to the parent component, if exists
* @type {Component}
*/
- this._parent = opts.parent || null;
+ this._parentContainer = config.parentContainer || null;
/**
* A container for all the child components
@@ -89,83 +90,95 @@
Source: ui/components/component.js
* The state (data) of the component to be provided to the template for rendering
* @type {object}
*/
- this._state = new State(opts.state);
+ this._state = new State(config.state);
/**
* TODO(billy) This should be 'services'
*/
- this.core = opts.core || null;
+ this.core = systemConfig.core || null;
/**
* A local reference to the component manager, which contains all of the component classes
* eligible to be created
* @type {ComponentManager}
*/
- this.componentManager = opts.componentManager || null;
+ this.componentManager = systemConfig.componentManager || null;
/**
* A local reference to the analytics reporter, used to report events for this component
* @type {AnalyticsReporter}
*/
- this.analyticsReporter = opts.analyticsReporter || null;
+ this.analyticsReporter = systemConfig.analyticsReporter || null;
+
+ /**
+ * Options to include with all analytic events sent by this component
+ * @type {object}
+ * @private
+ */
+ this._analyticsOptions = config.analyticsOptions || {};
+
+ /**
+ * Allows the main thread to regain control while rendering child components
+ * @type {boolean}
+ */
+ this._progressivelyRenderChildren = config.progressivelyRenderChildren;
/**
* A reference to the DOM node that the component will be appended to when mounted/rendered.
* @type {HTMLElement}
*/
- if (this._parent === null) {
- if (typeof opts.container !== 'string') {
- throw new Error('Missing `container` option for component configuration. Must be of type `string`.');
+ if (this._parentContainer === null) {
+ if (typeof config.container === 'string') {
+ this._container = DOM.query(config.container) || null;
+ if (this._container === null) {
+ throw new Error('Cannot find container DOM node: ' + config.container);
+ }
}
- this._container = DOM.query(opts.container) || null;
} else {
- this._container = DOM.query(this._parent._container, opts.container);
+ this._container = DOM.query(this._parentContainer, config.container);
// If we have a parent, and the container is missing from the DOM,
// we construct the container and append it to the parent
if (this._container === null) {
this._container = DOM.createEl('div', {
- class: opts.container.substring(1, opts.container.length)
+ class: config.container.substring(1, config.container.length)
});
- DOM.append(this._parent._container, this._container);
+ DOM.append(this._parentContainer, this._container);
}
}
- if (this._container === null) {
- throw new Error('Cannot find container DOM node: ' + opts.container);
- }
-
/**
- * A custom class to be applied to {this._container} node
+ * A custom class to be applied to {this._container} node. Note that the class
+ * 'yxt-Answers-component' will be included as well.
* @type {string}
*/
- this._className = opts.class || 'component';
+ this._className = config.class || 'component';
/**
* A custom render function to be used instead of using the default renderer
* @type {Renderer}
*/
- this._render = opts.render || null;
+ this._render = config.render || null;
/**
* A local reference to the default {Renderer} that will be used for rendering the template
* @type {Renderer}
*/
- this._renderer = opts.renderer || Renderers.Handlebars;
+ this._renderer = systemConfig.renderer || Renderers.Handlebars;
/**
* The template string to use for rendering the component
* If this is left empty, we lookup the template the base templates using the templateName
* @type {string}
*/
- this._template = opts.template ? this._renderer.compile(opts.template) : null;
+ this._template = config.template ? this._renderer.compile(config.template) : null;
/**
* The templateName to use for rendering the component.
* This is only used if _template is empty.
* @type {string}
*/
- this._templateName = opts.templateName || 'default';
+ this._templateName = config.templateName || this.constructor.defaultTemplateName(config);
/**
* An internal state indicating whether or not the component has been mounted to the DOM
@@ -179,25 +192,55 @@
Source: ui/components/component.js
* By default, no transformation happens.
* @type {function}
*/
- this.transformData = opts.transformData || this.transformData || function () {};
+ this.transformData = config.transformData;
/**
* The a local reference to the callback that will be invoked when a component is created.
* @type {function}
*/
- this.onCreate = opts.onCreate || this.onCreate || function () {};
+ this.onCreate = config.onCreateOverride || this.onCreate || function () {};
+ this.onCreate = this.onCreate.bind(this);
/**
* The a local reference to the callback that will be invoked when a component is Mounted.
* @type {function}
*/
- this.onMount = opts.onMount || this.onMount || function () { };
+ this.onMount = config.onMountOverride || this.onMount || function () {};
+ this.onMount = this.onMount.bind(this);
/**
* The a local reference to the callback that will be invoked when a components state is updated.
* @type {function}
*/
- this.onUpdate = opts.onUpdate || this.onUpdate || function () { };
+ this.onUpdate = config.onUpdateOverride || this.onUpdate || function () { };
+ this.onUpdate = this.onUpdate.bind(this);
+
+ /**
+ * A user provided onCreate callback
+ * @type {function}
+ */
+ this.userOnCreate = config.onCreate || function () {};
+
+ /**
+ * A user provided onMount callback
+ * @type {function}
+ */
+ this.userOnMount = config.onMount || function () {};
+
+ /**
+ * A user provided onUpdate callback
+ * @type {function}
+ */
+ this.userOnUpdate = config.onUpdate || function () {};
+ }
+
+ /**
+ * The template to render
+ * @returns {string}
+ * @override
+ */
+ static defaultTemplateName (config) {
+ return 'default';
}
static get type () {
@@ -209,19 +252,55 @@
Source: ui/components/component.js
}
init (opts) {
- this.setState(opts.data || opts.state || {});
- this.onCreate();
+ try {
+ this.setState(opts.data || opts.state || {});
+ this.onCreate();
+ this.userOnCreate();
+ } catch (e) {
+ throw new AnswersComponentError(
+ 'Error initializing component',
+ this.constructor.type,
+ e);
+ }
+
this._state.on('update', () => {
- this.onUpdate();
- this.mount();
+ try {
+ this.onUpdate();
+ this.userOnUpdate();
+ this.unMount();
+ this.mount();
+ } catch (e) {
+ throw new AnswersComponentError(
+ 'Error updating component',
+ this.constructor.type,
+ e);
+ }
});
DOM.addClass(this._container, this._className);
+ DOM.addClass(this._container, 'yxt-Answers-component');
return this;
}
+ /**
+ * Adds a class to the container of the component.
+ * @param {string} className A comma separated value of classes
+ */
+ addContainerClass (className) {
+ DOM.addClass(this._container, className);
+ }
+
+ /**
+ * Removes the specified classes from the container of the component
+ * @param {string} className A comma separated value of classes
+ */
+ removeContainerClass (className) {
+ DOM.removeClass(this._container, className);
+ }
+
setState (data) {
- this._state.set(data);
+ const newState = Object.assign({}, { _config: this._config }, data);
+ this._state.set(newState);
return this;
}
@@ -233,19 +312,15 @@
return childComponent;
}
+ /**
+ * Unmount and remove this component and its children from the list
+ * of active components
+ */
+ remove () {
+ this._children.forEach(c => c.remove());
+ this.componentManager.remove(this);
+ }
+
/**
* Set the render method to be used for rendering the component
* @param {Function} render
@@ -281,49 +365,80 @@
Source: ui/components/component.js
}
unMount () {
+ if (!this._container) {
+ return this;
+ }
+
+ this._children.forEach(child => {
+ child.unMount();
+ });
+
DOM.empty(this._container);
+ this._children.forEach(c => c.remove());
+ this._children = [];
+ this.onUnMount();
}
- mount () {
+ mount (container) {
+ if (container) {
+ this._container = container;
+ }
+
if (!this._container) {
return this;
}
- this.unMount();
if (this.beforeMount() === false) {
return this;
}
DOM.append(this._container, this.render(this._state.asJSON()));
- this._isMounted = true;
- this._onMount();
+ // Process the DOM to determine if we should create
+ // in-memory sub-components for rendering
+ const domComponents = DOM.queryAll(this._container, '[data-component]:not([data-is-component-mounted])');
+ const data = this.transformData
+ ? this.transformData(cloneDeep(this._state.get()))
+ : this._state.get();
+ domComponents.forEach(c => this._createSubcomponent(c, data));
- // Attach analytics hooks as necessary
- let domHooks = DOM.queryAll(this._container, '[data-eventtype]');
- domHooks.forEach(this._createAnalyticsHook.bind(this));
+ if (this._progressivelyRenderChildren) {
+ this._children.forEach(child => {
+ setTimeout(() => {
+ child.mount();
+ });
+ });
+ } else {
+ this._children.forEach(child => {
+ child.mount();
+ });
+ }
- return this;
- }
+ // Attach analytics hooks as necessary
+ if (this.analyticsReporter) {
+ let domHooks = DOM.queryAll(this._container, '[data-eventtype]:not([data-is-analytics-attached])');
+ domHooks.forEach(this._createAnalyticsHook.bind(this));
+ }
- _onMount () {
+ this._isMounted = true;
this.onMount(this);
- if (this._children.length === 0) {
- return;
- }
+ this.userOnMount(this);
- this._children.forEach(child => {
- child._onMount();
- });
+ DOM.removeClass(this._container, 'yxt-Answers-component--unmounted');
+
+ return this;
}
/**
* render the template using the {Renderer} with the current state and template of the component
* @returns {string}
*/
- render (data) {
+ render (data = this._state.get()) {
this.beforeRender();
- data = this.transformData(data) || this.transformData(this._state.get());
+ // Temporary fix for passing immutable data to transformData().
+ data = this.transformData
+ ? this.transformData(cloneDeep(data))
+ : data;
let html = '';
// Use either the custom render function or the internal renderer
@@ -345,28 +460,23 @@
Source: ui/components/component.js
// So that we can query it for processing of sub components
let el = DOM.create(html);
- // Process the DOM to determine if we should create
- // in-memory sub-components for rendering
- let domComponents = DOM.queryAll(el, '[data-component]');
- domComponents.forEach(c => this._createSubcomponent(c, data));
-
this.afterRender();
return el.innerHTML;
}
_createSubcomponent (domComponent, data) {
- let dataset = domComponent.dataset;
- let type = dataset.component;
- let prop = dataset.prop;
+ domComponent.dataset.isComponentMounted = true;
+ const dataset = domComponent.dataset;
+ const type = dataset.component;
+ const prop = dataset.prop;
let opts = dataset.opts ? JSON.parse(dataset.opts) : {};
- // Rendering a sub component should be within the context,
- // of the node that we processed it from
- opts = Object.assign(opts, {
- container: domComponent
- });
+ let childData = data[prop] || {};
- let childData = data[prop];
+ opts = {
+ ...opts,
+ container: domComponent
+ };
// TODO(billy) Right now, if we provide an array as the data prop,
// the behavior is to create many components for each item in the array.
@@ -375,31 +485,33 @@
Source: ui/components/component.js
// to create many components for each item.
// Overloading and having this side effect is unintuitive and WRONG
if (!Array.isArray(childData)) {
- let childComponent = this.addChild(childData, type, opts);
- DOM.append(domComponent, childComponent.render());
+ // Rendering a sub component should be within the context,
+ // of the node that we processed it from
+ this.addChild(childData, type, opts);
return;
}
- // Otherwise, render the component as expected
- let childHTML = [];
- for (let i = 0; i < childData.length; i++) {
- let childComponent = this.addChild(childData[i], type, opts);
- childHTML.push(childComponent.render());
- }
-
- DOM.append(domComponent, childHTML.join(''));
+ childData.reverse();
+ childData.forEach(data => {
+ this.addChild(data, type, opts);
+ });
}
_createAnalyticsHook (domComponent) {
+ domComponent.dataset.isAnalyticsAttached = true;
const dataset = domComponent.dataset;
const type = dataset.eventtype;
const label = dataset.eventlabel;
+ const middleclick = dataset.middleclick;
const options = dataset.eventoptions ? JSON.parse(dataset.eventoptions) : {};
- DOM.on(domComponent, 'mousedown', () => {
- const event = new AnalyticsEvent(type, label);
- event.addOptions(options);
- this.analyticsReporter.report(event);
+ DOM.on(domComponent, 'mousedown', e => {
+ if (e.button === 0 || (middleclick && e.button === 1)) {
+ const event = new AnalyticsEvent(type, label);
+ event.addOptions(this._analyticsOptions);
+ event.addOptions(options);
+ this.analyticsReporter.report(event);
+ }
});
}
@@ -477,13 +589,13 @@
/** @module ComponentManager */
import { AnswersComponentError } from '../../core/errors/errors';
+import DOM from '../dom/dom';
+import { COMPONENT_REGISTRY } from './registry';
+
+/** @typedef {import('../../core/core').default} Core */
/**
* ComponentManager is a Singletone that contains both an internal registry of
@@ -39,24 +43,17 @@
Source: ui/components/componentmanager.js
*/
export default class ComponentManager {
constructor () {
- if (!ComponentManager.setInstance(this)) {
- return ComponentManager.getInstance();
- }
-
- /**
- * The component registry is an internal map, that contains
- * all available component CLASSES used for creation or override.
- * Each component class has a unique TYPE, which is used as the key for the registry
- * @type {Object}
- */
- this._componentRegistry = {};
-
/**
* The active components is an internal container to keep track
* of all of the components that have been constructed
*/
this._activeComponents = [];
+ /**
+ * A counter for the id the give to the next component that is created.
+ */
+ this._componentIdCounter = 0;
+
/**
* A local reference to the core library dependency
*
@@ -79,17 +76,23 @@
Source: ui/components/componentmanager.js
* A local reference to the analytics reporter dependency
*/
this._analyticsReporter = null;
+
+ /**
+ * A mapping from component types to component names, as these may be configured by a user
+ */
+ this._componentTypeToComponentNames = {};
+
+ /**
+ * A mapping of Components to moduleId storage listeners, for removal purposes.
+ */
+ this._componentToModuleIdListener = new Map();
}
- static setInstance (instance) {
+ static getInstance () {
if (!this.instance) {
- this.instance = instance;
- return true;
+ this.instance = new ComponentManager();
}
- return false;
- }
- static getInstance () {
return this.instance;
}
@@ -113,10 +116,24 @@
Source: ui/components/componentmanager.js
* @param {Component} The Component Class to register
*/
register (componentClazz) {
- this._componentRegistry[componentClazz.type] = componentClazz;
+ COMPONENT_REGISTRY[componentClazz.type] = componentClazz;
return this;
}
+ /**
+ * Returns components with names similar to the passed in component class.
+ * @param {string} componentType
+ */
+ getSimilarComponents (componentType) {
+ let similarComponents = Object.keys(COMPONENT_REGISTRY).filter(type =>
+ type.startsWith(componentType.substring(0, 2))
+ );
+ if (similarComponents.length === 0) {
+ similarComponents = Object.keys(COMPONENT_REGISTRY);
+ }
+ return similarComponents;
+ }
+
/**
* create is the entry point for constructing any and all components.
* It will instantiate a new instance of the component, and both apply
@@ -128,14 +145,21 @@
Source: ui/components/componentmanager.js
// Every component needs local access to the component manager
// because sometimes components have subcomponents that need to be
// constructed during creation
- opts = Object.assign({
+ let systemOpts = {
core: this._core,
renderer: this._renderer,
analyticsReporter: this._analyticsReporter,
- componentManager: this
- }, opts);
+ componentManager: this,
+ uniqueId: this._componentIdCounter
+ };
+ this._componentIdCounter++;
- let componentClass = this._componentRegistry[componentType];
+ let componentClass = COMPONENT_REGISTRY[componentType];
+ if (!componentClass) {
+ throw new AnswersComponentError(
+ `Component type ${componentType} is not recognized as a valid component.` +
+ ` You might have meant ${this.getSimilarComponents(componentType).join(', ')}?`);
+ }
if (
!componentClass.areDuplicateNamesAllowed() &&
@@ -146,32 +170,78 @@
Source: ui/components/componentmanager.js
componentType);
}
+ const config = {
+ isTwin: this._activeComponents.some(component => component.constructor.type === componentType),
+ ...opts
+ };
+
// Instantiate our new component and keep track of it
let component =
- new this._componentRegistry[componentType](opts)
- .init(opts);
+ new COMPONENT_REGISTRY[componentType](config, systemOpts)
+ .init(config);
this._activeComponents.push(component);
+ if (!this._componentTypeToComponentNames[componentType]) {
+ this._componentTypeToComponentNames[componentType] = [];
+ }
+ this._componentTypeToComponentNames[componentType].push(component.name);
- // If there is a local storage to power state, apply the state
+ // If there is a storage to power state, apply the state
// from the storage to the component, and then bind the component
// state to the storage via its updates
if (this._core && this._core.storage !== null) {
if (component.moduleId === undefined || component.moduleId === null) {
return component;
}
-
- this._core.storage
- .on('update', component.moduleId, (data) => {
- component.setState(data);
- });
+ const listener = {
+ eventType: 'update',
+ storageKey: component.moduleId,
+ callback: data => component.setState(data)
+ };
+ this._core.storage.registerListener(listener);
+ this._componentToModuleIdListener.set(component, listener);
}
return component;
}
+ /**
+ * Remove the provided component from the list of active components and remove
+ * the associated storage event listener
+ * @param {Component} component The component to remove
+ */
+ remove (component) {
+ this._core.storage.removeListener(this._componentToModuleIdListener.get(component));
+
+ const index = this._activeComponents.findIndex(c => c.uniqueId === component.uniqueId);
+ if (index !== -1) {
+ this._activeComponents.splice(index, 1);
+ }
+ }
+
+ /**
+ * Remove the component with the given name
+ * @param {string} name The name of the compnent to remove
+ */
+ removeByName (name) {
+ const component = this._activeComponents.find(c => c.name === name);
+ component.remove();
+ DOM.empty(component._container);
+ }
+
getActiveComponent (type) {
- return this._activeComponents.find(c => c.type === type);
+ return this._activeComponents.find(c => c.constructor.type === type);
+ }
+
+ /**
+ * Returns a concatenated list of all names associated with the given component types
+ * @param {string[]} type The types of the component
+ * @returns {string[]} The component names for the component types
+ */
+ getComponentNamesForComponentTypes (types) {
+ return types.reduce((names, type) => {
+ return names.concat(this._componentTypeToComponentNames[type] || []);
+ }, []);
}
}
'FilterBox');
}
- /**
- * The list of filters to display and control
- * @type {object[]}
- * @private
- */
- this._filterConfigs = config.filters;
-
/**
* The vertical key for the search
* @type {string}
@@ -63,20 +189,6 @@
Source: ui/components/filters/filteroptionscomponent.js
import { AnswersComponentError } from '../../../core/errors/errors';
import Filter from '../../../core/models/filter';
import DOM from '../../dom/dom';
+import HighlightedValue from '../../../core/models/highlightedvalue';
+import levenshtein from 'js-levenshtein';
+import FilterNodeFactory from '../../../core/filters/filternodefactory';
+import FilterMetadata from '../../../core/filters/filtermetadata';
+import { groupArray } from '../../../core/utils/arrayutils';
+import FilterType from '../../../core/filters/filtertype';
+import ComponentTypes from '../../components/componenttypes';
+import TranslationFlagger from '../../i18n/translationflagger';
+import StorageKeys from '../../../core/storage/storagekeys';
+import { filterIsPersisted } from '../../tools/filterutils';
/**
* The currently supported controls
@@ -43,122 +53,705 @@
Source: ui/components/filters/filteroptionscomponent.js
];
/**
- * Renders a set of options, each one representing a filter in a search.
+ * The currently supported option types.
*/
-export default class FilterOptionsComponent extends Component {
- constructor (config = {}) {
- super(config);
+const OptionTypes = {
+ RADIUS_FILTER: 'RADIUS_FILTER',
+ STATIC_FILTER: 'STATIC_FILTER'
+};
- if (!config.control || !SUPPORTED_CONTROLS.includes(config.control)) {
- throw new AnswersComponentError(
- 'FilterOptions requires a valid "control" to be provided',
- 'FilterOptions');
- }
+class FilterOptionsConfig {
+ constructor (config, persistedState) {
+ /**
+ * The type of control to display
+ * @type {string}
+ */
+ this.control = config.control;
- if (!config.options) {
- throw new AnswersComponentError(
- 'FilterOptions component requires options to be provided',
- 'FilterOptions');
- }
+ /**
+ * The type of filtering to apply to the options.
+ * @type {string}
+ */
+ this.optionType = config.optionType || OptionTypes.STATIC_FILTER;
/**
- * The list of filter options to display with checked status
+ * The list of filter options to display with checked status as
+ * initially specified in the user configuration
* @type {object[]}
- * @private
*/
- this._options = config.options.map(o => Object.assign({}, o, { checked: false }));
+ this.initialOptions = config.options.map(o => ({ ...o }));
/**
- * The type of control to display
+ * The list of filter options to display.
+ * @type {object[]}
+ */
+ this.options = config.options.map(o => ({ ...o }));
+
+ /**
+ * The label to be used in the legend
* @type {string}
- * @private
*/
- this._control = config.control;
+ this.label = config.label || TranslationFlagger.flag({
+ phrase: 'Filters',
+ context: 'Plural noun, title for a group of controls that filter results'
+ });
/**
- * The selector used for options in the template
+ * The callback function to call when changed
+ * @type {function}
+ */
+ this.onChange = config.onChange || function () { };
+
+ /**
+ * If true, stores the filter to global and persistent storage on each change
+ * @type {boolean}
+ */
+ this.storeOnChange = config.storeOnChange === undefined ? true : config.storeOnChange;
+
+ /**
+ * If true, show a button to reset the current filter selection
+ * @type {boolean}
+ */
+ this.showReset = config.showReset && this.options.length > 0;
+
+ /**
+ * Whether this FilterOptions is part of a dynamic FilterBox component (i.e. is
+ * part of a FacetsComponent). Used to correctly set the {@link FilterType} of
+ * the created {@link FilterNode}.
+ * @type {boolean}
+ */
+ this.isDynamic = config.isDynamic;
+
+ /**
+ * The label to show for the reset button
+ * @type {string}
+ */
+ this.resetLabel = config.resetLabel || TranslationFlagger.flag({
+ phrase: 'reset',
+ context: 'Button label, deselects one or more options'
+ });
+
+ /**
+ * The max number of facets to show before displaying "show more"/"show less"
+ * @type {number}
+ */
+ this.showMoreLimit = config.showMoreLimit || 5;
+
+ /**
+ * The label to show for displaying more facets
* @type {string}
- * @private
*/
- this._optionSelector = config.optionSelector || '.js-yext-filter-option';
+ this.showMoreLabel = config.showMoreLabel || TranslationFlagger.flag({
+ phrase: 'show more',
+ context: 'Displays more options'
+ });
/**
- * If true, stores the filter to storage on each change
+ * The label to show for displaying less facets
+ * @type {string}
+ */
+ this.showLessLabel = config.showLessLabel || TranslationFlagger.flag({
+ phrase: 'show less',
+ context: 'Displays less options'
+ });
+
+ /**
+ * If true, enable hiding excess facets with a "show more"/"show less" button
* @type {boolean}
- * @private
*/
- this._storeOnChange = config.storeOnChange || false;
+ this.showMore = config.showMore === undefined ? true : config.showMore;
+ this.showMore = this.showMore && this.options.length > this.showMoreLimit;
/**
- * The callback function to call when changed
- * @type {function}
- * @private
+ * If true, allow expanding and collapsing the group of facets
+ * @type {boolean}
+ */
+ this.showExpand = config.showExpand === undefined ? true : config.showExpand;
+
+ /**
+ * If true, display the number of currently applied filters when collapsed
+ * @type {boolean}
+ */
+ this.showNumberApplied = config.showNumberApplied === undefined ? true : config.showNumberApplied;
+
+ /**
+ * The selector used for options in the template
+ * @type {string}
*/
- this._onChange = config.onChange || function () {};
+ this.optionSelector = config.optionSelector || '.js-yext-filter-option';
/**
- * The template to render, based on the control
+ * The placeholder text used for the filter option search input
* @type {string}
- * @private
*/
- this._templateName = `controls/${config.control}`;
+ this.placeholderText = config.placeholderText || TranslationFlagger.flag({
+ phrase: 'Search here...',
+ context: 'Placeholder text for input field'
+ });
+
+ /**
+ * If true, display the filter option search input
+ * @type {boolean}
+ */
+ this.searchable = config.searchable || false;
+
+ /**
+ * The form label text for the search input
+ * @type {boolean}
+ */
+ this.searchLabelText = config.searchLabelText || TranslationFlagger.flag({
+ phrase: 'Search for a filter option',
+ context: 'Labels an input field'
+ });
+
+ this.validate();
+ const { persistedFilter, persistedLocationRadius } = persistedState;
+ if (!this.isDynamic) {
+ const hasPersistedLocationRadius = persistedLocationRadius || persistedLocationRadius === 0;
+ if (this.optionType === OptionTypes.STATIC_FILTER && persistedFilter) {
+ this.options = this.getPersistedStaticFilterOptions(this.options, persistedFilter);
+ } else if (this.optionType === OptionTypes.RADIUS_FILTER && hasPersistedLocationRadius) {
+ this.options = this.getPersistedLocationRadiusOptions(this.options, persistedLocationRadius);
+ }
+ }
+ }
+
+ /**
+ * Returns the initial options from config, but with the persisted filters set to
+ * selected = true.
+ *
+ * @param {Array<{{
+ * label: string,
+ * value: string,
+ * field: string,
+ * selected?: boolean
+ * }}>} initialOptions Options from the component configuration.
+ * @param {Object} persistedFilter A persisted filter, can be combined or simple
+ * @returns {Array<Object>} The options in the same format as initialOptions with updated
+ * selected values
+ */
+ getPersistedStaticFilterOptions (initialOptions, persistedFilter) {
+ return initialOptions.map(o => {
+ const filterForOption = Filter.equal(o.field, o.value);
+ const isPersisted = filterIsPersisted(filterForOption, persistedFilter);
+ return {
+ ...o,
+ selected: isPersisted
+ };
+ });
+ }
+
+ /**
+ * Returns the initial options from config, but with the persisted location radius filter
+ * set to selected = true.
+ *
+ * @param {Array<{{
+ * label: string,
+ * value: string,
+ * selected?: boolean
+ * }}>} initialOptions Options from the component configuration.
+ * @param {number} persistedLocationRadius The value of the persisted locationRadius
+ * @returns {Array<Object>} The options in the same format as initialOptions with updated
+ * selected values
+ */
+ getPersistedLocationRadiusOptions (initialOptions, persistedLocationRadius) {
+ return initialOptions.map(o => {
+ const isPersisted = o.value === persistedLocationRadius;
+ return {
+ ...o,
+ selected: isPersisted
+ };
+ });
+ }
+
+ getInitialSelectedCount () {
+ return this.options.reduce(
+ (numSelected, option) => option.selected ? numSelected + 1 : numSelected,
+ 0);
+ }
+
+ validate () {
+ if (!this.control || !SUPPORTED_CONTROLS.includes(this.control)) {
+ throw new AnswersComponentError(
+ 'FilterOptions requires a valid "control" to be provided',
+ 'FilterOptions');
+ }
+
+ if (!(this.optionType in OptionTypes)) {
+ const possibleTypes = Object.values(OptionTypes).join(', ');
+ throw new AnswersComponentError(
+ `Invalid optionType ${this.optionType} passed to FilterOptions. Expected one of ${possibleTypes}`,
+ 'FilterOptions');
+ }
+
+ if (this.optionType === OptionTypes.RADIUS_FILTER && this.control !== 'singleoption') {
+ throw new AnswersComponentError(
+ `FilterOptions of optionType ${OptionTypes.RADIUS_FILTER} requires control "singleoption"`,
+ 'FilterOptions');
+ }
+
+ if (!this.options) {
+ throw new AnswersComponentError(
+ 'FilterOptions component requires options to be provided',
+ 'FilterOptions');
+ }
+
+ if (this.control === 'singleoption' && this.options.filter(o => o.selected).length > 1) {
+ throw new AnswersComponentError(
+ 'FilterOptions component with "singleoption" control cannot have multiple selected options',
+ 'FilterOptions');
+ }
+ }
+}
+
+/**
+ * Renders a set of options, each one representing a filter in a search.
+ */
+export default class FilterOptionsComponent extends Component {
+ constructor (config = {}, systemConfig = {}) {
+ super(config, systemConfig);
+ this._initVariables(config);
+
+ if (this.config.storeOnChange) {
+ this.apply();
+ }
+
+ if (!this.config.isDynamic) {
+ this._registerBackNavigationListener();
+ }
+ }
+
+ /**
+ * Initializes the component's instance variables.
+ *
+ * @param {Object} config
+ */
+ _initVariables (config) {
+ const persistedFilter = this.core.storage.get(StorageKeys.PERSISTED_FILTER);
+ const persistedLocationRadius = this.core.storage.get(StorageKeys.PERSISTED_LOCATION_RADIUS);
+ const persistedState = { persistedFilter, persistedLocationRadius };
+
+ /**
+ * The component config
+ * @type {FilterOptionsConfig}
+ */
+ this.config = new FilterOptionsConfig(config, persistedState);
+
+ const selectedCount = this.config.getInitialSelectedCount();
+
+ /**
+ * True if the option list is expanded and visible
+ * @type {boolean}
+ */
+ this.expanded = this.config.showExpand ? selectedCount > 0 : true;
+
+ /**
+ * Whether the current is currently showing more or less. If true, is currently "show more".
+ * Only used if config.showMore is true.
+ * @type {boolean}
+ */
+ this.showMoreState = this.config.showMore;
+ }
+
+ _registerBackNavigationListener () {
+ this.core.storage.registerListener({
+ eventType: 'update',
+ storageKey: StorageKeys.HISTORY_POP_STATE,
+ callback: () => {
+ this._initVariables(this._config);
+ this.updateListeners(true, true);
+ this.setState();
+ }
+ });
}
static get type () {
- return 'FilterOptions';
+ return ComponentTypes.FILTER_OPTIONS;
+ }
+
+ /**
+ * The template to render, based on the control
+ * @returns {string}
+ * @override
+ */
+ static defaultTemplateName (config) {
+ return `controls/filteroptions`;
}
setState (data) {
+ const selectedCount = this._getSelectedCount();
super.setState(Object.assign({}, data, {
- name: this.name,
- options: this._options
+ name: this.name.toLowerCase(),
+ ...this.config,
+ showMoreState: this.showMoreState,
+ displayReset: this.config.showReset && selectedCount > 0,
+ expanded: this.expanded,
+ selectedCount,
+ isSingleOption: this.config.control === 'singleoption'
}));
}
onMount () {
- DOM.delegate(this._container, this._optionSelector, 'click', (event) => {
- this._updateOption(parseInt(event.target.dataset.index), event.target.checked);
+ DOM.delegate(
+ DOM.query(this._container, `.yxt-FilterOptions-options`),
+ this.config.optionSelector,
+ 'click',
+ event => {
+ let selectedCountEl = DOM.query(this._container, '.js-yxt-FilterOptions-selectedCount');
+ if (selectedCountEl) {
+ selectedCountEl.innerText = this._getSelectedCount();
+ }
+ this._updateOption(parseInt(event.target.dataset.index), event.target.checked);
+ });
+
+ // Initialize reset element if present
+ const resetEl = DOM.query(this._container, '.js-yxt-FilterOptions-reset');
+ if (resetEl) {
+ DOM.on(resetEl, 'click', this.clearOptions.bind(this));
+ }
- const filter = this._buildFilter();
- if (this._storeOnChange) {
- this.core.setFilter(this.name, filter);
+ // show more/less button
+ if (this.config.showMore) {
+ const showLessEl = DOM.query(this._container, '.js-yxt-FilterOptions-showLess');
+ const showMoreEl = DOM.query(this._container, '.js-yxt-FilterOptions-showMore');
+ const optionsOverLimitEls = DOM.queryAll(this._container, '.js-yxt-FilterOptions-aboveShowMoreLimit');
+ DOM.on(
+ showLessEl,
+ 'click',
+ () => {
+ this.showMoreState = true;
+ showLessEl.classList.add('hidden');
+ showMoreEl.classList.remove('hidden');
+ for (let optionEl of optionsOverLimitEls) {
+ optionEl.classList.add('hidden');
+ }
+ });
+ DOM.on(
+ showMoreEl,
+ 'click',
+ () => {
+ this.showMoreState = false;
+ showLessEl.classList.remove('hidden');
+ showMoreEl.classList.add('hidden');
+ for (let optionEl of optionsOverLimitEls) {
+ optionEl.classList.remove('hidden');
+ }
+ });
+ }
+
+ // searchable option list
+ if (this.config.searchable) {
+ const clearSearchEl = DOM.query(this._container, '.js-yxt-FilterOptions-clearSearch');
+ const searchInputEl = DOM.query(this._container, '.js-yxt-FilterOptions-filter');
+ const filterOptionEls = DOM.queryAll(this._container, '.js-yxt-FilterOptions-option');
+ const filterContainerEl = DOM.query(this._container, '.js-yxt-FilterOptions-container');
+
+ // On clearSearchEl click, clear search input
+ if (clearSearchEl && searchInputEl) {
+ DOM.on(clearSearchEl, 'click', event => {
+ searchInputEl.value = '';
+ DOM.trigger(searchInputEl, 'input');
+ searchInputEl.focus();
+ });
}
- this._onChange(filter);
- });
+ DOM.on(
+ searchInputEl,
+ 'input',
+ event => {
+ const filter = event.target.value;
+
+ if (!filter) {
+ filterContainerEl.classList.remove('yxt-FilterOptions-container--searching');
+ clearSearchEl.classList.add('js-hidden');
+ } else {
+ filterContainerEl.classList.add('yxt-FilterOptions-container--searching');
+ clearSearchEl.classList.remove('js-hidden');
+ }
+
+ for (let filterOption of filterOptionEls) {
+ const labelEl = DOM.query(filterOption, '.js-yxt-FilterOptions-optionLabel--name');
+ let labelText = labelEl.textContent || labelEl.innerText || '';
+ labelText = labelText.trim();
+ if (!filter) {
+ filterOption.classList.remove('hiddenSearch');
+ filterOption.classList.remove('displaySearch');
+ labelEl.innerHTML = labelText;
+ } else {
+ let matchedSubstring = this._getMatchedSubstring(labelText.toLowerCase(), filter.toLowerCase());
+ if (matchedSubstring) {
+ filterOption.classList.add('displaySearch');
+ filterOption.classList.remove('hiddenSearch');
+ labelEl.innerHTML = new HighlightedValue({
+ value: labelText,
+ matchedSubstrings: [matchedSubstring]
+ }).get();
+ } else {
+ filterOption.classList.add('hiddenSearch');
+ filterOption.classList.remove('displaySearch');
+ labelEl.innerHTML = labelText;
+ }
+ }
+ }
+ }
+ );
+ }
+
+ // expand button
+ if (this.config.showExpand) {
+ const legend = DOM.query(this._container, '.yxt-FilterOptions-clickableLegend');
+ DOM.on(
+ legend,
+ 'mousedown',
+ click => {
+ if (click.button === 0) {
+ this.expanded = !this.expanded;
+ this.setState();
+ }
+ });
+
+ DOM.on(
+ legend,
+ 'keydown',
+ key => {
+ if (key.key === ' ' || key.key === 'Enter') {
+ key.preventDefault();
+ this.expanded = !this.expanded;
+ this.setState();
+ }
+ });
+ }
+ }
+
+ /**
+ * Returns the count of currently selected options
+ * @returns {number}
+ * @private
+ */
+ _getSelectedCount () {
+ return this.config.options.filter(o => o.selected).length;
}
- _updateOption (index, checked) {
- if (this._control === 'singleoption') {
- this._options = this._options.map(o => Object.assign({}, o, { checked: false }));
+ /**
+ * Toggles the display of the reset element based on the selected count. If there are selected
+ * options, show the reset element, if not, hide it.
+ *
+ * Note: this will not have any effect if the reset element isn't in the DOM.
+ *
+ * @returns {number}
+ * @private
+ */
+ _toggleReset () {
+ const resetEl = DOM.query(this._container, '.js-yxt-FilterOptions-reset');
+ const selectedCount = this._getSelectedCount();
+ if (selectedCount > 0) {
+ resetEl.classList.remove('js-hidden');
+ } else if (!resetEl.classList.contains('js-hidden')) {
+ resetEl.classList.add('js-hidden');
}
+ }
- this._options[index] = Object.assign({}, this._options[index], { checked });
- this.setState();
+ /**
+ * Finds the length and offset of the substring where (string) option and
+ * (string) filter "match".
+ *
+ * "Match" is defined as an exact text match, or -- if the length of filter
+ * is greater than the `minFilterSizeForLevenshtein` -- a "match" can occur if
+ * any "n length" substring of option (where "n length" is the length of filter)
+ * is within the `maxLevenshteinDistance` levenshtein distance of the filter.
+ *
+ * Note: this is case sensitive.
+ *
+ * @returns {Object}
+ * @private
+ */
+ _getMatchedSubstring (option, filter) {
+ let offset = this._getOffset(option, filter);
+ if (offset > -1) {
+ return {
+ length: filter.length,
+ offset: offset
+ };
+ }
+
+ const minFilterSizeForLevenshtein = 3;
+ const maxLevenshteinDistance = 1;
+ if (filter.length > minFilterSizeForLevenshtein) {
+ // Break option into X filter.length size substrings
+ let substrings = [];
+ for (let start = 0; start <= (option.length - filter.length); start++) {
+ substrings.push(option.substr(start, filter.length));
+ }
+
+ // Find the substring that is the closest in levenshtein distance to filter
+ let minLevDist = filter.length;
+ let minLevSubstring = filter;
+ for (let substring of substrings) {
+ let levDist = this._calcLevenshteinDistance(substring, filter);
+ if (levDist < minLevDist) {
+ minLevDist = levDist;
+ minLevSubstring = substring;
+ }
+ }
+
+ // If the min levenshtein distance is below the max, count it as a match
+ if (minLevDist <= maxLevenshteinDistance) {
+ offset = this._getOffset(option, minLevSubstring);
+ if (offset > -1) {
+ return {
+ length: filter.length,
+ offset: offset
+ };
+ }
+ }
+ }
}
/**
- * Clear all options
+ * Calculate the levenshtein distance for two strings
+ * @returns {number}
+ * @private
*/
- clear () {
- const elements = DOM.queryAll(this._container, this._optionSelector);
- elements.forEach(e => e.setAttribute('checked', 'false'));
- this._applyFilter();
+ _calcLevenshteinDistance (a, b) {
+ return levenshtein(a, b);
}
/**
- * Build and return the Filter that represents the current state
- * @returns {Filter}
+ * Returns the starting index of first occurance of the (string) filter in
+ * the (string) option, or -1 if not present
+ * @returns {number}
* @private
*/
- _buildFilter () {
- const filters = this._options
- .filter(o => o.checked)
- .map(o => Filter.equal(o.field, o.value));
+ _getOffset (option, filter) {
+ return (option && filter) ? option.indexOf(filter) : -1;
+ }
+
+ /**
+ * Clears all selected options.
+ */
+ clearOptions () {
+ this.config.options = this.config.options.map(o => Object.assign({}, o, { selected: false }));
+ this.updateListeners();
+ this.setState();
+ }
+
+ /**
+ * Call the config.onChange callback with the FilterNode corresponding to the
+ * component state.
+ * @param {boolean} alwaysSaveFilterNodes
+ * @param {boolean} blockSearchOnChange
+ */
+ updateListeners (alwaysSaveFilterNodes, blockSearchOnChange) {
+ const filterNode = this.getFilterNode();
+ if (this.config.storeOnChange) {
+ this.apply();
+ }
+
+ this.config.onChange(filterNode, alwaysSaveFilterNodes, blockSearchOnChange);
+ }
+
+ _updateOption (index, selected) {
+ if (this.config.control === 'singleoption') {
+ this.config.options = this.config.options.map(o => Object.assign({}, o, { selected: false }));
+ }
+
+ this.config.options[index] = Object.assign({}, this.config.options[index], { selected });
+
+ if (this.config.showReset) {
+ this._toggleReset();
+ }
+ this.updateListeners();
+ }
+
+ /**
+ * Apply filter changes
+ */
+ apply () {
+ switch (this.config.optionType) {
+ case OptionTypes.RADIUS_FILTER:
+ this.core.setLocationRadiusFilterNode(this.getLocationRadiusFilterNode());
+ break;
+ case OptionTypes.STATIC_FILTER:
+ this.core.setStaticFilterNodes(this.name, this.getFilterNode());
+ break;
+ default:
+ throw new AnswersComponentError(`Unknown optionType ${this.config.optionType}`, 'FilterOptions');
+ }
+ }
+
+ floatSelected () {
+ this.config.options = this.config.options.sort((a, b) => b.selected - a.selected);
+ }
+
+ _buildFilter (option) {
+ return option.filter ? option.filter : Filter.equal(option.field, option.value);
+ }
+
+ _getFilterType () {
+ if (this.config.isDynamic) {
+ return FilterType.FACET;
+ }
+ return this.config.optionType === 'RADIUS_FILTER'
+ ? FilterType.RADIUS
+ : FilterType.STATIC;
+ }
+
+ _buildFilterMetadata (option) {
+ return new FilterMetadata({
+ fieldName: this.config.label,
+ displayValue: option.label,
+ filterType: this._getFilterType()
+ });
+ }
+
+ /**
+ * Return the FilterNode when this is a RADIUS_FILTER.
+ * @type {FilterNode}
+ */
+ getLocationRadiusFilterNode () {
+ const selectedOption = this.config.options.find(o => o.selected);
+ if (!selectedOption) {
+ return FilterNodeFactory.from();
+ }
+ const filterNode = {
+ metadata: this._buildFilterMetadata(selectedOption),
+ filter: { value: selectedOption.value },
+ remove: () => this._clearSingleOption(selectedOption)
+ };
+ return FilterNodeFactory.from(filterNode);
+ }
+
+ _clearSingleOption (option) {
+ option.selected = false;
+ this.updateListeners(true, true);
+ this.setState();
+ }
+
+ /**
+ * Returns this component's filter node when it is a STATIC_FILTER.
+ * This method is exposed so that components like {@link FilterBoxComponent}
+ * can access them.
+ * @returns {FilterNode}
+ */
+ getFilterNode () {
+ const filterNodes = this.config.options
+ .filter(o => o.selected)
+ .map(o => FilterNodeFactory.from({
+ filter: this._buildFilter(o),
+ metadata: this._buildFilterMetadata(o),
+ remove: () => this._clearSingleOption(o)
+ }));
+
+ const fieldIdToFilterNodes = groupArray(filterNodes, fn => fn.getFilter().getFilterKey());
+
+ // OR together filter nodes for the same field id.
+ const totalFilterNodes = [];
+ for (const sameIdNodes of Object.values(fieldIdToFilterNodes)) {
+ totalFilterNodes.push(FilterNodeFactory.or(...sameIdNodes));
+ }
- return filters.length > 0
- ? Filter.group(...filters)
- : {};
+ // AND all of the ORed together nodes.
+ return FilterNodeFactory.and(...totalFilterNodes);
}
}
@@ -171,13 +764,13 @@
import MapBoxMapProvider from './providers/mapboxmapprovider';
import StorageKeys from '../../../core/storage/storagekeys';
+import ResultsContext from '../../../core/storage/resultscontext';
const ProviderTypes = {
'google': GoogleMapProvider,
@@ -41,8 +42,8 @@
Source: ui/components/map/mapcomponent.js
};
export default class MapComponent extends Component {
- constructor (opts = {}) {
- super(opts);
+ constructor (opts = {}, systemOpts = {}) {
+ super(opts, systemOpts);
/**
* Bind this component to listen to the storage based on this key
@@ -50,9 +51,14 @@
Source: ui/components/map/mapcomponent.js
this.moduleId = StorageKeys.VERTICAL_RESULTS;
/**
- * The default template to use to render this component
+ * Configuration for the behavior when there are no vertical results.
*/
- this._templateName = 'results/map';
+ this._noResults = {
+ displayAllResults: false,
+ visible: undefined,
+ template: '',
+ ...(opts.noResults || this.core.storage.get(StorageKeys.NO_RESULTS_CONFIG))
+ };
/**
* An aliased used to determine the type of map provider to use
@@ -63,12 +69,6 @@
Source: ui/components/map/mapcomponent.js
throw new Error('MapComponent: Invalid Map Provider; must be `google` or `mapBox`');
}
- /**
- * Internal indication of whether or not to use a static or dynamic map
- * @type {boolean}
- */
- this._isStatic = opts.isStatic || false;
-
/**
* A reference to an instance of the {MapProvider} that's constructed
* @type {MapProvider}
@@ -80,31 +80,34 @@
Source: ui/components/map/mapcomponent.js
return 'Map';
}
+ /**
+ * The template to render
+ * @returns {string}
+ * @override
+ */
+ static defaultTemplateName (config) {
+ return 'results/map';
+ }
+
// TODO(billy) Make ProviderTypes a factory class
getProviderInstance (type) {
- return new ProviderTypes[type.toLowerCase()](this._opts);
+ const _config = {
+ locale: this.core.storage.get(StorageKeys.LOCALE),
+ ...this._config,
+ noResults: this._noResults
+ };
+
+ return new ProviderTypes[type.toLowerCase()](_config);
}
onCreate () {
this._map = this.getProviderInstance(this._mapProvider);
- let mapData = this.getState('map');
- if (mapData === undefined && this._isStatic) {
- return this;
- }
-
- if (this._isStatic) {
- // TODO(billy) The existing template should just take in the map `imgURL` as data
- // Instead of overriding the template like so, but NBD for now.
- this.setTemplate(this._map.generateStatic(mapData));
- return this;
- }
-
this._map.loadJS();
}
onMount () {
this._map.onLoaded(() => {
- this._map.init(this._container, this.getState('map'));
+ this._map.init(this._container, this.getState('map'), this.getState('resultsContext'));
});
}
@@ -113,6 +116,12 @@
Source: ui/components/map/providers/googlemapprovider.js<
// Only here for demo purposes, so we'll fix later.
setTimeout(() => {
let container = DOM.query(el);
- DOM.css(container, {
- width: this._width,
- height: this._height
- });
-
this.map = new google.maps.Map(container, {
- zoom: this._zoom
+ zoom: this._zoom,
+ center: this.getCenterMarker(mapData)
});
// Apply our search data to our GoogleMap
- let bounds = new google.maps.LatLngBounds();
- let googleMapMarkerConfigs = GoogleMapMarkerConfig.from(
- mapData.mapMarkers,
- this._pinConfig,
- this.map);
-
- for (let i = 0; i < googleMapMarkerConfigs.length; i++) {
- let marker = new google.maps.Marker(googleMapMarkerConfigs[i]);
- bounds.extend(marker.position);
+ if (mapData && mapData.mapMarkers.length) {
+ const collapsedMarkers = this._collapsePins
+ ? this._collapseMarkers(mapData.mapMarkers)
+ : mapData.mapMarkers;
+ let googleMapMarkerConfigs = GoogleMapMarkerConfig.from(
+ collapsedMarkers,
+ this._pinConfig,
+ this.map);
+
+ let bounds = new google.maps.LatLngBounds();
+ for (let i = 0; i < googleMapMarkerConfigs.length; i++) {
+ let marker = new google.maps.Marker(googleMapMarkerConfigs[i]);
+ if (this._onPinClick) {
+ marker.addListener('click', () => this._onPinClick(collapsedMarkers[i].item));
+ }
+ if (this._onPinMouseOver) {
+ marker.addListener('mouseover', () => this._onPinMouseOver(collapsedMarkers[i].item));
+ }
+ if (this._onPinMouseOut) {
+ marker.addListener('mouseout', () => this._onPinMouseOut(collapsedMarkers[i].item));
+ }
+ bounds.extend(marker.position);
+ }
+
+ if (googleMapMarkerConfigs.length >= 2) {
+ this.map.fitBounds(bounds);
+ }
}
-
- this.map.fitBounds(bounds);
}, 100);
}
+
+ getCenterMarker (mapData) {
+ return mapData && mapData.mapCenter && mapData.mapCenter.longitude && mapData.mapCenter.latitude
+ ? { lng: mapData.mapCenter.longitude, lat: mapData.mapCenter.latitude }
+ : { lng: this._defaultPosition.lng, lat: this._defaultPosition.lat };
+ }
}
// TODO(billy) Move to own class
@@ -183,9 +233,9 @@
Source: ui/components/map/providers/googlemapprovider.js<
/**
* Converts the storage data model of markers into GoogleAPIMarker
- * @param {GoogleMap} A reference to the google map to apply the marker to
* @param {object[]} markers The data of the marker
- * @param {Object} pinConfig The configuration to apply to the marker
+ * @param {(Object|function)} pinConfig The configuration to apply to the marker
+ * @param {GoogleMap} map reference to the google map to apply the marker to
* @returns {GoogleMapMarkerConfig[]}
*/
static from (markers, pinConfig, map) {
@@ -212,7 +262,7 @@
Source: ui/components/map/providers/googlemapprovider.js<
}
if (pinConfigObj.scaledSize) {
- icon.scaledSize = google.maps.Size(pinConfigObj.scaledSize.w, pinConfigObj.scaledSize.h);
+ icon.scaledSize = new google.maps.Size(pinConfigObj.scaledSize.w, pinConfigObj.scaledSize.h);
}
if (pinConfigObj.url) {
@@ -262,13 +312,13 @@
Source: ui/components/map/providers/mapboxmapprovider.js<
* @type {string}
*/
this.label = opts.label || undefined;
+
+ /**
+ * The url of the pin for the static map
+ * @type {string}
+ */
+ this.staticMapPin = opts.staticMapPin || undefined;
}
/**
@@ -154,14 +189,18 @@
Source: ui/components/map/providers/mapboxmapprovider.js<
static serialize (mapboxMapMarkerConfigs) {
let serializedMarkers = [];
mapboxMapMarkerConfigs.forEach((marker) => {
- serializedMarkers.push(`pin-s-${marker.label}(${marker.position.longitude},${marker.position.latitude})`);
+ if (marker.staticMapPin) {
+ serializedMarkers.push(`url-${marker.staticMapPin}(${marker.position.longitude},${marker.position.latitude})`);
+ } else {
+ serializedMarkers.push(`pin-s-${marker.label}(${marker.position.longitude},${marker.position.latitude})`);
+ }
});
return serializedMarkers.join(',');
}
/**
- * Converts the storage data model of markers into GoogleAPIMarker
- * @param {MapBox} A reference to the google map to apply the marker to
+ * Converts the storage data model of markers into MapBoxMarkerConfig
+ * @param {MapBox} A reference to the mapbox map to apply the marker to
* @param {object[]} markers The data of the marker
* @param {Object} pinConfig The configuration to apply to the marker
* @returns {MapBoxMarkerConfig[]}
@@ -182,7 +221,8 @@
/** @module MapProvider */
+import ResultsContext from '../../../../core/storage/resultscontext';
+
/**
* A MapProvider is an interface that represents that should be implemented
- * in order to integrate with a Third Party Map provider for both
- * static and interactive maps. MapProviders are used by the MapComponent.
+ * in order to integrate with a Third Party Map provider for
+ * interactive maps. MapProviders are used by the MapComponent.
*
* Implementations should extend this interface.
*/
export default class MapProvider {
- constructor (opts = {}) {
+ constructor (config = {}) {
/**
* The API Key used for interacting with the map provider
* @type {string}
*/
- this._apiKey = opts.apiKey;
+ this._apiKey = config.apiKey;
/**
- * The height of the map to append to the DOM, defaults to 100%
+ * The zoom level of the map, defaults to 14
* @type {number}
*/
- this._height = opts.height || 200;
+ this._zoom = config.zoom || 14;
/**
- * The width of the map to append to the DOM, defaults to 100%
- * @type {number}
+ * The default coordinates to display if there are no results returned
+ * Only used if showEmptyMap is set to true
+ * @type {Object}
*/
- this._width = opts.width || 600;
+ this._defaultPosition = config.defaultPosition || { lat: 37.0902, lng: -95.7129 };
/**
- * The zoom level of the map, defaults to 9
- * @type {number}
+ * Configuration for the behavior when there are no vertical results.
+ * @type {Object}
*/
- this._zoom = opts.zoom || 9;
+ this._noResults = config.noResults || {};
+
+ /**
+ * Determines if an empty map should be shown when there are no results
+ * @type {boolean}
+ */
+ this._showEmptyMap = config.showEmptyMap || false;
/**
* A reference to the underlying map instance, created by the external lib.
@@ -73,17 +82,61 @@
*/
this._isLoaded = false;
+ /**
+ * Callback to invoke when a pin is clicked. The clicked item(s) are passed to the callback
+ * @type {function}
+ */
+ this._onPinClick = config.onPinClick || null;
+
+ /**
+ * Callback to invoke when a pin is hovered. The hovered item is passed to the callback
+ * @type {function}
+ */
+ this._onPinMouseOver = config.onPinMouseOver || null;
+
+ /**
+ * Callback to invoke when a pin is no longer hovered after being hovered.
+ * The hovered item is passed to the callback
+ * @type {function}
+ */
+ this._onPinMouseOut = config.onPinMouseOut || null;
+
/**
* Callback to invoke once the Javascript is loaded
* @type {function}
*/
- this._onLoaded = opts.onLoaded || function () {};
+ this._onLoaded = config.onLoaded || function () {};
/**
* The custom configuration override to use for the map markers
* @type {Object|Function}
*/
- this._pinConfig = typeof opts.pin === 'function' ? opts.pin : Object.assign(MapProvider.DEFAULT_PIN_CONFIG, opts.pin);
+ this._pinConfig = typeof config.pin === 'function' ? config.pin : Object.assign(MapProvider.DEFAULT_PIN_CONFIG, config.pin);
+
+ /**
+ * Determines whether or not to collapse pins at the same lat/lng
+ * @type {boolean}
+ */
+ this._collapsePins = config.collapsePins || false;
+
+ /**
+ * Locale of the map. MapComponent supplies the locale specifed by
+ * ANSWERS.init() by default
+ * @type {string}
+ */
+ this._locale = this._getValidatedLocale(config.locale);
+ }
+
+ /**
+ * Returns the locale if it passes validation, otherwise returns 'en'
+ * @param {string} locale
+ */
+ _getValidatedLocale (locale) {
+ if (locale.length < 2) {
+ console.error(`Locale '${locale}' must include at least two characters. Falling back to 'en'`);
+ return 'en';
+ }
+ return locale;
}
/**
@@ -103,6 +156,14 @@
/** @module NavigationComponent */
+/* global Node */
+
import Component from '../component';
import { AnswersComponentError } from '../../../core/errors/errors';
import StorageKeys from '../../../core/storage/storagekeys';
+import DOM from '../../dom/dom';
+import { mergeTabOrder, getDefaultTabOrder, getUrlParams } from '../../tools/taborder';
+import { filterParamsForExperienceLink, replaceUrlParams } from '../../../core/utils/urlutils.js';
+import TranslationFlagger from '../../i18n/translationflagger';
+
+/**
+ * The debounce duration for resize events
+ * @type {number}
+ */
+const RESIZE_DEBOUNCE = 100;
+
+/**
+ * The breakpoint for mobile
+ * @type {number}
+ */
+const MOBILE_BREAKPOINT = 767;
+
+/**
+ * Enum options for mobile overflow beahvior
+ * @type {Object.<string, string>}
+ */
+const MOBILE_OVERFLOW_BEHAVIOR_OPTION = {
+ COLLAPSE: 'COLLAPSE',
+ INNERSCROLL: 'INNERSCROLL'
+};
/**
* The Tab is a model that is used to power the Navigation tabs in the view.
@@ -61,7 +88,7 @@
Source: ui/components/navigation/navigationcomponent.js
* By providing this, enables dynamic sorting based on results.
* @type {string}
*/
- this.configId = config.configId || null;
+ this.verticalKey = config.verticalKey || null;
/**
* The base URL used for constructing the URL with params
@@ -84,22 +111,29 @@
Source: ui/components/navigation/navigationcomponent.js
}
/**
- * from will construct a map of configId to {Tab} from
+ * from will construct a map of verticalKey to {Tab} from
* a configuration file
* @param {object} tabsConfig the configuration to use
*/
static from (tabsConfig) {
- let tabs = {};
+ const tabs = {};
// Parse the options and build out our tabs and
for (let i = 0; i < tabsConfig.length; i++) {
- let tab = tabsConfig[i];
+ const tab = { ...tabsConfig[i] };
+
+ // If a tab is configured to be hidden in this component,
+ // do not process it
+ if (tab.hideInNavigation) {
+ continue;
+ }
+
// For tabs without config ids, map their URL to the configID
// to avoid duplication of renders
- if (tab.configId === undefined && tabs[tab.configId] === undefined) {
- tab.configId = tab.url;
+ if (!tab.verticalKey && !tabs[tab.url]) {
+ tab.verticalKey = tab.url;
}
- tabs[tab.configId] = new Tab(tab);
+ tabs[tab.verticalKey] = new Tab(tab);
}
return tabs;
}
@@ -111,8 +145,23 @@
Source: ui/components/navigation/navigationcomponent.js
* @extends Component
*/
export default class NavigationComponent extends Component {
- constructor (config = {}) {
- super(config);
+ constructor (config = {}, systemConfig = {}) {
+ super(config, systemConfig);
+
+ /**
+ * The label to show on the dropdown menu button when overflow
+ * @type {string}
+ */
+ this.overflowLabel = config.overflowLabel || TranslationFlagger.flag({
+ phrase: 'More',
+ context: 'Button label, displays more items'
+ });
+
+ /**
+ * The optional icon to show on the dropdown menu button when overflow
+ * @type {string}
+ */
+ this.overflowIcon = config.overflowIcon || 'kabob';
/**
* The data storage key
@@ -121,26 +170,68 @@
Source: ui/components/navigation/navigationcomponent.js
this.moduleId = StorageKeys.NAVIGATION;
/**
- * The handlebars template to use
- * @type {string}
+ * Tabs config from global navigation config
+ * @type {Array.<object>}
* @private
*/
- this._templateName = 'navigation/navigation';
+ this._tabsConfig = config.verticalPages ||
+ this.core.storage.get(StorageKeys.VERTICAL_PAGES_CONFIG).get();
/**
- * Unordered map of each tab, keyed by VS configId
+ * Unordered map of each tab, keyed by VS verticalKey
* @type {Object.<String, Object>}
* @private
*/
- this._tabs = Tab.from(config.tabs);
+ this._tabs = Tab.from(this._tabsConfig);
/**
* The order of the tabs, parsed from configuration or URL.
* This gets updated based on the server results
- * @type {Array.<String>} The list of VS configIds
+ * @type {Array.<String>} The list of VS verticalKeys
+ * @private
+ */
+ this._tabOrder = getDefaultTabOrder(
+ this._tabsConfig, getUrlParams(this.core.storage.getCurrentStateUrlMerged()));
+
+ /**
+ * Breakpoints at which navigation items move to the "more" dropdown
+ * @type {number[]}
* @private
*/
- this._tabOrder = this.getDefaultTabOrder(config.tabs, this.getUrlParams());
+ this._navBreakpoints = [];
+
+ /**
+ * The mobile overflow behavior config
+ * @type {string}
+ */
+ this._mobileOverflowBehavior = config.mobileOverflowBehavior || MOBILE_OVERFLOW_BEHAVIOR_OPTION.COLLAPSE;
+
+ /**
+ * The ARIA label
+ * @type {string}
+ */
+ this._ariaLabel = config.ariaLabel || TranslationFlagger.flag({
+ phrase: 'Search Page Navigation',
+ context: 'Noun, labels the navigation for the search page'
+ });
+
+ this.checkOutsideClick = this.checkOutsideClick.bind(this);
+ this.checkMobileOverflowBehavior = this.checkMobileOverflowBehavior.bind(this);
+
+ const reRender = () => {
+ this.setState(this.core.storage.get(StorageKeys.NAVIGATION) || {});
+ };
+
+ this.core.storage.registerListener({
+ eventType: 'update',
+ storageKey: StorageKeys.API_CONTEXT,
+ callback: reRender
+ });
+ this.core.storage.registerListener({
+ eventType: 'update',
+ storageKey: StorageKeys.SESSIONS_OPT_IN,
+ callback: reRender
+ });
}
static get type () {
@@ -148,107 +239,222 @@
Source: ui/components/navigation/navigationcomponent.js
}
/**
- * Since the server data only provides a list of
- * VS configIds, we need to compute and transform
- * the data into the proper format for rendering.
- *
+ * The template to render
+ * @returns {string}
* @override
*/
- setState (data) {
- if (data.tabOrder !== undefined) {
- this._tabOrder = this.mergeTabOrder(data.tabOrder, this._tabOrder);
- }
+ static defaultTemplateName (config) {
+ return 'navigation/navigation';
+ }
- // Since the tab ordering can change based on the server data
- // we need to update each tabs URL to include the order as part of their params.
- // This helps with persisting state across verticals.
- let tabs = [];
- for (let i = 0; i < this._tabOrder.length; i++) {
- let tab = this._tabs[this._tabOrder[i]];
- if (tab !== undefined) {
- tab.url = this.generateTabUrl(tab.baseUrl, this.getUrlParams());
- tabs.push(tab);
- }
+ onCreate () {
+ // TODO: Re-rendering and re-mounting the component every tim e the window changes size
+ // is not great.
+ DOM.on(window, 'resize', this.checkMobileOverflowBehavior);
+ }
+
+ onDestroy () {
+ DOM.off(window, 'resize', this.checkMobileOverflowBehavior);
+ }
+
+ onMount () {
+ if (this.shouldCollapse()) {
+ this._navBreakpoints = [];
+ this.bindOverflowHandlers();
+ this.refitNav();
+ DOM.on(DOM.query(this._container, '.yxt-Nav-more'), 'click', this.toggleMoreDropdown.bind(this));
}
+ }
- return super.setState({
- tabs: tabs
- });
+ onUnMount () {
+ this.unbindOverflowHandlers();
}
- getUrlParams () {
- return new URLSearchParams(window.location.search.substring(1));
+ bindOverflowHandlers () {
+ DOM.on(window, 'click', this.checkOutsideClick);
}
- /**
- * getDefaultTabOrder will compute the initial tab ordering based
- * on a combination of the configuration provided directly to the component
- * and the url params.
- * @param {Object[]} tabsConfig
- * @param {UrlSearchParams}
- */
- getDefaultTabOrder (tabsConfig, urlParams) {
- let tabOrder = [];
+ unbindOverflowHandlers () {
+ DOM.off(window, 'click', this.checkOutsideClick);
+ }
- // Use the ordering from the URL as the primary configuration
- // And then merge it with the local configuration, if provided.
- if (urlParams && urlParams.has('tabOrder')) {
- tabOrder = urlParams.get('tabOrder').split(',');
+ refitNav () {
+ const container = DOM.query(this._container, '.yxt-Nav-container');
+ const moreButton = DOM.query(this._container, '.yxt-Nav-more');
+ const mainLinks = DOM.query(this._container, '.yxt-Nav-expanded');
+ const collapsedLinks = DOM.query(this._container, '.yxt-Nav-modal');
+
+ const navWidth = moreButton.classList.contains('yxt-Nav-item--more')
+ ? container.offsetWidth
+ : container.offsetWidth - moreButton.offsetWidth;
+ let numBreakpoints = this._navBreakpoints.length;
+
+ // sum child widths instead of using parent's width to avoid
+ // browser inconsistencies
+ let mainLinksWidth = 0;
+ for (const el of mainLinks.children) {
+ mainLinksWidth += el.offsetWidth;
}
- for (let i = 0; i < tabsConfig.length; i++) {
- const tab = tabsConfig[i];
- // Some tabs don't have configId, so we map it from URL
- if (tab.configId === undefined) {
- tab.configId = tab.url;
+ if (mainLinksWidth > navWidth) {
+ this._navBreakpoints.push(mainLinksWidth);
+ const lastLink = mainLinks.children.item(mainLinks.children.length - 1);
+ if (lastLink === null) {
+ return;
}
+ this._prepend(collapsedLinks, lastLink);
- // Avoid duplicates if config was provided from URL
- if (tabOrder.includes(tab.configId)) {
- continue;
+ if (moreButton.classList.contains('yxt-Nav-item--more')) {
+ moreButton.classList.remove('yxt-Nav-item--more');
+ }
+ } else {
+ if (numBreakpoints && navWidth > this._navBreakpoints[numBreakpoints - 1]) {
+ const firstLink = collapsedLinks.children.item(0);
+ if (firstLink === null) {
+ return;
+ }
+ mainLinks.append(firstLink);
+ this._navBreakpoints.pop();
+ numBreakpoints--;
}
- // isFirst should always be the first element in the list
- if (tab.isFirst) {
- tabOrder.unshift(tab.configId);
- } else {
- tabOrder.push(tab.configId);
+ if (collapsedLinks.children.length === 0) {
+ moreButton.classList.add('yxt-Nav-item--more');
}
}
- return tabOrder;
+ this.closeMoreDropdown();
+ if (mainLinksWidth > navWidth ||
+ (numBreakpoints > 0 && navWidth > this._navBreakpoints[numBreakpoints - 1])) {
+ this.refitNav();
+ }
+ }
+
+ closeMoreDropdown () {
+ const collapsed = DOM.query(this._container, '.yxt-Nav-modal');
+ collapsed.classList.remove('is-active');
+ const moreButton = DOM.query(this._container, '.yxt-Nav-more');
+ moreButton.setAttribute('aria-expanded', false);
+ }
+
+ openMoreDropdown () {
+ const collapsed = DOM.query(this._container, '.yxt-Nav-modal');
+ collapsed.classList.add('is-active');
+ const moreButton = DOM.query(this._container, '.yxt-Nav-more');
+ moreButton.setAttribute('aria-expanded', true);
+ }
+
+ toggleMoreDropdown () {
+ const collapsed = DOM.query(this._container, '.yxt-Nav-modal');
+ collapsed.classList.toggle('is-active');
+ const moreButton = DOM.query(this._container, '.yxt-Nav-more');
+ moreButton.setAttribute('aria-expanded', collapsed.classList.contains('is-active'));
+ }
+
+ checkOutsideClick (e) {
+ if (this._closest(e.target, '.yxt-Nav-container')) {
+ return;
+ }
+
+ this.closeMoreDropdown();
+ }
+
+ checkMobileOverflowBehavior () {
+ if (this._checkMobileOverflowBehaviorTimer) {
+ clearTimeout(this._checkMobileOverflowBehaviorTimer);
+ }
+
+ this._checkMobileOverflowBehaviorTimer = setTimeout(this.setState.bind(this), RESIZE_DEBOUNCE);
}
/**
- * mergeTabOrder merges two arrays into one
- * by appending additional tabs to the end of the original array
- * @param {string[]} tabOrder Tab order provided by the server
- * @param {string[]} otherTabOrder Tab order provided by configuration
- * @return {string[]}
+ * Since the server data only provides a list of
+ * VS verticalKeys, we need to compute and transform
+ * the data into the proper format for rendering.
+ *
+ * @override
*/
- mergeTabOrder (tabOrder, otherTabOrder) {
- for (let i = 0; i < otherTabOrder.length; i++) {
- const tabConfig = otherTabOrder[i];
- if (tabOrder.includes(tabConfig)) {
- continue;
- }
+ setState (data = {}) {
+ if (data.tabOrder !== undefined) {
+ this._tabOrder = mergeTabOrder(data.tabOrder, this._tabOrder, this._tabs);
+ }
+
+ const params = getUrlParams(this.core.storage.getCurrentStateUrlMerged());
+ params.set('tabOrder', this._tabOrder);
+ const context = this.core.storage.get(StorageKeys.API_CONTEXT);
+ if (context) {
+ params.set(StorageKeys.API_CONTEXT, context);
+ }
+ const referrerPageUrl = this.core.storage.get(StorageKeys.REFERRER_PAGE_URL);
+ if (referrerPageUrl !== undefined) {
+ params.set(StorageKeys.REFERRER_PAGE_URL, referrerPageUrl);
+ }
- // isFirst should be an override to dynamic tab ordering.
- if (this._tabs[tabConfig] && this._tabs[tabConfig].isFirst) {
- tabOrder.unshift(tabConfig);
- } else {
- tabOrder.push(tabConfig);
+ const filteredParams = filterParamsForExperienceLink(
+ params,
+ types => this.componentManager.getComponentNamesForComponentTypes(types)
+ );
+
+ // Since the tab ordering can change based on the server data
+ // we need to update each tabs URL to include the order as part of their params.
+ // This helps with persisting state across verticals.
+ const tabs = [];
+ for (let i = 0; i < this._tabOrder.length; i++) {
+ const tab = this._tabs[this._tabOrder[i]];
+ if (tab !== undefined) {
+ tab.url = replaceUrlParams(tab.baseUrl, filteredParams);
+ tabs.push(tab);
}
}
- return tabOrder;
+ return super.setState({
+ tabs: tabs,
+ overflowLabel: this.overflowLabel,
+ overflowIcon: this.overflowIcon,
+ showCollapse: this.shouldCollapse(),
+ ariaLabel: this._ariaLabel
+ });
}
- generateTabUrl (baseUrl, params = new URLSearchParams()) {
- // We want to persist the params from the existing URL to the new
- // URLS we create.
- params.set('tabOrder', this._tabOrder);
- return baseUrl + '?' + params.toString();
+ // TODO (agrow) investigate removing this
+ // ParentNode.prepend polyfill
+ // https://developer.mozilla.org/en-US/docs/Web/API/ParentNode/prepend#Polyfill
+ _prepend (collapsedLinks, lastLink) {
+ if (!collapsedLinks.hasOwnProperty('prepend')) {
+ const docFrag = document.createDocumentFragment();
+ const isNode = lastLink instanceof Node;
+ docFrag.appendChild(isNode ? lastLink : document.createTextNode(String(lastLink)));
+
+ collapsedLinks.insertBefore(docFrag, collapsedLinks.firstChild);
+ return;
+ }
+
+ collapsedLinks.prepend(lastLink);
+ }
+
+ // TODO (agrow) investigate removing this
+ // Adapted from Element.closest polyfill
+ // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
+ _closest (el, closestElSelector) {
+ if (!el.hasOwnProperty('closest')) {
+ do {
+ if (DOM.matches(el, closestElSelector)) return el;
+ el = el.parentElement || el.parentNode;
+ } while (el !== null && el.nodeType === 1);
+ return null;
+ }
+ return el.closest(closestElSelector);
+ }
+
+ shouldCollapse () {
+ switch (this._mobileOverflowBehavior) {
+ case MOBILE_OVERFLOW_BEHAVIOR_OPTION.COLLAPSE:
+ return true;
+ case MOBILE_OVERFLOW_BEHAVIOR_OPTION.INNERSCROLL:
+ const container = DOM.query(this._container, '.yxt-Nav-container') || this._container;
+ const navWidth = container.offsetWidth;
+ return navWidth > MOBILE_BREAKPOINT;
+ }
}
}
{
+ this.autoComplete(queryInput.value);
+ });
+ }
+
// Allow the user to select a result with the mouse
- DOM.delegate(this._container, '.js-yext-autocomplete-option', 'mousedown', (evt, target) => {
+ DOM.delegate(this._container, '.js-yext-autocomplete-option', 'click', (evt, target) => {
let data = target.dataset;
let val = data.short;
@@ -237,7 +324,7 @@
this._removeFilterNode()
+ });
+ }
+
/**
* A helper method to wire up our auto complete on an input selector
* @param {string} inputSelector CSS selector to bind our auto complete component to
@@ -163,19 +215,21 @@
/** @module SearchComponent */
import Component from '../component';
import DOM from '../../dom/dom';
-import Filter from '../../../core/models/filter';
import StorageKeys from '../../../core/storage/storagekeys';
+import SearchParams from '../../dom/searchparams';
+import TranslationFlagger from '../../i18n/translationflagger';
+import QueryUpdateListener from '../../../core/statelisteners/queryupdatelistener';
+import QueryTriggers from '../../../core/models/querytriggers';
+
+const IconState = {
+ 'YEXT': 0,
+ 'MAGNIFYING_GLASS': 1
+};
/**
* SearchComponent exposes an interface in order to create
@@ -40,70 +48,107 @@
Source: ui/components/search/searchcomponent.js
* @extends Component
*/
export default class SearchComponent extends Component {
- constructor (opts = {}) {
- super(opts);
-
- /**
- * The template name to use for rendering with handlebars
- * @type {string}
- */
- this._templateName = 'search/search';
+ constructor (config = {}, systemConfig = {}) {
+ super(config, systemConfig);
/**
- * The optional input key for the vertical search configuration
+ * The optional vertical key for vertical search configuration
* If not provided, auto-complete and search will be based on universal
* @type {string}
*/
- this._barKey = opts.barKey || null;
+ this._verticalKey = config.verticalKey || null;
/**
- * The optional vertical key for vertical search configuration
- * If not provided, auto-complete and search will be based on universal
- * @type {string}
+ * Query submission can optionally be based on a form as context. Note that if
+ * a form is not used, the component has no guarantee of WCAG compliance.
*/
- this._verticalKey = opts.verticalKey || null;
+ this._useForm = config.useForm !== undefined ? config.useForm : true;
/**
* Query submission is based on a form as context.
* Optionally provided, otherwise defaults to native form node within container
* @type {string} CSS selector
*/
- this._formEl = opts.formSelector || 'form';
+ this._formEl = config.formSelector || 'form';
/**
* The input element used for searching and wires up the keyboard interaction
* Optionally provided.
* @type {string} CSS selector
*/
- this._inputEl = opts.inputEl || '.js-yext-query';
+ this._inputEl = config.inputEl || '.js-yext-query';
/**
* The title used, provided to the template as a data point
- * Optionally provided.
+ * Optionally provided. If not provided, no title will be included.
* @type {string}
*/
- this.title = opts.title || 'Answers Universal Search';
+ this.title = config.title;
/**
- * The search text used for labeling the input box, also provided to template.
+ * The label text is used for labeling the input box, also provided to template.
* Optionally provided
* @type {string}
*/
- this.searchText = opts.searchText || 'What are you interested in?';
+ this.labelText = config.labelText || TranslationFlagger.flag({
+ phrase: 'Conduct a search',
+ context: 'Labels an input field'
+ });
+
+ /**
+ * The submit text is used for labeling the submit button, also provided to the template.
+ * @type {string}
+ */
+ this.submitText = config.submitText || TranslationFlagger.flag({
+ phrase: 'Submit',
+ context: 'Button label'
+ });
+
+ /**
+ * The clear text is used for labeling the clear button, also provided to the template.
+ * @type {string}
+ */
+ this.clearText = config.clearText || TranslationFlagger.flag({
+ phrase: 'Clear',
+ context: 'Verb, clears search'
+ });
+
+ /**
+ * The submit icon is an icon for the submit button, if provided it will be displayed and the
+ * submit text will be used for screen readers.
+ * @type {string|null}
+ */
+ this.submitIcon = config.submitIcon || null;
/**
* The query text to show as the first item for auto complete.
* Optionally provided
* @type {string}
*/
- this.promptHeader = opts.promptHeader || null;
+ this.promptHeader = config.promptHeader || null;
/**
* Auto focuses the input box if set to true.
* Optionally provided, defaults to false.
* @type {boolean}
*/
- this.autoFocus = opts.autoFocus === true;
+ this.autoFocus = config.autoFocus === true;
+
+ /**
+ * If true, show an "x" that allows the user to clear the current
+ * query
+ * @type {boolean}
+ */
+ this.clearButton = config.clearButton === undefined
+ ? true
+ : config.clearButton;
+
+ /**
+ * When autofocusing on load, optionally open the autocomplete
+ * (preset prompts)
+ * @type {boolean}
+ */
+ this.autocompleteOnLoad = config.autocompleteOnLoad || false;
/**
* submitURL will force the search query submission to get
@@ -112,75 +157,418 @@
Source: ui/components/search/searchcomponent.js
*
* If no redirectUrl provided, we keep the page as a single page app.
*
- * @type {boolean}
+ * @type {string}
+ */
+ this.redirectUrl = config.redirectUrl || null;
+
+ /**
+ * redirectUrlTarget will force the search query submission to open in the frame specified if
+ * redirectUrl is also supplied.
+ * Optional, defaults to current frame.
+ *
+ * @type {string}
+ */
+ this.redirectUrlTarget = config.redirectUrlTarget || '_self';
+
+ /**
+ * true if there is another search bar present on the page.
+ * Twins only update the query, and do not search
+ */
+ this._isTwin = config.isTwin;
+
+ /**
+ * The search config from ANSWERS.init configuration
*/
- this.redirectUrl = opts.redirectUrl || null;
+ this._globalSearchConfig = this.core.storage.get(StorageKeys.SEARCH_CONFIG) || {};
+
+ /**
+ * The default initial search query, can be an empty string
+ */
+ this._defaultInitialSearch = this._globalSearchConfig.defaultInitialSearch;
/**
* The query string to use for the input box, provided to template for rendering.
* Optionally provided
+ * @type {string|null}
+ */
+ this.query = config.query || this.core.storage.get(StorageKeys.QUERY);
+ this.core.storage.registerListener({
+ eventType: 'update',
+ storageKey: StorageKeys.QUERY,
+ callback: q => {
+ this.query = q;
+ if (this.queryEl) {
+ this.queryEl.value = q;
+ }
+ if (q === null) {
+ return;
+ }
+ this._updateClearButtonVisibility(q);
+ }
+ });
+
+ /**
+ * The minimum time allowed in milliseconds between searches to prevent
+ * many duplicate searches back-to-back
+ * @type {number}
+ * @private
+ */
+ this._searchCooldown = config.searchCooldown || 300;
+
+ /**
+ * When true and "near me" intent is expressed, prompt the user for their geolocation
+ * @type {boolean}
+ * @private
+ */
+ this._promptForLocation = config.promptForLocation === undefined
+ ? true
+ : Boolean(config.promptForLocation);
+
+ /**
+ * Controls showing and hiding the search clear button
+ */
+ this._showClearButton = this.clearButton && this.query;
+
+ /**
+ * Whether or not to allow empty searches.
+ * @type {boolean}
+ * @private
+ */
+ this._allowEmptySearch = !!config.allowEmptySearch;
+
+ /**
+ * The name of the child AutoComplete component.
* @type {string}
+ * @private
*/
- this.query = opts.query || this.getUrlParams().get('query') || '';
+ this._autoCompleteName = `${this.name}.autocomplete`;
+
+ /**
+ * Options to pass to the geolocation api.
+ * @type {Object}
+ */
+ this._geolocationOptions = {
+ enableHighAccuracy: false,
+ timeout: 1000,
+ maximumAge: 300000,
+ ...config.geolocationOptions
+ };
+
+ /**
+ * Options for the geolocation timeout alert.
+ * @type {Object}
+ */
+ this._geolocationTimeoutAlert = {
+ enabled: false,
+ message: TranslationFlagger.flag({
+ phrase: 'We are unable to determine your location'
+ }),
+ ...config.geolocationTimeoutAlert
+ };
+
+ /**
+ * The unique HTML id name for the autocomplete container
+ * @type {string}
+ */
+ this.autocompleteContainerIdName = `yxt-SearchBar-autocomplete--${this.name}`;
+
+ /**
+ * The unique HTML id name for the search input label
+ * @type {string}
+ */
+ this.inputLabelIdName = `yxt-SearchBar-inputLabel--${this.name}`;
+
+ /**
+ * The unique HTML id name for the search input
+ * @type {string}
+ */
+ this.inputIdName = `yxt-SearchBar-input--${this.name}`;
+
+ this.customHooks = {
+ /**
+ * Callback invoked when the clear search button is clicked
+ */
+ onClearSearch: (config.customHooks && config.customHooks.onClearSearch) || function () {},
+ /**
+ * Callback invoked when a search is conducted
+ */
+ onConductSearch: (config.customHooks && config.customHooks.onConductSearch) || function () {}
+ };
+
+ /**
+ * Options to pass to the autocomplete component
+ * @type {Object}
+ */
+ this._autocompleteConfig = {
+ shouldHideOnEmptySearch: config.autocomplete && config.autocomplete.shouldHideOnEmptySearch,
+ onOpen: config.autocomplete && config.autocomplete.onOpen,
+ onClose: config.autocomplete && config.autocomplete.onClose
+ };
+
+ if (!this._isTwin) {
+ this.initQueryUpdateListener();
+ }
+ }
+
+ /**
+ * Updates the global search listener with the searchbar's config.
+ */
+ initQueryUpdateListener () {
+ const queryUpdateListener = new QueryUpdateListener(this.core, {
+ searchCooldown: this._searchCooldown,
+ verticalKey: this._verticalKey,
+ allowEmptySearch: this._allowEmptySearch,
+ defaultInitialSearch: this._defaultInitialSearch
+ });
+ this.core.setQueryUpdateListener(queryUpdateListener);
+ this.core.queryUpdateListener.registerMiddleware(query => this.promptForLocation(query));
+ this.core.queryUpdateListener.registerMiddleware(query => this.customHooks.onConductSearch(query));
}
static get type () {
return 'SearchBar';
}
- onCreate () {
- if (this.query && this.query.length > 0) {
+ /**
+ * The template to render
+ * @returns {string}
+ * @override
+ */
+ static defaultTemplateName () {
+ return 'search/search';
+ }
+
+ /**
+ * This method is called by answers-umd AFTER the onReady() is finished, and
+ * all components have been mounted.
+ */
+ searchAfterAnswersOnReady () {
+ if (this.query != null && !this.redirectUrl) {
this.core.setQuery(this.query);
- this.search(this.query);
}
-
- this.bindBrowserHistory();
}
onMount () {
+ this.queryEl = DOM.query(this._container, this._inputEl);
+ if (this.autoFocus && !this.query && !this.autocompleteOnLoad) {
+ this.focusInputElement();
+ }
+
+ this.isUsingYextAnimatedIcon = !this._config.customIconUrl && !this.submitIcon;
+ if (this.isUsingYextAnimatedIcon) {
+ this.initAnimatedIcon();
+ }
+
// Wire up our search handling and auto complete
this.initSearch(this._formEl);
this.initAutoComplete(this._inputEl);
- if (this.autoFocus === true && this.query.length === 0) {
- DOM.query(this._container, this._inputEl).focus();
+ if (this.clearButton) {
+ this.initClearButton();
+ }
+
+ if (this.autoFocus && !this.query && this.autocompleteOnLoad) {
+ this.focusInputElement();
+ }
+ }
+
+ requestIconAnimationFrame (iconState) {
+ if (this.iconState === iconState) {
+ return;
+ }
+ this.iconState = iconState;
+ if (!this.isRequestingAnimationFrame) {
+ this.isRequestingAnimationFrame = true;
+ window.requestAnimationFrame(() => {
+ this.forwardIcon.classList.remove('yxt-SearchBar-AnimatedIcon--paused');
+ this.reverseIcon.classList.remove('yxt-SearchBar-AnimatedIcon--paused');
+ if (this.iconState === IconState.MAGNIFYING_GLASS) {
+ this.forwardIcon.classList.remove('yxt-SearchBar-AnimatedIcon--inactive');
+ this.reverseIcon.classList.add('yxt-SearchBar-AnimatedIcon--inactive');
+ } else if (this.iconState === IconState.YEXT) {
+ this.forwardIcon.classList.add('yxt-SearchBar-AnimatedIcon--inactive');
+ this.reverseIcon.classList.remove('yxt-SearchBar-AnimatedIcon--inactive');
+ }
+ this.isRequestingAnimationFrame = false;
+ });
}
}
+ animateIconToMagnifyingGlass () {
+ if (this.iconIsFrozen) {
+ return;
+ }
+ this.requestIconAnimationFrame(IconState.MAGNIFYING_GLASS);
+ }
+
+ animateIconToYext (e) {
+ let focusStillInSearchbar = false;
+ if (e && e.relatedTarget) {
+ focusStillInSearchbar = this._container.contains(e.relatedTarget);
+ }
+ if (this.iconIsFrozen || focusStillInSearchbar) {
+ return;
+ }
+ this.requestIconAnimationFrame(IconState.YEXT);
+ }
+
+ initAnimatedIcon () {
+ this.iconState = (this.autoFocus && !this.query) ? IconState.MAGNIFYING_GLASS : IconState.YEXT;
+ this.forwardIcon = DOM.query(this._container, '.js-yxt-AnimatedForward');
+ this.reverseIcon = DOM.query(this._container, '.js-yxt-AnimatedReverse');
+ const clickableElementSelectors = ['.js-yext-submit', '.js-yxt-SearchBar-clear'];
+ for (const selector of clickableElementSelectors) {
+ const clickableEl = DOM.query(this._container, selector);
+ if (clickableEl) {
+ DOM.on(clickableEl, 'mousedown', () => {
+ this.iconIsFrozen = true;
+ });
+ DOM.on(clickableEl, 'mouseup', () => {
+ this.iconIsFrozen = false;
+ });
+ }
+ }
+ DOM.on(this.queryEl, 'focus', () => {
+ this.animateIconToMagnifyingGlass();
+ });
+ DOM.on(this._container, 'focusout', e => {
+ this.animateIconToYext(e);
+ });
+ }
+
+ remove () {
+ this._autocomplete.remove();
+ super.remove();
+ }
+
+ initClearButton () {
+ const button = this._getClearButton();
+ this._showClearButton = this._showClearButton || this.query;
+ button.classList.toggle('yxt-SearchBar--hidden', !this._showClearButton);
+
+ DOM.on(button, 'click', () => {
+ this.customHooks.onClearSearch();
+ this.query = '';
+ this._showClearButton = false;
+ button.classList.add('yxt-SearchBar--hidden');
+ this.queryEl.value = this.query;
+
+ this.core.storage.delete(StorageKeys.SEARCH_OFFSET);
+ this.core.triggerSearch(QueryTriggers.SEARCH_BAR, this.query);
+
+ // Focus the input element after clearing the query, regardless of whether
+ // or not the autoFocus option is enabled.
+ // NOTE(amullings): This depends heavily on the fact that the re-renders
+ // triggered by setState and core.setQuery happen synchronously; if this
+ // stops being the case at some point, we'll need an alternative solution
+ this.focusInputElement();
+ });
+
+ DOM.on(this.queryEl, 'input', e => {
+ const input = e.target.value;
+ this.query = input;
+ this._updateClearButtonVisibility(input);
+ });
+ }
+
/**
- * A helper method to use for wiring up searching on form submission
- * @param {string} formSelector CSS selector to bind our submit handling to
+ * Registers the different event handlers that can issue a search. Note that
+ * different handlers are used depending on whether or not a form is used as
+ * context.
+ *
+ * @param {string} formSelector CSS selector to bind our form submit handling to
*/
initSearch (formSelector) {
this._formEl = formSelector;
- let form = DOM.query(this._container, formSelector);
- if (!form) {
- throw new Error('Could not initialize SearchBar; Can not find {HTMLElement} `', this._formEl, '`.');
- }
+ this._container.classList.add('yxt-SearchBar-wrapper');
+
+ if (this._useForm) {
+ let form = DOM.query(this._container, formSelector);
+ if (!form) {
+ throw new Error(
+ 'Could not initialize SearchBar; Can not find {HTMLElement} `',
+ this._formEl, '`.');
+ }
- DOM.on(form, 'submit', (e) => {
- e.preventDefault();
+ DOM.on(form, 'submit', (e) => {
+ e.preventDefault();
+ // TODO(oshi) we should not use the same css selector (this._inputEl)
+ // For both the autocomplete AND the search bar input
+ // This is incredibly confusing, and also makes the first DOM.query
+ // Rely on the order of the input el and autocomplete in the template
+ const inputEl = form.querySelector(this._inputEl);
+ this.onQuerySubmit(inputEl);
+ });
+ } else {
+ const inputEl = DOM.query(this._container, this._inputEl);
+ if (!inputEl) {
+ throw new Error(
+ 'Could not initialize SearchBar; Can not find {HTMLElement} `',
+ this._inputEl, '`.');
+ }
+ DOM.on(inputEl, 'keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ this.onQuerySubmit(inputEl);
+ }
+ });
+
+ const submitButton = DOM.query(this._container, '.js-yext-submit');
+ DOM.on(submitButton, 'click', (e) => {
+ e.preventDefault();
+ this.onQuerySubmit(inputEl);
+ });
+ }
+ }
- let query = form.querySelector(this._inputEl).value;
- let params = this.getUrlParams();
- params.set('query', query);
+ /**
+ * The handler for a query submission. This method first sets the new query in
+ * persistent and storage, than performs a debounced search.
+ *
+ * @param {Node} inputEl The input element containing the query.
+ */
+ onQuerySubmit (inputEl) {
+ const query = inputEl.value;
+ this.query = query;
+ const params = new SearchParams(this.core.storage.getCurrentStateUrlMerged());
+ params.set('query', query);
+
+ const context = this.core.storage.get(StorageKeys.API_CONTEXT);
+ if (context) {
+ params.set(StorageKeys.API_CONTEXT, context);
+ }
- // If we have a redirectUrl, we want the form to be
- // serialized and submitted.
- if (typeof this.redirectUrl === 'string') {
- window.location.href = this.redirectUrl + '?' + params.toString();
+ // If we have a redirectUrl, we want the form to be
+ // serialized and submitted.
+ if (typeof this.redirectUrl === 'string') {
+ if (this._allowEmptySearch || query) {
+ const newUrl = this.redirectUrl + '?' + params.toString();
+ window.open(newUrl, this.redirectUrlTarget) || (window.location.href = newUrl);
return false;
}
+ }
- window.history.pushState({
- query: query
- }, query, '?' + params.toString());
+ inputEl.blur();
+ DOM.query(this._container, '.js-yext-submit').blur();
+ // TODO: move this into initClearButton
+ if (this.clearButton) {
+ const button = DOM.query(this._container, '.js-yxt-SearchBar-clear');
+ if (this.query) {
+ this._showClearButton = true;
+ button.classList.remove('yxt-SearchBar--hidden');
+ } else {
+ this._showClearButton = false;
+ button.classList.add('yxt-SearchBar--hidden');
+ }
+ }
+ if (this.isUsingYextAnimatedIcon) {
+ this.animateIconToYext();
+ }
- this.core.setQuery(query);
- this.search(query);
- return false;
- });
+ this.core.storage.delete(StorageKeys.SEARCH_OFFSET);
+ this.core.triggerSearch(QueryTriggers.SEARCH_BAR, query);
+ return false;
}
/**
@@ -190,81 +578,184 @@
Source: ui/components/search/searchcomponent.js
initAutoComplete (inputSelector) {
this._inputEl = inputSelector;
- this.componentManager.create('AutoComplete', {
- parent: this,
- name: `${this.name}.autocomplete`,
+ if (this._autocomplete) {
+ this._autocomplete.remove();
+ }
+
+ this._autocomplete = this.componentManager.create('AutoComplete', {
+ parentContainer: this._container,
+ name: this._autoCompleteName,
container: '.yxt-SearchBar-autocomplete',
- barKey: this._barKey,
+ autoFocus: this.autoFocus && !this.autocompleteOnLoad,
verticalKey: this._verticalKey,
promptHeader: this.promptHeader,
originalQuery: this.query,
inputEl: inputSelector,
+ listLabelIdName: this.inputLabelIdName,
+ ...this._autocompleteConfig,
onSubmit: () => {
- DOM.trigger('form', 'submit');
+ if (this._useForm) {
+ DOM.trigger(DOM.query(this._container, this._formEl), 'submit');
+ } else {
+ const inputEl = DOM.query(this._container, inputSelector);
+ this.onQuerySubmit(inputEl);
+ }
+ },
+ onChange: () => {
+ DOM.trigger(DOM.query(this._container, inputSelector), 'input');
}
});
+ this._autocomplete.mount();
}
- search (query) {
- if (this._verticalKey) {
- const allFilters = this.core.storage.getAll(StorageKeys.FILTER);
- const totalFilter = allFilters.length > 1
- ? Filter.and(...allFilters)
- : allFilters[0];
- return this.core.verticalSearch(query, this._verticalKey, JSON.stringify(totalFilter));
- } else {
- // NOTE(billy) Temporary hack for DEMO
- // Remove me after the demo
- let nav = this.componentManager
- .getActiveComponent('Navigation');
-
- if (nav) {
- let tabs = nav.getState('tabs');
- let urls = {};
-
- if (tabs && Array.isArray(tabs)) {
- for (let i = 0; i < tabs.length; i++) {
- let params = this.getUrlParams(tabs[i].url.split('?')[1]);
- params.set('query', query);
-
- let url = tabs[i].baseUrl;
- if (params.toString().length > 0) {
- url += '?' + params.toString();
- }
- urls[tabs[i].configId] = url;
+ /**
+ * If _promptForLocation is enabled, we will compute the query's intent and, from there,
+ * determine if it's necessary to prompt the user for their location information. It will
+ * be unnecessary if the query does not have near me intent or we already have their location
+ * stored.
+ * @param {string} query The string to query against.
+ * @returns {Promise} A promise that will perform the query and update storage accordingly.
+ */
+ promptForLocation (query) {
+ if (this._promptForLocation) {
+ return this.fetchQueryIntents(query)
+ .then(queryIntents => queryIntents.includes('NEAR_ME'))
+ .then(queryHasNearMeIntent => {
+ if (queryHasNearMeIntent && !this.core.storage.get(StorageKeys.GEOLOCATION)) {
+ return new Promise((resolve, reject) =>
+ navigator.geolocation.getCurrentPosition(
+ position => {
+ this.core.storage.set(StorageKeys.GEOLOCATION, {
+ lat: position.coords.latitude,
+ lng: position.coords.longitude,
+ radius: position.coords.accuracy
+ });
+ resolve();
+ },
+ () => {
+ resolve();
+ const { enabled, message } = this._geolocationTimeoutAlert;
+ if (enabled) {
+ window.alert(message);
+ }
+ },
+ this._geolocationOptions)
+ );
}
- }
- return this.core.search(query, urls);
- }
+ });
+ } else {
+ return Promise.resolve();
+ }
+ }
- return this.core.search(query);
+ /**
+ * A helper method that computes the intents of the provided query. If the query was entered
+ * manually into the search bar or selected via autocomplete, its intents will have been stored
+ * already in storage. Otherwise, a new API call will have to be issued to determine
+ * intent.
+ * @param {string} query The query whose intent is needed.
+ * @returns {Promise} A promise containing the intents of the query.
+ */
+ fetchQueryIntents (query) {
+ const autocompleteData =
+ this.core.storage.get(`${StorageKeys.AUTOCOMPLETE}.${this._autoCompleteName}`);
+ if (!autocompleteData) {
+ const autocompleteRequest = this._verticalKey
+ ? this.core.autoCompleteVertical(
+ query,
+ this._autoCompleteName,
+ this._verticalKey)
+ : this.core.autoCompleteUniversal(query, this._autoCompleteName);
+ return autocompleteRequest.then(data => data.inputIntents);
+ } else {
+ // There are two alternatives to consider here. The user could have selected the query
+ // as an autocomplete option or manually input it themselves. If the former, use the intents
+ // of the corresponding autocomplete option. If the latter, use the inputIntents of the
+ // autocompleteData.
+ const results = autocompleteData.sections.flatMap(section => section.results);
+ const matchingResult = results.find(result => result.value === query);
+ const queryIntents = matchingResult ? matchingResult.intents : autocompleteData.inputIntents;
+ return Promise.resolve(queryIntents);
}
}
+ /**
+ * A helper method that constructs the meta information needed by the SEARCH_CLEAR_BUTTON
+ * analytics event.
+ */
+ eventOptions () {
+ const queryId = this.core.storage.get(StorageKeys.QUERY_ID);
+ const options = Object.assign(
+ {},
+ queryId && { queryId },
+ this._verticalKey && { verticalKey: this._verticalKey }
+ );
+ return JSON.stringify(options);
+ }
+
setState (data) {
+ const forwardIconOpts = {
+ iconName: 'yext_animated_forward',
+ classNames: 'Icon--lg',
+ complexContentsParams: {
+ iconPrefix: this.name
+ }
+ };
+ const reverseIconOpts = {
+ iconName: 'yext_animated_reverse',
+ classNames: 'Icon--lg',
+ complexContentsParams: {
+ iconPrefix: this.name
+ }
+ };
return super.setState(Object.assign({
title: this.title,
- searchText: this.searchText,
- query: this.query
+ inputIdName: this.inputIdName,
+ labelText: this.labelText,
+ inputLabelIdName: this.inputLabelIdName,
+ submitIcon: this.submitIcon,
+ submitText: this.submitText,
+ clearText: this.clearText,
+ showClearButton: this._showClearButton,
+ query: this.query || '',
+ eventOptions: this.eventOptions(),
+ iconId: this.name,
+ forwardIconOpts: forwardIconOpts,
+ reverseIconOpts: reverseIconOpts,
+ autoFocus: this.autoFocus && !this.query,
+ useForm: this._useForm,
+ autocompleteContainerIdName: this.autocompleteContainerIdName
}, data));
}
- getUrlParams (url) {
- url = url || window.location.search.substring(1);
- return new URLSearchParams(url);
+ focusInputElement () {
+ DOM.query(this._container, this._inputEl).focus();
}
- bindBrowserHistory () {
- DOM.on(window, 'popstate', () => {
- this.query = this.getUrlParams().get('query');
- this.setState({
- query: this.query
- });
-
- this.core.setQuery(this.query);
+ /**
+ * Returns the clear button element, if exists
+ *
+ * @returns {Element}
+ */
+ _getClearButton () {
+ return DOM.query(this._container, '.js-yxt-SearchBar-clear');
+ }
- this.search(this.query);
- });
+ /**
+ * Updates the Search inputs clear button based on the current input value
+ *
+ * @param {string} input
+ */
+ _updateClearButtonVisibility (input) {
+ const clearButton = this._getClearButton();
+
+ if (!this._showClearButton && input.length > 0) {
+ this._showClearButton = true;
+ clearButton.classList.remove('yxt-SearchBar--hidden');
+ } else if (this._showClearButton && input.length === 0) {
+ this._showClearButton = false;
+ clearButton.classList.add('yxt-SearchBar--hidden');
+ }
}
}
/** @module DOM */
-/* global HTMLElement, HTMLDocument, Window, Event */
+/* global HTMLElement, HTMLDocument, Window, Element */
let document = window.document;
-let parser = new DOMParser();
/**
* Static interface for interacting with the DOM API.
@@ -40,7 +39,6 @@
* @return {HTMLElement}
*/
static create (html) {
- return parser.parseFromString(html, 'text/html').body;
+ if ('createRange' in document) {
+ // prefer this implementation as it has wider browser support
+ // and it's better performing.
+ // see https://davidwalsh.name/convert-html-stings-dom-nodes
+ const container = document.createElement('div');
+ const frag = document.createRange().createContextualFragment(html);
+ container.appendChild(frag);
+ return container;
+ }
+
+ // fallback to this because of a bug in jsdom that causes tests to fail
+ // see: https://github.com/jsdom/jsdom/issues/399
+ return new DOMParser().parseFromString(html, 'text/html').body;
}
/**
@@ -79,7 +89,7 @@
Source: ui/dom/dom.js
* @param {HTMLElement} parent Optional context to use for a search. Defaults to document if not provided.
* @param {string} selector the CSS selector to query for
*
- * @returns {HTMLElement} the FIRST node it finds, if any
+ * @returns {Array} the FIRST node it finds, if any
*/
static queryAll (parent, selector) {
// Facade, shifting the selector to the parent argument if only one
@@ -89,11 +99,16 @@
Source: ui/dom/dom.js
parent = document;
}
+ // handle the case where client code is using a pointer to a dom node and it's null, e.g. this._container
+ if (parent == null) {
+ parent = document;
+ }
+
if (selector instanceof HTMLElement || selector instanceof HTMLDocument || selector instanceof Window) {
- return selector;
+ return [selector];
}
- return parent.querySelectorAll(selector);
+ return Array.from(parent.querySelectorAll(selector));
}
static onReady (cb) {
@@ -145,6 +160,10 @@
Source: ui/dom/dom.js
}
static addClass (node, className) {
+ if (!node) {
+ return;
+ }
+
let classes = className.split(',');
let len = classes.length;
@@ -153,6 +172,19 @@
Source: ui/dom/dom.js
}
}
+ /**
+ * Removes classes from a specified element.
+ * @param {HTMLElement} node The html element to be acted upon
+ * @param {string} className A css class to be removed
+ */
+ static removeClass (node, className) {
+ if (!node) {
+ return;
+ }
+
+ node.classList.remove(className);
+ }
+
static empty (parent) {
parent.innerHTML = '';
}
@@ -169,19 +201,38 @@
/** @module SearchParams */
+
+/* global window */
+
+/**
+ * SearchParams is a class to get the search params in a URL.
+ * It is a replacement for URL.searchParams and URLSearchParams for browsers like IE11
+ */
+export default class SearchParams {
+ constructor (url) {
+ /**
+ * Mapping of all query parameters in the given url, query param -> value
+ * Only used if URLSearchParams does not exist in the window
+ * @type {Object}
+ * @private
+ */
+ this._params = {};
+
+ if (window && window.URLSearchParams) {
+ return new URLSearchParams(url);
+ } else {
+ this._params = this.parse(url);
+ }
+ }
+
+ /**
+ * parse creates a mapping of all query params in a given url
+ * The query param values are decoded before being put in the map
+ * Three types of input are supported
+ * (1) full URL e.g. http://www.yext.com/?q=hello
+ * (2) params with ? e.g. ?q=hello
+ * (1) params without ? e.g. q=hello
+ * @param {string} url The url
+ * @returns {Object} mapping from query param -> value where value is '' if no value is provided
+ */
+ parse (url) {
+ let params = {};
+ let search = url;
+
+ if (!search) {
+ return params;
+ }
+
+ // Normalize all url inputs to string of query params separated by &
+ if (url.indexOf('?') > -1) {
+ search = url.slice(url.indexOf('?') + 1);
+ }
+
+ const encodedParams = search.split('&');
+ for (let i = 0; i < encodedParams.length; i++) {
+ const keyVal = encodedParams[i].split('=');
+ if (keyVal.length > 1) {
+ params[keyVal[0]] = SearchParams.decode(keyVal[1]);
+ } else {
+ params[keyVal[0]] = '';
+ }
+ }
+
+ return params;
+ }
+
+ /**
+ * get returns the value of the given query param
+ * @param {string} query the query param key to get the value of
+ * @return {string} param value, null otherwise
+ */
+ get (query) {
+ if (typeof this._params[String(query)] === 'undefined') {
+ return null;
+ }
+ return this._params[query];
+ }
+
+ /**
+ * set changes the value of a given query param
+ * @param {string} name the query param key
+ * @param {string} value the value of the query param update with
+ */
+ set (name, value) {
+ this._params[String(name)] = String(value);
+ }
+
+ /**
+ * has checks to see if the given query param key exists in the params object
+ * @param {string} query the query param to check
+ * @return {boolean} true if the query param is in the params object, false o/w
+ */
+ has (query) {
+ return query in this._params;
+ }
+
+ /**
+ * delete removes the given query param and its associated value from the params object
+ * @param {string} name the query param key
+ */
+ delete (name) {
+ delete this._params[String(name)];
+ }
+
+ /**
+ * toString returns a url with all the query params in the params object (without a ?)
+ * @return {string}
+ */
+ toString () {
+ let string = [];
+ for (let key in this._params) {
+ string.push(`${key}=${SearchParams.encode(this._params[key])}`);
+ }
+ return string.join('&');
+ }
+
+ entries () {
+ let entries = [];
+ for (let key in this._params) {
+ entries.push([key, this._params[key]]);
+ }
+ return entries;
+ }
+
+ /**
+ * decode returns the decoded representation of the given string
+ * @param {string} string the string to decode
+ * @return {string}
+ */
+ static decode (string) {
+ return decodeURIComponent(string.replace(/[ +]/g, '%20'));
+ }
+
+ /**
+ * decode returns the encoded representation of the given string (e.g. + -> %2B)
+ * @param {string} string the string to encode
+ * @return {string}
+ */
+ static encode (string) {
+ let replace = {
+ '!': '%21',
+ "'": '%27',
+ '(': '%28',
+ ')': '%29',
+ '%20': '+'
+ };
+ return encodeURIComponent(string).replace(/[!'()]|%20/g, function (match) {
+ return replace[match];
+ });
+ }
+}
+
/**
+ * TranslationFlagger is a class used to flag Translation calls. The usages of this class
+ * are handled and removed during SDK bundling.
+ */
+export default class TranslationFlagger {
+ /**
+ * Any calls of this method will be removed during a preprocessing step during SDK
+ * bundling.
+ *
+ * To support cases where someone may want to bundle without using our
+ * bundling tasks, this function attempts to return the same-language interpolated
+ * and pluralized value based on the information given.
+ *
+ * @param {string} phrase
+ * @param {string} pluralForm
+ * @param {string | number} count
+ * @param {string} context
+ * @param {Object} interpolationValues
+ * @returns {string}
+ */
+ static flag ({ phrase, pluralForm, count, context, interpolationValues }) {
+ const isPlural = count && count > 1 && pluralForm;
+ const declensionOfPhrase = isPlural ? pluralForm : phrase;
+ if (!interpolationValues) {
+ return declensionOfPhrase;
+ }
+
+ let interpolatedPhrase = declensionOfPhrase;
+ for (const [key, value] of Object.entries(interpolationValues)) {
+ interpolatedPhrase = interpolatedPhrase.replace(`[[${key}]]`, value);
+ }
+ return interpolatedPhrase;
+ }
+}
+
/** @module */
-export { COMPONENT_MANAGER } from './components/const';
export { default as DOM } from './dom/dom';
+export { default as SearchParams } from './dom/searchparams';
export { Renderers } from './rendering/const';
-export { default as TemplateLoader } from './rendering/templateloader';
+export { default as DefaultTemplatesLoader } from './rendering/defaulttemplatesloader';
/** @module HandlebarsRenderer */
import Renderer from './renderer';
+import Icons from '../icons';
+import HighlightedValue from '../../core/models/highlightedvalue';
+import TranslationProcessor from '../../core/i18n/translationprocessor';
/**
* HandlebarsRenderer is a wrapper around the nativate handlebars renderer.
@@ -53,12 +56,15 @@
Source: ui/rendering/handlebarsrenderer.js
this._templates = templates || {};
}
- init (templates) {
+ init (templates, locale) {
// Assign the handlebars compiler and templates based on
// information provided from external dep (in default case, it comes from external server request)
this._handlebars = templates._hb;
this._templates = templates;
+ // Store the locale that ANSWERS was initialized with
+ this._initLocale = locale;
+
// TODO(billy) Once we re-write templates using the new helpers library
// we probably don't need these custom helpers anymore
this._registerCustomHelpers();
@@ -72,10 +78,27 @@
Source: ui/rendering/handlebarsrenderer.js
this._handlebars.registerHelper(name, cb);
}
+ /**
+ * SafeString is a public interface for external dependencies to
+ * mark a string as 'safe'. Handlebars will not escape a SafeString
+ */
+ SafeString (string) {
+ return new this._handlebars.SafeString(string);
+ }
+
+ /**
+ * EscapeExpression is a public interface for external dependencies to
+ * escape a string
+ */
+ escapeExpression (string) {
+ return this._handlebars.escapeExpression(string);
+ }
+
/**
* compile a handlebars template so that it can be rendered,
* using the {Handlebars} compiler
* @param {string} template The template string to compile
+ * @returns {Function}
*/
compile (template) {
if (typeof template !== 'string') {
@@ -84,6 +107,15 @@
Source: ui/rendering/handlebarsrenderer.js
return this._handlebars.compile(template);
}
+ /**
+ * compile a template and then add it to the current template bundle
+ * @param {string} templateName The unique name for the template
+ * @param {string} template The handlebars template string
+ */
+ registerTemplate (templateName, template) {
+ this._templates[templateName] = this.compile(template);
+ }
+
/**
* render will render a template with data
* @param {Object} config Provide either a templateName or a pre-compiled template
@@ -97,10 +129,15 @@
Source: ui/rendering/handlebarsrenderer.js
return config.template(data);
}
+ if (!(config.templateName in this._templates)) {
+ throw new Error('Can\'t find template: ' + config.templateName);
+ }
+
try {
return this._templates[config.templateName](data);
} catch (e) {
- throw new Error('Can not find/render template: ' + config.templateName, e);
+ console.error('Error when trying to render the template: ' + config.templateName);
+ throw e;
}
}
@@ -113,6 +150,47 @@
import SearchParams from '../dom/searchparams';
+
+/**
+ * @param {string} urlParams a query param string like "query=hi&otherParam=hello"
+ * @returns {SearchParams}
+ */
+export function getUrlParams (urlParams) {
+ return new SearchParams(urlParams);
+}
+
+export function getDefaultTabOrder (tabsConfig, urlParams) {
+ let tabOrder = [];
+ // Use the ordering from the URL as the primary configuration
+ // And then merge it with the local configuration, if provided.
+ if (urlParams && urlParams.has('tabOrder')) {
+ tabOrder = urlParams.get('tabOrder').split(',');
+ }
+ for (const tab of tabsConfig) {
+ const verticalKeyOrUrl = tab.verticalKey || tab.url;
+ // Avoid duplicates if config was provided from URL
+ if (tabOrder.includes(verticalKeyOrUrl)) {
+ continue;
+ }
+
+ // isFirst should always be the first element in the list
+ if (tab.isFirst) {
+ tabOrder.unshift(verticalKeyOrUrl);
+ } else {
+ tabOrder.push(verticalKeyOrUrl);
+ }
+ }
+ return tabOrder;
+}
+
+/**
+ * mergeTabOrder merges two arrays into one
+ * by appending additional tabs to the end of the original array
+ * @param {string[]} tabOrder Tab order provided by the server
+ * @param {string[]} otherTabOrder Tab order provided by configuration
+ * @return {string[]}
+ */
+export function mergeTabOrder (tabOrder, otherTabOrder, tabs) {
+ for (const tabConfig of otherTabOrder) {
+ if (tabOrder.includes(tabConfig)) {
+ continue;
+ }
+ // isFirst should be an override to dynamic tab ordering.
+ if (tabs[tabConfig] && tabs[tabConfig].isFirst) {
+ tabOrder.unshift(tabConfig);
+ } else {
+ tabOrder.push(tabConfig);
+ }
+ }
+ return tabOrder;
+}
+
+export function getTabOrder (tabsConfig, dataTabOrder, urlParams) {
+ let tabOrder = getDefaultTabOrder(tabsConfig, getUrlParams(urlParams));
+ // We want to persist the params from the existing URL to the new
+ // URLS we create.
+ if (tabOrder && dataTabOrder) {
+ tabOrder = mergeTabOrder(dataTabOrder, tabOrder, tabsConfig);
+ }
+ return tabOrder;
+}
+