diff --git a/.eslintrc.js b/.eslintrc.js
index 8d899f8294..eff57b21b8 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -7,11 +7,7 @@ module.exports = {
'new-cap': [
'error',
{
- capIsNewExceptions: [
- 'EXPERIMENTAL_use',
- 'EXPERIMENTAL_connectConfigureRelatedItems',
- 'EXPERIMENTAL_configureRelatedItems',
- ],
+ capIsNewExceptionPattern: '(\\.|^)EXPERIMENTAL_.+',
},
],
'react/no-string-refs': 'error',
diff --git a/.storybook/static/answers.css b/.storybook/static/answers.css
new file mode 100644
index 0000000000..31663e3fb2
--- /dev/null
+++ b/.storybook/static/answers.css
@@ -0,0 +1,132 @@
+.my-Answers .ais-Answers-loader {
+ display: none;
+}
+
+.my-Answers .ais-Answers-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.my-Answers .ais-Answers-item {
+ height: 10rem;
+ border: 1px solid #ddd;
+ border-radius: 0.5rem;
+}
+
+.my-Answers .title {
+ padding: 0;
+ margin: 1rem;
+ font-size: 1.2rem;
+ color: #333;
+ line-height: 1.4rem;
+}
+
+.my-Answers .separator {
+ border-top: 1px solid #ddd;
+}
+
+.my-Answers .description {
+ margin: 1rem;
+ padding: 0;
+ color: #333;
+ line-height: 1.4rem;
+}
+
+.my-Answers .description em {
+ background-color: #ffc168;
+}
+
+.one-line {
+ display: -webkit-box;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.three-lines {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* skeleton loader from https://codepen.io/jordanmsykes/pen/RgPqgV - begin */
+@keyframes placeHolderShimmer {
+ 0% {
+ -webkit-transform: translateZ(0);
+ transform: translateZ(0);
+ background-position: -468px 0;
+ }
+ to {
+ -webkit-transform: translateZ(0);
+ transform: translateZ(0);
+ background-position: 468px 0;
+ }
+}
+
+.card-skeleton {
+ margin-left: 1rem;
+ margin-right: 1rem;
+ width: calc(100% - 2rem);
+ height: 10rem;
+ transition: all 0.3s ease-in-out;
+ -webkit-backface-visibility: hidden;
+ background: #fff;
+ z-index: 10;
+ opacity: 1;
+}
+
+.card-skeleton.hidden {
+ transition: all 0.3s ease-in-out;
+ opacity: 0;
+ height: 0;
+ padding: 0;
+}
+
+.card-skeleton-img {
+ width: 100%;
+ height: 120px;
+ background: #e6e6e6;
+ display: block;
+}
+
+.animated-background {
+ will-change: transform;
+ animation: placeHolderShimmer 1s linear infinite forwards;
+ -webkit-backface-visibility: hidden;
+ background: #e6e6e6;
+ background: linear-gradient(90deg, #eee 8%, #ddd 18%, #eee 33%);
+ background-size: 800px 104px;
+ height: 100%;
+ position: relative;
+}
+
+.skel-mask-container {
+ position: relative;
+}
+
+.skel-mask {
+ background: #fff;
+ position: absolute;
+ z-index: 200;
+}
+
+.skel-mask-1 {
+ width: 100%;
+ height: 15px;
+ top: 0;
+}
+
+.skel-mask-2 {
+ width: 100%;
+ height: 25px;
+ top: 45px;
+}
+
+.skel-mask-3 {
+ width: 100%;
+ height: 15px;
+ top: 145px;
+}
+/* skeleton loader - end */
diff --git a/package.json b/package.json
index 74de7b1d8d..79503e2c28 100644
--- a/package.json
+++ b/package.json
@@ -143,7 +143,7 @@
"bundlesize": [
{
"path": "./dist/instantsearch.production.min.js",
- "maxSize": "67.00 kB"
+ "maxSize": "68.00 kB"
},
{
"path": "./dist/instantsearch.development.js",
diff --git a/src/components/Answers/Answers.tsx b/src/components/Answers/Answers.tsx
new file mode 100644
index 0000000000..2bea7cf7b8
--- /dev/null
+++ b/src/components/Answers/Answers.tsx
@@ -0,0 +1,74 @@
+/** @jsx h */
+
+import { h } from 'preact';
+import cx from 'classnames';
+import Template from '../Template/Template';
+import { AnswersTemplates } from '../../widgets/answers/answers';
+import { Hits } from '../../types';
+
+type AnswersCSSClasses = {
+ root: string;
+ emptyRoot: string;
+ header: string;
+ loader: string;
+ list: string;
+ item: string;
+};
+
+export type AnswersProps = {
+ hits: Hits;
+ isLoading: boolean;
+ cssClasses: AnswersCSSClasses;
+ templateProps: {
+ [key: string]: any;
+ templates: AnswersTemplates;
+ };
+};
+
+const Answers = ({
+ hits,
+ isLoading,
+ cssClasses,
+ templateProps,
+}: AnswersProps) => (
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {hits.map((hit, position) => (
+
+ ))}
+
+ )}
+
+);
+
+export default Answers;
diff --git a/src/components/Answers/__tests__/Answers-test.tsx b/src/components/Answers/__tests__/Answers-test.tsx
new file mode 100644
index 0000000000..53239d7574
--- /dev/null
+++ b/src/components/Answers/__tests__/Answers-test.tsx
@@ -0,0 +1,124 @@
+/** @jsx h */
+
+import { h } from 'preact';
+import { render } from '@testing-library/preact';
+import Answers, { AnswersProps } from '../Answers';
+
+const defaultProps: AnswersProps = {
+ hits: [],
+ isLoading: false,
+ cssClasses: {
+ root: 'root',
+ header: 'header',
+ emptyRoot: 'empty',
+ loader: 'loader',
+ list: 'list',
+ item: 'item',
+ },
+ templateProps: {
+ templates: {
+ header: 'header',
+ loader: 'loader',
+ item: 'item',
+ },
+ },
+};
+
+describe('Answers', () => {
+ describe('Rendering', () => {
+ it('renders without anything', () => {
+ const { container } = render();
+ expect(container.querySelector('.root')).toHaveClass('empty');
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('renders the loader', () => {
+ const { container } = render(
+
+ );
+ expect(container).toMatchInlineSnapshot(`
+
+`);
+ });
+
+ it('renders the header with data', () => {
+ const props: AnswersProps = {
+ ...defaultProps,
+ templateProps: {
+ templates: {
+ ...defaultProps.templateProps.templates,
+ header: ({ hits, isLoading }) => {
+ return `${hits.length} answer(s) ${
+ isLoading ? 'loading' : 'loaded'
+ }`;
+ },
+ },
+ },
+ };
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.header')).toHaveTextContent(
+ '1 answer(s) loaded'
+ );
+ });
+
+ it('renders the answers', () => {
+ const props: AnswersProps = {
+ ...defaultProps,
+ templateProps: {
+ templates: {
+ ...defaultProps.templateProps.templates,
+ item: hit => {
+ return `answer: ${hit.title}`;
+ },
+ },
+ },
+ };
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.list')).toHaveTextContent(
+ 'answer: hello!'
+ );
+ });
+ });
+});
diff --git a/src/connectors/answers/__tests__/connectAnswers-test.ts b/src/connectors/answers/__tests__/connectAnswers-test.ts
new file mode 100644
index 0000000000..b284efca62
--- /dev/null
+++ b/src/connectors/answers/__tests__/connectAnswers-test.ts
@@ -0,0 +1,465 @@
+import algoliasearchHelper, { SearchResults } from 'algoliasearch-helper';
+import { createInstantSearch } from '../../../../test/mock/createInstantSearch';
+import { createSearchClient } from '../../../../test/mock/createSearchClient';
+import {
+ createInitOptions,
+ createRenderOptions,
+} from '../../../../test/mock/createWidget';
+import { createSingleSearchResponse } from '../../../../test/mock/createAPIResponse';
+import { wait } from '../../../../test/utils/wait';
+import connectAnswers from '../connectAnswers';
+
+const defaultRenderDebounceTime = 10;
+const defaultSearchDebounceTime = 10;
+
+describe('connectAnswers', () => {
+ describe('Usage', () => {
+ it('throws without render function', () => {
+ expect(() => {
+ // @ts-ignore: test connectAnswers with invalid parameters
+ connectAnswers()({});
+ }).toThrowErrorMatchingInlineSnapshot(`
+"The render function is not valid (received type Undefined).
+
+See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/#connector"
+`);
+ });
+
+ it('throws without `queryLanguages`', () => {
+ expect(() => {
+ // @ts-ignore: test connectAnswers with invalid parameters
+ connectAnswers(() => {})({});
+ }).toThrowErrorMatchingInlineSnapshot(`
+"The \`queryLanguages\` expects an array of strings.
+
+See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/#connector"
+`);
+ });
+ });
+
+ const setupTestEnvironment = ({
+ hits,
+ attributesForPrediction = ['description'],
+ renderDebounceTime = defaultRenderDebounceTime,
+ searchDebounceTime = defaultSearchDebounceTime,
+ }: {
+ hits: any[];
+ attributesForPrediction?: string[];
+ renderDebounceTime?: number;
+ searchDebounceTime?: number;
+ }) => {
+ const renderFn = jest.fn();
+ const unmountFn = jest.fn();
+ const client = createSearchClient({
+ // @ts-ignore-next-line
+ initIndex() {
+ return {
+ findAnswers: () => Promise.resolve({ hits }),
+ };
+ },
+ });
+ const instantSearchInstance = createInstantSearch({ client });
+ const makeWidget = connectAnswers(renderFn, unmountFn);
+ const widget = makeWidget({
+ queryLanguages: ['en'],
+ attributesForPrediction,
+ renderDebounceTime,
+ searchDebounceTime,
+ });
+
+ const helper = algoliasearchHelper(client, '', {});
+
+ return {
+ renderFn,
+ unmountFn,
+ instantSearchInstance,
+ widget,
+ helper,
+ };
+ };
+
+ it('is a widget', () => {
+ const render = jest.fn();
+ const unmount = jest.fn();
+
+ const makeWidget = connectAnswers(render, unmount);
+ const widget = makeWidget({
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ });
+
+ expect(widget).toEqual(
+ expect.objectContaining({
+ $$type: 'ais.answers',
+ init: expect.any(Function),
+ render: expect.any(Function),
+ dispose: expect.any(Function),
+ getRenderState: expect.any(Function),
+ getWidgetRenderState: expect.any(Function),
+ getWidgetSearchParameters: expect.any(Function),
+ })
+ );
+ });
+
+ it('Renders during init and render', () => {
+ const {
+ renderFn,
+ widget,
+ instantSearchInstance,
+ helper,
+ } = setupTestEnvironment({ hits: [] });
+
+ expect(renderFn).toHaveBeenCalledTimes(0);
+
+ widget.init!(
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ );
+
+ expect(renderFn).toHaveBeenCalledTimes(1);
+ expect(renderFn).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ instantSearchInstance,
+ hits: [],
+ isLoading: false,
+ widgetParams: {
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ },
+ }),
+ true
+ );
+
+ widget.render!(
+ createRenderOptions({
+ results: new SearchResults(helper.state, [
+ createSingleSearchResponse({ hits: [] }),
+ ]),
+ state: helper.state,
+ instantSearchInstance,
+ helper,
+ })
+ );
+
+ expect(renderFn).toHaveBeenCalledTimes(2);
+ expect(renderFn).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ instantSearchInstance,
+ hits: [],
+ isLoading: false,
+ widgetParams: {
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ },
+ }),
+ false
+ );
+ });
+
+ it('renders empty hits when query is not given', () => {
+ const {
+ renderFn,
+ widget,
+ helper,
+ instantSearchInstance,
+ } = setupTestEnvironment({ hits: [] });
+
+ widget.init!(
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ );
+
+ helper.state.query = '';
+
+ widget.render!(
+ createRenderOptions({
+ results: new SearchResults(helper.state, [
+ createSingleSearchResponse({ hits: [] }),
+ ]),
+ state: helper.state,
+ instantSearchInstance,
+ helper,
+ })
+ );
+
+ expect(renderFn).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ instantSearchInstance,
+ hits: [],
+ isLoading: false,
+ widgetParams: {
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ },
+ }),
+ false
+ );
+ });
+
+ it('renders loader and results when query is given', async () => {
+ const hits = [{ title: '', objectID: 'a' }];
+ const {
+ renderFn,
+ instantSearchInstance,
+ widget,
+ helper,
+ } = setupTestEnvironment({
+ hits,
+ });
+
+ widget.init!(
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ );
+
+ helper.state.query = 'a';
+ widget.render!(
+ createRenderOptions({
+ state: helper.state,
+ instantSearchInstance,
+ helper,
+ })
+ );
+
+ // render with isLoading and no hit
+ expect(renderFn).toHaveBeenCalledTimes(2);
+ expect(renderFn).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ hits: [],
+ isLoading: true,
+ }),
+ false
+ );
+
+ await wait(30); // 10(render debounce) + 10(search debounce) + 10(just to make sure)
+
+ // render with hits
+ const expectedHits = [{ title: '', objectID: 'a', __position: 1 }];
+ (expectedHits as any).__escaped = true;
+ expect(renderFn).toHaveBeenCalledTimes(3);
+ expect(renderFn).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ hits: expectedHits,
+ isLoading: false,
+ }),
+ false
+ );
+ });
+
+ it('debounces renders', async () => {
+ const hits = [{ title: '', objectID: 'a' }];
+ const {
+ renderFn,
+ instantSearchInstance,
+ widget,
+ helper,
+ } = setupTestEnvironment({
+ hits,
+ });
+
+ widget.init!(
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ );
+
+ helper.state.query = 'a';
+ widget.render!(
+ createRenderOptions({
+ state: helper.state,
+ instantSearchInstance,
+ helper,
+ })
+ );
+
+ // render with isLoading and no hit
+ expect(renderFn).toHaveBeenCalledTimes(2);
+ expect(renderFn).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ hits: [],
+ isLoading: true,
+ }),
+ false
+ );
+
+ // another query
+ helper.state.query = 'ab';
+ widget.render!(
+ createRenderOptions({
+ state: helper.state,
+ instantSearchInstance,
+ helper,
+ })
+ );
+
+ // no debounce for rendering loader
+ expect(renderFn).toHaveBeenCalledTimes(3);
+
+ // wait for debounce
+ await wait(30);
+ const expectedHits = [{ title: '', objectID: 'a', __position: 1 }];
+ (expectedHits as any).__escaped = true;
+ expect(renderFn).toHaveBeenCalledTimes(4);
+ expect(renderFn).toHaveBeenNthCalledWith(
+ 4,
+ expect.objectContaining({
+ hits: expectedHits,
+ isLoading: false,
+ }),
+ false
+ );
+
+ // wait
+ await wait(30);
+ // but no more rendering
+ expect(renderFn).toHaveBeenCalledTimes(4);
+ });
+
+ describe('getRenderState', () => {
+ it('returns the render state', () => {
+ const { instantSearchInstance, widget, helper } = setupTestEnvironment({
+ hits: [],
+ });
+
+ expect(
+ widget.getRenderState(
+ {},
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ )
+ ).toEqual({
+ answers: {
+ hits: [],
+ isLoading: false,
+ widgetParams: {
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ },
+ },
+ });
+
+ widget.init!(
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ );
+
+ expect(
+ widget.getRenderState(
+ {},
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ )
+ ).toEqual({
+ answers: {
+ hits: [],
+ isLoading: false,
+ widgetParams: {
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ },
+ },
+ });
+ });
+ });
+
+ describe('getWidgetRenderState', () => {
+ it('returns the widget render state', async () => {
+ const { instantSearchInstance, widget, helper } = setupTestEnvironment({
+ hits: [{ title: '', objectID: 'a' }],
+ });
+
+ widget.init!(
+ createInitOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ );
+
+ helper.state.query = 'a';
+ widget.render!(
+ createRenderOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ );
+
+ // render the loading state
+ expect(
+ widget.getWidgetRenderState(
+ createRenderOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ )
+ ).toEqual({
+ hits: [],
+ isLoading: true,
+ widgetParams: {
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ },
+ });
+
+ await wait(30);
+ const expectedHits = [{ title: '', objectID: 'a', __position: 1 }];
+ (expectedHits as any).__escaped = true;
+ expect(
+ widget.getWidgetRenderState(
+ createRenderOptions({
+ instantSearchInstance,
+ state: helper.state,
+ helper,
+ })
+ )
+ ).toEqual({
+ hits: expectedHits,
+ isLoading: false,
+ widgetParams: {
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ },
+ });
+ });
+ });
+});
diff --git a/src/connectors/answers/connectAnswers.ts b/src/connectors/answers/connectAnswers.ts
new file mode 100644
index 0000000000..76453bae78
--- /dev/null
+++ b/src/connectors/answers/connectAnswers.ts
@@ -0,0 +1,258 @@
+import {
+ checkRendering,
+ createDocumentationMessageGenerator,
+ createConcurrentSafePromise,
+ addQueryID,
+ debounce,
+ addAbsolutePosition,
+ noop,
+ escapeHits,
+} from '../../lib/utils';
+import {
+ Connector,
+ Hits,
+ Hit,
+ FindAnswersOptions,
+ FindAnswersResponse,
+} from '../../types';
+
+type IndexWithAnswers = {
+ readonly findAnswers: any;
+};
+
+function hasFindAnswersMethod(
+ answersIndex: IndexWithAnswers | any
+): answersIndex is IndexWithAnswers {
+ return typeof (answersIndex as IndexWithAnswers).findAnswers === 'function';
+}
+
+const withUsage = createDocumentationMessageGenerator({
+ name: 'answers',
+ connector: true,
+});
+
+export type AnswersRendererOptions = {
+ /**
+ * The matched hits from Algolia API.
+ */
+ hits: Hits;
+
+ /**
+ * Whether it's still loading the results from the Answers API.
+ */
+ isLoading: boolean;
+};
+
+export type AnswersConnectorParams = {
+ /**
+ * Attributes to use for predictions.
+ * If empty, we use all `searchableAttributes` to find answers.
+ * All your `attributesForPrediction` must be part of your `searchableAttributes`.
+ */
+ attributesForPrediction?: string[];
+
+ /**
+ * The languages in the query. Currently only supports `en`.
+ */
+ queryLanguages: ['en'];
+
+ /**
+ * Maximum number of answers to retrieve from the Answers Engine.
+ * Cannot be greater than 1000.
+ * @default 1
+ */
+ nbHits?: number;
+
+ /**
+ * Debounce time in milliseconds to debounce render
+ * @default 100
+ */
+ renderDebounceTime?: number;
+
+ /**
+ * Debounce time in milliseconds to debounce search
+ * @default 100
+ */
+ searchDebounceTime?: number;
+
+ /**
+ * Whether to escape HTML tags from hits string values.
+ *
+ * @default true
+ */
+ escapeHTML?: boolean;
+
+ /**
+ * Extra parameters to pass to findAnswers method.
+ * @default {}
+ */
+ extraParameters?: FindAnswersOptions;
+};
+
+export type AnswersConnector = Connector<
+ AnswersRendererOptions,
+ AnswersConnectorParams
+>;
+
+const connectAnswers: AnswersConnector = function connectAnswers(
+ renderFn,
+ unmountFn = noop
+) {
+ checkRendering(renderFn, withUsage());
+
+ return widgetParams => {
+ const {
+ queryLanguages,
+ attributesForPrediction,
+ nbHits = 1,
+ renderDebounceTime = 100,
+ searchDebounceTime = 100,
+ escapeHTML = true,
+ extraParameters = {},
+ } = widgetParams || ({} as typeof widgetParams);
+
+ // @ts-ignore checking for the wrong value
+ if (!queryLanguages || queryLanguages.length === 0) {
+ throw new Error(
+ withUsage('The `queryLanguages` expects an array of strings.')
+ );
+ }
+
+ const runConcurrentSafePromise = createConcurrentSafePromise<
+ FindAnswersResponse
+ >();
+
+ let lastResult: Partial>;
+ let isLoading = false;
+ const debouncedRender = debounce(renderFn, renderDebounceTime);
+ let debouncedRefine;
+
+ return {
+ $$type: 'ais.answers',
+
+ init(initOptions) {
+ const { state, instantSearchInstance } = initOptions;
+ const answersIndex = instantSearchInstance.client!.initIndex!(
+ state.index
+ );
+ if (!hasFindAnswersMethod(answersIndex)) {
+ throw new Error(withUsage('`algoliasearch` >= 4.8.0 required.'));
+ }
+ debouncedRefine = debounce(
+ answersIndex.findAnswers,
+ searchDebounceTime
+ );
+
+ renderFn(
+ {
+ ...this.getWidgetRenderState(initOptions),
+ instantSearchInstance: initOptions.instantSearchInstance,
+ },
+ true
+ );
+ },
+
+ render(renderOptions) {
+ const query = renderOptions.state.query;
+ if (!query) {
+ // renders nothing with empty query
+ lastResult = {};
+ isLoading = false;
+ renderFn(
+ {
+ ...this.getWidgetRenderState(renderOptions),
+ instantSearchInstance: renderOptions.instantSearchInstance,
+ },
+ false
+ );
+ return;
+ }
+
+ // render the loader
+ lastResult = {};
+ isLoading = true;
+ renderFn(
+ {
+ ...this.getWidgetRenderState(renderOptions),
+ instantSearchInstance: renderOptions.instantSearchInstance,
+ },
+ false
+ );
+
+ // call /answers API
+ runConcurrentSafePromise(
+ debouncedRefine(query, queryLanguages, {
+ ...extraParameters,
+ nbHits,
+ attributesForPrediction,
+ })
+ ).then(results => {
+ if (!results) {
+ // It's undefined when it's debounced.
+ return;
+ }
+
+ if (escapeHTML && results.hits.length > 0) {
+ results.hits = escapeHits(results.hits);
+ }
+ const initialEscaped = (results.hits as ReturnType)
+ .__escaped;
+
+ results.hits = addAbsolutePosition(
+ results.hits,
+ 0,
+ nbHits
+ );
+
+ results.hits = addQueryID(
+ results.hits,
+ results.queryID
+ );
+
+ // Make sure the escaped tag stays, even after mapping over the hits.
+ // This prevents the hits from being double-escaped if there are multiple
+ // hits widgets mounted on the page.
+ (results.hits as ReturnType<
+ typeof escapeHits
+ >).__escaped = initialEscaped;
+
+ lastResult = results;
+ isLoading = false;
+ debouncedRender(
+ {
+ ...this.getWidgetRenderState(renderOptions),
+ instantSearchInstance: renderOptions.instantSearchInstance,
+ },
+ false
+ );
+ });
+ },
+
+ getRenderState(renderState, renderOptions) {
+ return {
+ ...renderState,
+ answers: this.getWidgetRenderState(renderOptions),
+ };
+ },
+
+ getWidgetRenderState() {
+ return {
+ hits: lastResult?.hits || [],
+ isLoading,
+ widgetParams,
+ };
+ },
+
+ dispose({ state }) {
+ unmountFn();
+ return state;
+ },
+
+ getWidgetSearchParameters(state) {
+ return state;
+ },
+ };
+ };
+};
+
+export default connectAnswers;
diff --git a/src/connectors/index.ts b/src/connectors/index.ts
index a79ebf3ac0..b2cd59ea12 100644
--- a/src/connectors/index.ts
+++ b/src/connectors/index.ts
@@ -24,4 +24,5 @@ export { default as EXPERIMENTAL_connectConfigureRelatedItems } from './configur
export { default as connectAutocomplete } from './autocomplete/connectAutocomplete';
export { default as connectQueryRules } from './query-rules/connectQueryRules';
export { default as connectVoiceSearch } from './voice-search/connectVoiceSearch';
+export { default as EXPERIMENTAL_connectAnswers } from './answers/connectAnswers';
export { default as connectSmartSort } from './smart-sort/connectSmartSort';
diff --git a/src/lib/routers/__tests__/history.test.ts b/src/lib/routers/__tests__/history.test.ts
index 66dcda8d34..43b2156061 100644
--- a/src/lib/routers/__tests__/history.test.ts
+++ b/src/lib/routers/__tests__/history.test.ts
@@ -1,6 +1,5 @@
import historyRouter from '../history';
-
-const wait = (ms = 0) => new Promise(res => setTimeout(res, ms));
+import { wait } from '../../../../test/utils/wait';
describe('life cycle', () => {
beforeEach(() => {
diff --git a/src/lib/utils/__tests__/debounce-test.ts b/src/lib/utils/__tests__/debounce-test.ts
new file mode 100644
index 0000000000..cc81127a56
--- /dev/null
+++ b/src/lib/utils/__tests__/debounce-test.ts
@@ -0,0 +1,64 @@
+import { debounce } from '../debounce';
+
+describe('debounce', () => {
+ it('debounces the function', done => {
+ const originalFunction = jest.fn();
+ const debouncedFunction = debounce(originalFunction, 100);
+ debouncedFunction('a');
+ debouncedFunction('b');
+
+ setTimeout(() => {
+ expect(originalFunction).toHaveBeenCalledTimes(1);
+ expect(originalFunction).toHaveBeenLastCalledWith('b');
+ done();
+ }, 100);
+ });
+
+ it('executes all the calls if they are not within the debounce time', done => {
+ const originalFunction = jest.fn();
+ const debouncedFunction = debounce(originalFunction, 100);
+
+ debouncedFunction('a');
+
+ setTimeout(() => {
+ debouncedFunction('b');
+ }, 100);
+
+ setTimeout(() => {
+ expect(originalFunction).toHaveBeenCalledTimes(2);
+ expect(originalFunction).toHaveBeenLastCalledWith('b');
+ done();
+ }, 250);
+ });
+
+ it('returns a promise', async () => {
+ const originalFunction = jest.fn(x => Promise.resolve(x));
+ const debouncedFunction = debounce(originalFunction, 100);
+
+ debouncedFunction('a');
+
+ const promise = debouncedFunction('b');
+ await expect(promise).resolves.toEqual('b');
+
+ expect(originalFunction).toHaveBeenCalledTimes(1);
+ expect(originalFunction).toHaveBeenLastCalledWith('b');
+ });
+
+ it('returns a promise with a resolved data', async () => {
+ type OriginalFunction = () => Promise<'abc'>;
+ const originalFunction: OriginalFunction = () => Promise.resolve('abc');
+
+ const debouncedFunction = debounce(originalFunction, 100);
+ const promise = debouncedFunction();
+ const ret = await promise;
+ expect(ret).toEqual('abc');
+ });
+
+ it('accepts synchronous function as well', async () => {
+ const originalFunction = jest.fn(x => x);
+ const debouncedFunction = debounce(originalFunction, 100);
+ const promise = debouncedFunction('a');
+
+ await expect(promise).resolves.toEqual('a');
+ });
+});
diff --git a/src/lib/utils/createConcurrentSafePromise.ts b/src/lib/utils/createConcurrentSafePromise.ts
new file mode 100644
index 0000000000..bd47ed0e3b
--- /dev/null
+++ b/src/lib/utils/createConcurrentSafePromise.ts
@@ -0,0 +1,46 @@
+export type MaybePromise =
+ | Readonly>
+ | Promise
+ | TResolution;
+
+// copied from
+// https://github.com/algolia/autocomplete.js/blob/307a7acc4283e10a19cb7d067f04f1bea79dc56f/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts#L1:L1
+/**
+ * Creates a runner that executes promises in a concurrent-safe way.
+ *
+ * This is useful to prevent older promises to resolve after a newer promise,
+ * otherwise resulting in stale resolved values.
+ */
+export function createConcurrentSafePromise() {
+ let basePromiseId = -1;
+ let latestResolvedId = -1;
+ let latestResolvedValue: TValue | undefined = undefined;
+
+ return function runConcurrentSafePromise(promise: MaybePromise) {
+ const currentPromiseId = ++basePromiseId;
+
+ return Promise.resolve(promise).then(x => {
+ // The promise might take too long to resolve and get outdated. This would
+ // result in resolving stale values.
+ // When this happens, we ignore the promise value and return the one
+ // coming from the latest resolved value.
+ //
+ // +----------------------------------+
+ // | 100ms |
+ // | run(1) +---> R1 |
+ // | 300ms |
+ // | run(2) +-------------> R2 (SKIP) |
+ // | 200ms |
+ // | run(3) +--------> R3 |
+ // +----------------------------------+
+ if (latestResolvedValue && currentPromiseId < latestResolvedId) {
+ return latestResolvedValue;
+ }
+
+ latestResolvedId = currentPromiseId;
+ latestResolvedValue = x;
+
+ return x;
+ });
+ };
+}
diff --git a/src/lib/utils/debounce.ts b/src/lib/utils/debounce.ts
new file mode 100644
index 0000000000..ff3ec44891
--- /dev/null
+++ b/src/lib/utils/debounce.ts
@@ -0,0 +1,29 @@
+type Func = (...args: any[]) => any;
+
+type DebouncedFunction = (
+ this: ThisParameterType,
+ ...args: Parameters
+) => Promise>;
+
+// Debounce a function call to the trailing edge.
+// The debounced function returns a promise.
+export function debounce(
+ func: TFunction,
+ wait: number
+): DebouncedFunction {
+ let lastTimeout: ReturnType | null = null;
+ return function(...args) {
+ // @ts-ignore-next-line
+ return new Promise((resolve, reject) => {
+ if (lastTimeout) {
+ clearTimeout(lastTimeout);
+ }
+ lastTimeout = setTimeout(() => {
+ lastTimeout = null;
+ Promise.resolve(func(...args))
+ .then(resolve)
+ .catch(reject);
+ }, wait);
+ });
+ };
+}
diff --git a/src/lib/utils/hits-absolute-position.ts b/src/lib/utils/hits-absolute-position.ts
index 10d456498a..672acb66bc 100644
--- a/src/lib/utils/hits-absolute-position.ts
+++ b/src/lib/utils/hits-absolute-position.ts
@@ -1,10 +1,10 @@
-import { Hits } from '../../types';
+import { Hit } from '../../types';
-export const addAbsolutePosition = (
- hits: Hits,
+export const addAbsolutePosition = (
+ hits: THit[],
page: number,
hitsPerPage: number
-): Hits => {
+): THit[] => {
return hits.map((hit, idx) => ({
...hit,
__position: hitsPerPage * page + idx + 1,
diff --git a/src/lib/utils/hits-query-id.ts b/src/lib/utils/hits-query-id.ts
index e470e509df..782458b2fe 100644
--- a/src/lib/utils/hits-query-id.ts
+++ b/src/lib/utils/hits-query-id.ts
@@ -1,6 +1,6 @@
-import { Hits } from '../../types';
+import { Hit } from '../../types';
-export const addQueryID = (hits: Hits, queryID?: string): Hits => {
+export function addQueryID(hits: THit[], queryID?: string): THit[] {
if (!queryID) {
return hits;
}
@@ -8,4 +8,4 @@ export const addQueryID = (hits: Hits, queryID?: string): Hits => {
...hit,
__queryID: queryID,
}));
-};
+}
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts
index 352e24081c..ef08951df9 100644
--- a/src/lib/utils/index.ts
+++ b/src/lib/utils/index.ts
@@ -52,3 +52,5 @@ export * from './createSendEventForFacet';
export * from './createSendEventForHits';
export { getAppIdAndApiKey } from './getAppIdAndApiKey';
export { convertNumericRefinementsToFilters } from './convertNumericRefinementsToFilters';
+export { createConcurrentSafePromise } from './createConcurrentSafePromise';
+export { debounce } from './debounce';
diff --git a/src/middlewares/__tests__/createMetadataMiddleware.ts b/src/middlewares/__tests__/createMetadataMiddleware.ts
index 55e365a936..2376a67453 100644
--- a/src/middlewares/__tests__/createMetadataMiddleware.ts
+++ b/src/middlewares/__tests__/createMetadataMiddleware.ts
@@ -1,5 +1,6 @@
import { createMetadataMiddleware } from '..';
import { createSearchClient } from '../../../test/mock/createSearchClient';
+import { wait } from '../../../test/utils/wait';
import instantsearch from '../../lib/main';
import { configure, hits, index, pagination, searchBox } from '../../widgets';
import { isMetadataEnabled } from '../createMetadataMiddleware';
@@ -31,8 +32,6 @@ Object.defineProperty(
}))(window.navigator.userAgent)
);
-const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
-
const defaultUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Safari/605.1.15';
const algoliaUserAgent = 'Algolia Crawler 5.3.2';
diff --git a/src/types/algoliasearch.ts b/src/types/algoliasearch.ts
index 5b7a9b2b28..54f50915fe 100644
--- a/src/types/algoliasearch.ts
+++ b/src/types/algoliasearch.ts
@@ -16,7 +16,9 @@ import algoliasearch, {
SearchForFacetValues as SearchForFacetValuesV3,
} from 'algoliasearch';
import {
+ FindAnswersResponse as FindAnswersResponseV4,
SearchResponse as SearchResponseV4,
+ FindAnswersOptions as FindAnswersOptionsV4,
// no comma, TS is particular about which nodes expose comments
// eslint-disable-next-line prettier/prettier
SearchForFacetValuesResponse as SearchForFacetValuesResponseV4
@@ -25,6 +27,16 @@ import {
// eslint-disable-next-line import/no-unresolved
} from '@algolia/client-search';
+export type FindAnswersOptions = DefaultSearchClient extends DummySearchClientV4
+ ? FindAnswersOptionsV4
+ : any;
+
+export type FindAnswersResponse<
+ TObject
+> = DefaultSearchClient extends DummySearchClientV4
+ ? FindAnswersResponseV4
+ : any;
+
type DummySearchClientV4 = {
readonly transporter: any;
};
@@ -39,6 +51,7 @@ export type SearchClient = {
search: DefaultSearchClient['search'];
searchForFacetValues: DefaultSearchClient['searchForFacetValues'];
addAlgoliaAgent?: DefaultSearchClient['addAlgoliaAgent'];
+ initIndex?: DefaultSearchClient['initIndex'];
};
export type MultiResponse = {
diff --git a/src/types/widget.ts b/src/types/widget.ts
index ee79311526..a0e16f4d68 100644
--- a/src/types/widget.ts
+++ b/src/types/widget.ts
@@ -62,6 +62,10 @@ import {
PaginationRendererOptions,
PaginationConnectorParams,
} from '../connectors/pagination/connectPagination';
+import {
+ AnswersRendererOptions,
+ AnswersConnectorParams,
+} from '../connectors/answers/connectAnswers';
import {
RangeConnectorParams,
RangeRendererOptions,
@@ -364,6 +368,7 @@ export type IndexRenderState = Partial<{
}
>;
};
+ answers: WidgetRenderState;
smartSort: WidgetRenderState<
SmartSortRendererOptions,
SmartSortConnectorParams
@@ -389,6 +394,7 @@ export type Widget<
*/
$$type?:
| 'ais.analytics'
+ | 'ais.answers'
| 'ais.autocomplete'
| 'ais.breadcrumb'
| 'ais.clearRefinements'
@@ -425,6 +431,7 @@ export type Widget<
*/
$$widgetType?:
| 'ais.analytics'
+ | 'ais.answers'
| 'ais.autocomplete'
| 'ais.breadcrumb'
| 'ais.clearRefinements'
@@ -455,7 +462,6 @@ export type Widget<
| 'ais.stats'
| 'ais.toggleRefinement'
| 'ais.voiceSearch';
-
/**
* Called once before the first search
*/
diff --git a/src/widgets/__tests__/index.test.ts b/src/widgets/__tests__/index.test.ts
index d434b757df..60efe9c527 100644
--- a/src/widgets/__tests__/index.test.ts
+++ b/src/widgets/__tests__/index.test.ts
@@ -2,6 +2,14 @@ import { PlacesInstance } from 'places.js';
import * as widgets from '..';
import { Widget } from '../../types';
+/**
+ * Checklist when adding a new widget
+ *
+ * 1. Include $$type in the returned object from connector
+ * 2. Include $$widgetType in widget
+ * 3. Update $$type and $$widgetType in src/types/widget.ts
+ */
+
// This is written in the test, since Object.entries is not allowed in the
// source code. Once we use Object.entries without polyfill, we can move this
// helper to the `typedObject` file.
@@ -111,6 +119,10 @@ function initiateAllWidgets(): Array<[WidgetNames, Widget]> {
attributes: ['attr1', 'attr2'],
});
}
+ case 'EXPERIMENTAL_answers': {
+ const EXPERIMENTAL_answers = widget as Widgets['EXPERIMENTAL_answers'];
+ return EXPERIMENTAL_answers({ container, queryLanguages: ['en'] });
+ }
default: {
return widget({ container, attribute: 'attr' });
}
diff --git a/src/widgets/answers/__tests__/answers-test.ts b/src/widgets/answers/__tests__/answers-test.ts
new file mode 100644
index 0000000000..4c73684064
--- /dev/null
+++ b/src/widgets/answers/__tests__/answers-test.ts
@@ -0,0 +1,161 @@
+/** @jsx h */
+
+import algoliasearchHelper from 'algoliasearch-helper';
+import { fireEvent } from '@testing-library/preact';
+import instantsearch from '../../../index.es';
+import { createSearchClient } from '../../../../test/mock/createSearchClient';
+import { runAllMicroTasks } from '../../../../test/utils/runAllMicroTasks';
+import answers from '../answers';
+import searchBox from '../../search-box/search-box';
+
+describe('answers', () => {
+ describe('Usage', () => {
+ it('throws without `container`', () => {
+ expect(() => {
+ // @ts-ignore
+ answers({});
+ }).toThrowErrorMatchingInlineSnapshot(`
+"The \`container\` option is required.
+
+See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/"
+`);
+ });
+
+ it('throws without `queryLanguages`', () => {
+ const container = document.createElement('div');
+ expect(() => {
+ // @ts-ignore
+ answers({ container });
+ }).toThrowErrorMatchingInlineSnapshot(`
+"The \`queryLanguages\` expects an array of strings.
+
+See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/#connector"
+`);
+ });
+
+ it('throws when searchClient does not support findAnswers', () => {
+ const container = document.createElement('div');
+ const searchClient = createSearchClient({
+ // @ts-ignore-next-line
+ initIndex() {
+ return {};
+ },
+ });
+ const helper = algoliasearchHelper(searchClient, '', {});
+ const search = instantsearch({
+ indexName: 'instant_search',
+ searchClient,
+ });
+ const widget = answers({
+ container,
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ });
+ expect(() => {
+ // @ts-ignore-next-line
+ widget.init({
+ state: helper.state,
+ helper,
+ instantSearchInstance: search,
+ });
+ }).toThrowErrorMatchingInlineSnapshot(`
+"\`algoliasearch\` >= 4.8.0 required.
+
+See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/#connector"
+`);
+ });
+ });
+
+ describe('render', () => {
+ it('renders with empty state', async () => {
+ const container = document.createElement('div');
+ const search = instantsearch({
+ indexName: 'instant_search',
+ searchClient: createSearchClient({
+ // @ts-ignore-next-line
+ initIndex() {
+ return {
+ findAnswers: () => Promise.resolve({ hits: [] }),
+ };
+ },
+ }),
+ });
+ search.addWidgets([
+ answers({
+ container,
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ }),
+ ]);
+ search.start();
+ await runAllMicroTasks();
+ expect(container.innerHTML).toMatchInlineSnapshot(
+ `""`
+ );
+ });
+
+ it('renders the answers', async done => {
+ const answersContainer = document.createElement('div');
+ const searchBoxContainer = document.createElement('div');
+ const search = instantsearch({
+ indexName: 'instant_search',
+ searchClient: createSearchClient({
+ // @ts-ignore-next-line
+ initIndex() {
+ return {
+ findAnswers: () => {
+ return Promise.resolve({ hits: [{ title: 'Hello' }] });
+ },
+ };
+ },
+ }),
+ });
+ search.addWidgets([
+ answers({
+ container: answersContainer,
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ renderDebounceTime: 10,
+ searchDebounceTime: 10,
+ cssClasses: {
+ root: 'root',
+ loader: 'loader',
+ emptyRoot: 'empty',
+ item: 'item',
+ },
+ templates: {
+ loader: 'loading...',
+ item: hit => `title: ${hit.title}`,
+ },
+ }),
+ searchBox({
+ container: searchBoxContainer,
+ }),
+ ]);
+ search.start();
+ await runAllMicroTasks();
+
+ fireEvent.input(searchBoxContainer.querySelector('input')!, {
+ target: { value: 'a' },
+ });
+
+ await runAllMicroTasks();
+ expect(answersContainer.querySelector('.loader')!.innerHTML).toEqual(
+ 'loading...'
+ );
+ expect(answersContainer.querySelector('.root')).toHaveClass('empty');
+
+ setTimeout(() => {
+ // debounced render
+ expect(answersContainer.querySelector('.root')).not.toHaveClass(
+ 'empty'
+ );
+ expect(answersContainer.querySelectorAll('.item').length).toEqual(1);
+ expect(answersContainer.querySelector('.item')!.innerHTML).toEqual(
+ 'title: Hello'
+ );
+ done();
+ }, 30);
+ });
+ });
+});
diff --git a/src/widgets/answers/answers.tsx b/src/widgets/answers/answers.tsx
new file mode 100644
index 0000000000..7b74ee0715
--- /dev/null
+++ b/src/widgets/answers/answers.tsx
@@ -0,0 +1,179 @@
+/** @jsx h */
+
+import { h, render } from 'preact';
+import cx from 'classnames';
+import { WidgetFactory, Template, Hit, Renderer } from '../../types';
+import defaultTemplates from './defaultTemplates';
+import {
+ createDocumentationMessageGenerator,
+ getContainerNode,
+ prepareTemplateProps,
+} from '../../lib/utils';
+import { component } from '../../lib/suit';
+import Answers from '../../components/Answers/Answers';
+import connectAnswers, {
+ AnswersRendererOptions,
+ AnswersConnectorParams,
+} from '../../connectors/answers/connectAnswers';
+
+const withUsage = createDocumentationMessageGenerator({ name: 'answers' });
+const suit = component('Answers');
+
+const renderer = ({
+ renderState,
+ cssClasses,
+ containerNode,
+ templates,
+}): Renderer> => (
+ { hits, isLoading, instantSearchInstance },
+ isFirstRendering
+) => {
+ if (isFirstRendering) {
+ renderState.templateProps = prepareTemplateProps({
+ defaultTemplates,
+ templatesConfig: instantSearchInstance.templatesConfig,
+ templates,
+ });
+ return;
+ }
+
+ render(
+ ,
+ containerNode
+ );
+};
+
+export type AnswersTemplates = {
+ /**
+ * Template to use for the header. This template will receive an object containing `hits` and `isLoading`.
+ */
+ header: Template<{
+ hits: Hit[];
+ isLoading: boolean;
+ }>;
+
+ /**
+ * Template to use for the loader.
+ */
+ loader: Template;
+
+ /**
+ * Template to use for each result. This template will receive an object containing a single record.
+ */
+ item: Template;
+};
+
+export type AnswersCSSClasses = {
+ /**
+ * CSS class to add to the root element of the widget.
+ */
+ root: string | string[];
+
+ /**
+ * CSS class to add to the wrapping element when no results.
+ */
+ emptyRoot: string | string[];
+
+ /**
+ * CSS classes to add to the header.
+ */
+ header: string | string[];
+
+ /**
+ * CSS classes to add to the loader.
+ */
+ loader: string | string[];
+
+ /**
+ * CSS class to add to the list of results.
+ */
+ list: string | string[];
+
+ /**
+ * CSS class to add to each result.
+ */
+ item: string | string[];
+};
+
+export type AnswersWidgetParams = {
+ /**
+ * CSS Selector or HTMLElement to insert the widget.
+ */
+ container: string | HTMLElement;
+
+ /**
+ * The templates to use for the widget.
+ */
+ templates?: Partial;
+
+ /**
+ * The CSS classes to override.
+ */
+ cssClasses?: Partial;
+};
+
+export type AnswersWidget = WidgetFactory<
+ AnswersRendererOptions,
+ AnswersConnectorParams,
+ AnswersWidgetParams
+>;
+
+const answersWidget: AnswersWidget = widgetParams => {
+ const {
+ container,
+ attributesForPrediction,
+ queryLanguages,
+ nbHits,
+ searchDebounceTime,
+ renderDebounceTime,
+ escapeHTML,
+ extraParameters,
+ templates = defaultTemplates,
+ cssClasses: userCssClasses = {},
+ } = widgetParams || ({} as typeof widgetParams);
+
+ if (!container) {
+ throw new Error(withUsage('The `container` option is required.'));
+ }
+
+ const containerNode = getContainerNode(container);
+ const cssClasses = {
+ root: cx(suit(), userCssClasses.root),
+ emptyRoot: cx(suit({ modifierName: 'empty' }), userCssClasses.emptyRoot),
+ header: cx(suit({ descendantName: 'header' }), userCssClasses.header),
+ loader: cx(suit({ descendantName: 'loader' }), userCssClasses.loader),
+ list: cx(suit({ descendantName: 'list' }), userCssClasses.list),
+ item: cx(suit({ descendantName: 'item' }), userCssClasses.item),
+ };
+
+ const specializedRenderer = renderer({
+ containerNode,
+ cssClasses,
+ templates,
+ renderState: {},
+ });
+
+ const makeWidget = connectAnswers(specializedRenderer, () =>
+ render(null, containerNode)
+ );
+
+ return {
+ ...makeWidget({
+ attributesForPrediction,
+ queryLanguages,
+ nbHits,
+ searchDebounceTime,
+ renderDebounceTime,
+ escapeHTML,
+ extraParameters,
+ }),
+ $$widgetType: 'ais.answers',
+ };
+};
+
+export default answersWidget;
diff --git a/src/widgets/answers/defaultTemplates.ts b/src/widgets/answers/defaultTemplates.ts
new file mode 100644
index 0000000000..49c352e693
--- /dev/null
+++ b/src/widgets/answers/defaultTemplates.ts
@@ -0,0 +1,5 @@
+export default {
+ header: '',
+ loader: '',
+ item: item => JSON.stringify(item),
+};
diff --git a/src/widgets/index.ts b/src/widgets/index.ts
index f0794b9d14..19e4cb0e20 100644
--- a/src/widgets/index.ts
+++ b/src/widgets/index.ts
@@ -28,4 +28,5 @@ export { default as queryRuleCustomData } from './query-rule-custom-data/query-r
export { default as queryRuleContext } from './query-rule-context/query-rule-context';
export { default as index } from './index/index';
export { default as places } from './places/places';
+export { default as EXPERIMENTAL_answers } from './answers/answers';
export { default as smartSort } from './smart-sort/smart-sort';
diff --git a/stories/answers.stories.ts b/stories/answers.stories.ts
new file mode 100644
index 0000000000..107ce464b1
--- /dev/null
+++ b/stories/answers.stories.ts
@@ -0,0 +1,129 @@
+import { storiesOf } from '@storybook/html';
+import { withHits } from '../.storybook/decorators';
+import { EXPERIMENTAL_answers as answers } from '../src/widgets';
+import '../.storybook/static/answers.css';
+
+const searchOptions = {
+ appId: 'CKOEQ4XGMU',
+ apiKey: '6560d3886292a5aec86d63b9a2cba447',
+ indexName: 'ted',
+};
+
+storiesOf('Results/Answers', module)
+ .add(
+ 'default',
+ withHits(({ search, container }) => {
+ const p = document.createElement('p');
+ p.innerText = `Try to search for "sarah jones"`;
+ const answersContainer = document.createElement('div');
+ container.appendChild(p);
+ container.appendChild(answersContainer);
+
+ search.addWidgets([
+ answers({
+ container: answersContainer,
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ templates: {
+ item: hit => {
+ return `${hit._answer.extract}
`;
+ },
+ },
+ }),
+ ]);
+ }, searchOptions)
+ )
+ .add(
+ 'with header',
+ withHits(({ search, container }) => {
+ const p = document.createElement('p');
+ p.innerText = `Try to search for "sarah jones"`;
+ const answersContainer = document.createElement('div');
+ container.appendChild(p);
+ container.appendChild(answersContainer);
+
+ search.addWidgets([
+ answers({
+ container: answersContainer,
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ templates: {
+ header: ({ hits }) => {
+ return hits.length === 0 ? '' : `Answers
`;
+ },
+ item: hit => {
+ return `${hit._answer.extract}
`;
+ },
+ },
+ }),
+ ]);
+ }, searchOptions)
+ )
+ .add(
+ 'with loader',
+ withHits(({ search, container }) => {
+ const p = document.createElement('p');
+ p.innerText = `Try to search for "sarah jones"`;
+ const answersContainer = document.createElement('div');
+ container.appendChild(p);
+ container.appendChild(answersContainer);
+
+ search.addWidgets([
+ answers({
+ container: answersContainer,
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ templates: {
+ header: ({ hits }) => {
+ return hits.length === 0 ? '' : `Answers
`;
+ },
+ loader: `loading...`,
+ item: hit => {
+ return `${hit._answer.extract}
`;
+ },
+ },
+ }),
+ ]);
+ }, searchOptions)
+ )
+ .add(
+ 'full example',
+ withHits(({ search, container }) => {
+ const p = document.createElement('p');
+ p.innerText = `Try to search for "sarah jones"`;
+ const answersContainer = document.createElement('div');
+ container.appendChild(p);
+ container.appendChild(answersContainer);
+
+ search.addWidgets([
+ answers({
+ container: answersContainer,
+ queryLanguages: ['en'],
+ attributesForPrediction: ['description'],
+ cssClasses: {
+ root: 'my-Answers',
+ },
+ templates: {
+ loader: `
+
+ `,
+ item: hit => {
+ return `
+ ${hit.title}
+
+ ${hit._answer.extract}
+ `;
+ },
+ },
+ }),
+ ]);
+ }, searchOptions)
+ );
diff --git a/test/utils/wait.ts b/test/utils/wait.ts
new file mode 100644
index 0000000000..c6ea03d56a
--- /dev/null
+++ b/test/utils/wait.ts
@@ -0,0 +1,2 @@
+export const wait = (ms: number) =>
+ new Promise(resolve => setTimeout(resolve, ms));