Skip to content

Commit e4c9070

Browse files
Eunjae LeeHaroenvshortcutsinstantsearch-botfrancoischalifour
authoredFeb 26, 2021
feat(answers): add EXPERIMENTAL_answers widget (#4581)
* feat(answers): add widget * add warning when findAnswers is not supported * hits fall back to an empty array if not exists * split into widget + connector + component set * fix concurrency issue + types * debounce render * update stories * fix types * add tests * update storybook * add comments * pass query parameters to findAnswers * disable lint error * update bundlesize * Revert "pass query parameters to findAnswers" This reverts commit 98529c2. * rename answers widget to EXPERIMENTAL_answers * remove validation of attributesForPrediction because it can be optional * remove unnecessary validation * add __position to hits * make queryLanguages required * add "EXPERIMENTAL_" * Update src/types/algoliasearch.ts Co-authored-by: Haroen Viaene <hello@haroen.me> * clean up import & export * less typing for temporary part * Update src/connectors/answers/connectAnswers.ts Co-authored-by: Haroen Viaene <hello@haroen.me> * add debounceTime * override x-algolia-agent via requestOptions * update FindAnswersResponse type * add test case for queryLanguages missing * add generic to FindAnswersResponse * escape answers hits and add queryID * clear hits when trigger a new search * Update src/connectors/answers/connectAnswers.ts Co-authored-by: Clément Vannicatte <20689156+shortcuts@users.noreply.github.com> * Update src/connectors/answers/__tests__/connectAnswers-test.ts Co-authored-by: Clément Vannicatte <20689156+shortcuts@users.noreply.github.com> * do not render list when loading * fix test cases to include objectID and __escaped * debounce search call * fix $$type and $$widgetType * add comment * change default render debounce time to 100ms * catch promise rejects inside the connector * Update src/components/Answers/Answers.tsx Co-authored-by: Clément Vannicatte <20689156+shortcuts@users.noreply.github.com> * clean up `wait` function * fix wrong closing tag * add types to debounce functions * fix eslint rule for EXPERIMENTAL_ * support extraParameters * remove comma * update types alphabetically * remove unused css * clean up debounce functions * update bundlesize * chore: release v4.13.0 (#4635) * chore: release v4.13.0 * remove unnecessary catch * remove this from debounce * Apply suggestions from code review Co-authored-by: François Chalifour <francoischalifour@users.noreply.github.com> * fix types for js client v3 * add ts-ignore * remove the user agent workaround * update the types to use Partial * Update src/lib/utils/createConcurrentSafePromise.ts Co-authored-by: Clément Vannicatte <20689156+shortcuts@users.noreply.github.com> * update tests * fix types * update bundlesize * Update src/types/algoliasearch.ts Co-authored-by: Clément Vannicatte <20689156+shortcuts@users.noreply.github.com> * Update src/connectors/answers/connectAnswers.ts Co-authored-by: Haroen Viaene <hello@haroen.me> * fix lint error * update bundlesize * update bundlesize Co-authored-by: Haroen Viaene <hello@haroen.me> Co-authored-by: Clément Vannicatte <20689156+shortcuts@users.noreply.github.com> Co-authored-by: InstantSearch <66688561+instantsearch-bot@users.noreply.github.com> Co-authored-by: François Chalifour <francoischalifour@users.noreply.github.com>
1 parent e7aaa8c commit e4c9070

25 files changed

+1715
-18
lines changed
 

‎.eslintrc.js

+1-5
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ module.exports = {
77
'new-cap': [
88
'error',
99
{
10-
capIsNewExceptions: [
11-
'EXPERIMENTAL_use',
12-
'EXPERIMENTAL_connectConfigureRelatedItems',
13-
'EXPERIMENTAL_configureRelatedItems',
14-
],
10+
capIsNewExceptionPattern: '(\\.|^)EXPERIMENTAL_.+',
1511
},
1612
],
1713
'react/no-string-refs': 'error',

‎.storybook/static/answers.css

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
.my-Answers .ais-Answers-loader {
2+
display: none;
3+
}
4+
5+
.my-Answers .ais-Answers-list {
6+
list-style: none;
7+
margin: 0;
8+
padding: 0;
9+
}
10+
11+
.my-Answers .ais-Answers-item {
12+
height: 10rem;
13+
border: 1px solid #ddd;
14+
border-radius: 0.5rem;
15+
}
16+
17+
.my-Answers .title {
18+
padding: 0;
19+
margin: 1rem;
20+
font-size: 1.2rem;
21+
color: #333;
22+
line-height: 1.4rem;
23+
}
24+
25+
.my-Answers .separator {
26+
border-top: 1px solid #ddd;
27+
}
28+
29+
.my-Answers .description {
30+
margin: 1rem;
31+
padding: 0;
32+
color: #333;
33+
line-height: 1.4rem;
34+
}
35+
36+
.my-Answers .description em {
37+
background-color: #ffc168;
38+
}
39+
40+
.one-line {
41+
display: -webkit-box;
42+
-webkit-line-clamp: 1;
43+
-webkit-box-orient: vertical;
44+
overflow: hidden;
45+
}
46+
47+
.three-lines {
48+
display: -webkit-box;
49+
-webkit-line-clamp: 3;
50+
-webkit-box-orient: vertical;
51+
overflow: hidden;
52+
}
53+
54+
/* skeleton loader from https://codepen.io/jordanmsykes/pen/RgPqgV - begin */
55+
@keyframes placeHolderShimmer {
56+
0% {
57+
-webkit-transform: translateZ(0);
58+
transform: translateZ(0);
59+
background-position: -468px 0;
60+
}
61+
to {
62+
-webkit-transform: translateZ(0);
63+
transform: translateZ(0);
64+
background-position: 468px 0;
65+
}
66+
}
67+
68+
.card-skeleton {
69+
margin-left: 1rem;
70+
margin-right: 1rem;
71+
width: calc(100% - 2rem);
72+
height: 10rem;
73+
transition: all 0.3s ease-in-out;
74+
-webkit-backface-visibility: hidden;
75+
background: #fff;
76+
z-index: 10;
77+
opacity: 1;
78+
}
79+
80+
.card-skeleton.hidden {
81+
transition: all 0.3s ease-in-out;
82+
opacity: 0;
83+
height: 0;
84+
padding: 0;
85+
}
86+
87+
.card-skeleton-img {
88+
width: 100%;
89+
height: 120px;
90+
background: #e6e6e6;
91+
display: block;
92+
}
93+
94+
.animated-background {
95+
will-change: transform;
96+
animation: placeHolderShimmer 1s linear infinite forwards;
97+
-webkit-backface-visibility: hidden;
98+
background: #e6e6e6;
99+
background: linear-gradient(90deg, #eee 8%, #ddd 18%, #eee 33%);
100+
background-size: 800px 104px;
101+
height: 100%;
102+
position: relative;
103+
}
104+
105+
.skel-mask-container {
106+
position: relative;
107+
}
108+
109+
.skel-mask {
110+
background: #fff;
111+
position: absolute;
112+
z-index: 200;
113+
}
114+
115+
.skel-mask-1 {
116+
width: 100%;
117+
height: 15px;
118+
top: 0;
119+
}
120+
121+
.skel-mask-2 {
122+
width: 100%;
123+
height: 25px;
124+
top: 45px;
125+
}
126+
127+
.skel-mask-3 {
128+
width: 100%;
129+
height: 15px;
130+
top: 145px;
131+
}
132+
/* skeleton loader - end */

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
"bundlesize": [
144144
{
145145
"path": "./dist/instantsearch.production.min.js",
146-
"maxSize": "67.00 kB"
146+
"maxSize": "68.00 kB"
147147
},
148148
{
149149
"path": "./dist/instantsearch.development.js",

‎src/components/Answers/Answers.tsx

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/** @jsx h */
2+
3+
import { h } from 'preact';
4+
import cx from 'classnames';
5+
import Template from '../Template/Template';
6+
import { AnswersTemplates } from '../../widgets/answers/answers';
7+
import { Hits } from '../../types';
8+
9+
type AnswersCSSClasses = {
10+
root: string;
11+
emptyRoot: string;
12+
header: string;
13+
loader: string;
14+
list: string;
15+
item: string;
16+
};
17+
18+
export type AnswersProps = {
19+
hits: Hits;
20+
isLoading: boolean;
21+
cssClasses: AnswersCSSClasses;
22+
templateProps: {
23+
[key: string]: any;
24+
templates: AnswersTemplates;
25+
};
26+
};
27+
28+
const Answers = ({
29+
hits,
30+
isLoading,
31+
cssClasses,
32+
templateProps,
33+
}: AnswersProps) => (
34+
<div
35+
className={cx(cssClasses.root, {
36+
[cssClasses.emptyRoot]: hits.length === 0,
37+
})}
38+
>
39+
<Template
40+
{...templateProps}
41+
templateKey="header"
42+
rootProps={{ className: cssClasses.header }}
43+
data={{
44+
hits,
45+
isLoading,
46+
}}
47+
/>
48+
{isLoading ? (
49+
<Template
50+
{...templateProps}
51+
templateKey="loader"
52+
rootProps={{ className: cssClasses.loader }}
53+
/>
54+
) : (
55+
<ul className={cssClasses.list}>
56+
{hits.map((hit, position) => (
57+
<Template
58+
{...templateProps}
59+
templateKey="item"
60+
rootTagName="li"
61+
rootProps={{ className: cssClasses.item }}
62+
key={hit.objectID}
63+
data={{
64+
...hit,
65+
__hitIndex: position,
66+
}}
67+
/>
68+
))}
69+
</ul>
70+
)}
71+
</div>
72+
);
73+
74+
export default Answers;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/** @jsx h */
2+
3+
import { h } from 'preact';
4+
import { render } from '@testing-library/preact';
5+
import Answers, { AnswersProps } from '../Answers';
6+
7+
const defaultProps: AnswersProps = {
8+
hits: [],
9+
isLoading: false,
10+
cssClasses: {
11+
root: 'root',
12+
header: 'header',
13+
emptyRoot: 'empty',
14+
loader: 'loader',
15+
list: 'list',
16+
item: 'item',
17+
},
18+
templateProps: {
19+
templates: {
20+
header: 'header',
21+
loader: 'loader',
22+
item: 'item',
23+
},
24+
},
25+
};
26+
27+
describe('Answers', () => {
28+
describe('Rendering', () => {
29+
it('renders without anything', () => {
30+
const { container } = render(<Answers {...defaultProps} />);
31+
expect(container.querySelector('.root')).toHaveClass('empty');
32+
expect(container).toMatchInlineSnapshot(`
33+
<div>
34+
<div
35+
class="root empty"
36+
>
37+
<div
38+
class="header"
39+
>
40+
header
41+
</div>
42+
<ul
43+
class="list"
44+
/>
45+
</div>
46+
</div>
47+
`);
48+
});
49+
50+
it('renders the loader', () => {
51+
const { container } = render(
52+
<Answers {...defaultProps} isLoading={true} />
53+
);
54+
expect(container).toMatchInlineSnapshot(`
55+
<div>
56+
<div
57+
class="root empty"
58+
>
59+
<div
60+
class="header"
61+
>
62+
header
63+
</div>
64+
<div
65+
class="loader"
66+
>
67+
loader
68+
</div>
69+
</div>
70+
</div>
71+
`);
72+
});
73+
74+
it('renders the header with data', () => {
75+
const props: AnswersProps = {
76+
...defaultProps,
77+
templateProps: {
78+
templates: {
79+
...defaultProps.templateProps.templates,
80+
header: ({ hits, isLoading }) => {
81+
return `${hits.length} answer(s) ${
82+
isLoading ? 'loading' : 'loaded'
83+
}`;
84+
},
85+
},
86+
},
87+
};
88+
const { container } = render(
89+
<Answers
90+
{...props}
91+
isLoading={false}
92+
hits={[{ objectID: '1', __position: 1 }]}
93+
/>
94+
);
95+
expect(container.querySelector('.header')).toHaveTextContent(
96+
'1 answer(s) loaded'
97+
);
98+
});
99+
100+
it('renders the answers', () => {
101+
const props: AnswersProps = {
102+
...defaultProps,
103+
templateProps: {
104+
templates: {
105+
...defaultProps.templateProps.templates,
106+
item: hit => {
107+
return `answer: ${hit.title}`;
108+
},
109+
},
110+
},
111+
};
112+
const { container } = render(
113+
<Answers
114+
{...props}
115+
isLoading={false}
116+
hits={[{ objectID: '1', title: 'hello!', __position: 1 }]}
117+
/>
118+
);
119+
expect(container.querySelector('.list')).toHaveTextContent(
120+
'answer: hello!'
121+
);
122+
});
123+
});
124+
});

‎src/connectors/answers/__tests__/connectAnswers-test.ts

+465
Large diffs are not rendered by default.
+258
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import {
2+
checkRendering,
3+
createDocumentationMessageGenerator,
4+
createConcurrentSafePromise,
5+
addQueryID,
6+
debounce,
7+
addAbsolutePosition,
8+
noop,
9+
escapeHits,
10+
} from '../../lib/utils';
11+
import {
12+
Connector,
13+
Hits,
14+
Hit,
15+
FindAnswersOptions,
16+
FindAnswersResponse,
17+
} from '../../types';
18+
19+
type IndexWithAnswers = {
20+
readonly findAnswers: any;
21+
};
22+
23+
function hasFindAnswersMethod(
24+
answersIndex: IndexWithAnswers | any
25+
): answersIndex is IndexWithAnswers {
26+
return typeof (answersIndex as IndexWithAnswers).findAnswers === 'function';
27+
}
28+
29+
const withUsage = createDocumentationMessageGenerator({
30+
name: 'answers',
31+
connector: true,
32+
});
33+
34+
export type AnswersRendererOptions = {
35+
/**
36+
* The matched hits from Algolia API.
37+
*/
38+
hits: Hits;
39+
40+
/**
41+
* Whether it's still loading the results from the Answers API.
42+
*/
43+
isLoading: boolean;
44+
};
45+
46+
export type AnswersConnectorParams = {
47+
/**
48+
* Attributes to use for predictions.
49+
* If empty, we use all `searchableAttributes` to find answers.
50+
* All your `attributesForPrediction` must be part of your `searchableAttributes`.
51+
*/
52+
attributesForPrediction?: string[];
53+
54+
/**
55+
* The languages in the query. Currently only supports `en`.
56+
*/
57+
queryLanguages: ['en'];
58+
59+
/**
60+
* Maximum number of answers to retrieve from the Answers Engine.
61+
* Cannot be greater than 1000.
62+
* @default 1
63+
*/
64+
nbHits?: number;
65+
66+
/**
67+
* Debounce time in milliseconds to debounce render
68+
* @default 100
69+
*/
70+
renderDebounceTime?: number;
71+
72+
/**
73+
* Debounce time in milliseconds to debounce search
74+
* @default 100
75+
*/
76+
searchDebounceTime?: number;
77+
78+
/**
79+
* Whether to escape HTML tags from hits string values.
80+
*
81+
* @default true
82+
*/
83+
escapeHTML?: boolean;
84+
85+
/**
86+
* Extra parameters to pass to findAnswers method.
87+
* @default {}
88+
*/
89+
extraParameters?: FindAnswersOptions;
90+
};
91+
92+
export type AnswersConnector = Connector<
93+
AnswersRendererOptions,
94+
AnswersConnectorParams
95+
>;
96+
97+
const connectAnswers: AnswersConnector = function connectAnswers(
98+
renderFn,
99+
unmountFn = noop
100+
) {
101+
checkRendering(renderFn, withUsage());
102+
103+
return widgetParams => {
104+
const {
105+
queryLanguages,
106+
attributesForPrediction,
107+
nbHits = 1,
108+
renderDebounceTime = 100,
109+
searchDebounceTime = 100,
110+
escapeHTML = true,
111+
extraParameters = {},
112+
} = widgetParams || ({} as typeof widgetParams);
113+
114+
// @ts-ignore checking for the wrong value
115+
if (!queryLanguages || queryLanguages.length === 0) {
116+
throw new Error(
117+
withUsage('The `queryLanguages` expects an array of strings.')
118+
);
119+
}
120+
121+
const runConcurrentSafePromise = createConcurrentSafePromise<
122+
FindAnswersResponse<Hit>
123+
>();
124+
125+
let lastResult: Partial<FindAnswersResponse<Hit>>;
126+
let isLoading = false;
127+
const debouncedRender = debounce(renderFn, renderDebounceTime);
128+
let debouncedRefine;
129+
130+
return {
131+
$$type: 'ais.answers',
132+
133+
init(initOptions) {
134+
const { state, instantSearchInstance } = initOptions;
135+
const answersIndex = instantSearchInstance.client!.initIndex!(
136+
state.index
137+
);
138+
if (!hasFindAnswersMethod(answersIndex)) {
139+
throw new Error(withUsage('`algoliasearch` >= 4.8.0 required.'));
140+
}
141+
debouncedRefine = debounce(
142+
answersIndex.findAnswers,
143+
searchDebounceTime
144+
);
145+
146+
renderFn(
147+
{
148+
...this.getWidgetRenderState(initOptions),
149+
instantSearchInstance: initOptions.instantSearchInstance,
150+
},
151+
true
152+
);
153+
},
154+
155+
render(renderOptions) {
156+
const query = renderOptions.state.query;
157+
if (!query) {
158+
// renders nothing with empty query
159+
lastResult = {};
160+
isLoading = false;
161+
renderFn(
162+
{
163+
...this.getWidgetRenderState(renderOptions),
164+
instantSearchInstance: renderOptions.instantSearchInstance,
165+
},
166+
false
167+
);
168+
return;
169+
}
170+
171+
// render the loader
172+
lastResult = {};
173+
isLoading = true;
174+
renderFn(
175+
{
176+
...this.getWidgetRenderState(renderOptions),
177+
instantSearchInstance: renderOptions.instantSearchInstance,
178+
},
179+
false
180+
);
181+
182+
// call /answers API
183+
runConcurrentSafePromise(
184+
debouncedRefine(query, queryLanguages, {
185+
...extraParameters,
186+
nbHits,
187+
attributesForPrediction,
188+
})
189+
).then(results => {
190+
if (!results) {
191+
// It's undefined when it's debounced.
192+
return;
193+
}
194+
195+
if (escapeHTML && results.hits.length > 0) {
196+
results.hits = escapeHits(results.hits);
197+
}
198+
const initialEscaped = (results.hits as ReturnType<typeof escapeHits>)
199+
.__escaped;
200+
201+
results.hits = addAbsolutePosition<typeof results.hits[0]>(
202+
results.hits,
203+
0,
204+
nbHits
205+
);
206+
207+
results.hits = addQueryID<typeof results.hits[0]>(
208+
results.hits,
209+
results.queryID
210+
);
211+
212+
// Make sure the escaped tag stays, even after mapping over the hits.
213+
// This prevents the hits from being double-escaped if there are multiple
214+
// hits widgets mounted on the page.
215+
(results.hits as ReturnType<
216+
typeof escapeHits
217+
>).__escaped = initialEscaped;
218+
219+
lastResult = results;
220+
isLoading = false;
221+
debouncedRender(
222+
{
223+
...this.getWidgetRenderState(renderOptions),
224+
instantSearchInstance: renderOptions.instantSearchInstance,
225+
},
226+
false
227+
);
228+
});
229+
},
230+
231+
getRenderState(renderState, renderOptions) {
232+
return {
233+
...renderState,
234+
answers: this.getWidgetRenderState(renderOptions),
235+
};
236+
},
237+
238+
getWidgetRenderState() {
239+
return {
240+
hits: lastResult?.hits || [],
241+
isLoading,
242+
widgetParams,
243+
};
244+
},
245+
246+
dispose({ state }) {
247+
unmountFn();
248+
return state;
249+
},
250+
251+
getWidgetSearchParameters(state) {
252+
return state;
253+
},
254+
};
255+
};
256+
};
257+
258+
export default connectAnswers;

‎src/connectors/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ export { default as EXPERIMENTAL_connectConfigureRelatedItems } from './configur
2424
export { default as connectAutocomplete } from './autocomplete/connectAutocomplete';
2525
export { default as connectQueryRules } from './query-rules/connectQueryRules';
2626
export { default as connectVoiceSearch } from './voice-search/connectVoiceSearch';
27+
export { default as EXPERIMENTAL_connectAnswers } from './answers/connectAnswers';
2728
export { default as connectSmartSort } from './smart-sort/connectSmartSort';

‎src/lib/routers/__tests__/history.test.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import historyRouter from '../history';
2-
3-
const wait = (ms = 0) => new Promise(res => setTimeout(res, ms));
2+
import { wait } from '../../../../test/utils/wait';
43

54
describe('life cycle', () => {
65
beforeEach(() => {
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { debounce } from '../debounce';
2+
3+
describe('debounce', () => {
4+
it('debounces the function', done => {
5+
const originalFunction = jest.fn();
6+
const debouncedFunction = debounce(originalFunction, 100);
7+
debouncedFunction('a');
8+
debouncedFunction('b');
9+
10+
setTimeout(() => {
11+
expect(originalFunction).toHaveBeenCalledTimes(1);
12+
expect(originalFunction).toHaveBeenLastCalledWith('b');
13+
done();
14+
}, 100);
15+
});
16+
17+
it('executes all the calls if they are not within the debounce time', done => {
18+
const originalFunction = jest.fn();
19+
const debouncedFunction = debounce(originalFunction, 100);
20+
21+
debouncedFunction('a');
22+
23+
setTimeout(() => {
24+
debouncedFunction('b');
25+
}, 100);
26+
27+
setTimeout(() => {
28+
expect(originalFunction).toHaveBeenCalledTimes(2);
29+
expect(originalFunction).toHaveBeenLastCalledWith('b');
30+
done();
31+
}, 250);
32+
});
33+
34+
it('returns a promise', async () => {
35+
const originalFunction = jest.fn(x => Promise.resolve(x));
36+
const debouncedFunction = debounce(originalFunction, 100);
37+
38+
debouncedFunction('a');
39+
40+
const promise = debouncedFunction('b');
41+
await expect(promise).resolves.toEqual('b');
42+
43+
expect(originalFunction).toHaveBeenCalledTimes(1);
44+
expect(originalFunction).toHaveBeenLastCalledWith('b');
45+
});
46+
47+
it('returns a promise with a resolved data', async () => {
48+
type OriginalFunction = () => Promise<'abc'>;
49+
const originalFunction: OriginalFunction = () => Promise.resolve('abc');
50+
51+
const debouncedFunction = debounce<OriginalFunction>(originalFunction, 100);
52+
const promise = debouncedFunction();
53+
const ret = await promise;
54+
expect(ret).toEqual('abc');
55+
});
56+
57+
it('accepts synchronous function as well', async () => {
58+
const originalFunction = jest.fn(x => x);
59+
const debouncedFunction = debounce(originalFunction, 100);
60+
const promise = debouncedFunction('a');
61+
62+
await expect(promise).resolves.toEqual('a');
63+
});
64+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export type MaybePromise<TResolution> =
2+
| Readonly<Promise<TResolution>>
3+
| Promise<TResolution>
4+
| TResolution;
5+
6+
// copied from
7+
// https://github.com/algolia/autocomplete.js/blob/307a7acc4283e10a19cb7d067f04f1bea79dc56f/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts#L1:L1
8+
/**
9+
* Creates a runner that executes promises in a concurrent-safe way.
10+
*
11+
* This is useful to prevent older promises to resolve after a newer promise,
12+
* otherwise resulting in stale resolved values.
13+
*/
14+
export function createConcurrentSafePromise<TValue>() {
15+
let basePromiseId = -1;
16+
let latestResolvedId = -1;
17+
let latestResolvedValue: TValue | undefined = undefined;
18+
19+
return function runConcurrentSafePromise(promise: MaybePromise<TValue>) {
20+
const currentPromiseId = ++basePromiseId;
21+
22+
return Promise.resolve(promise).then(x => {
23+
// The promise might take too long to resolve and get outdated. This would
24+
// result in resolving stale values.
25+
// When this happens, we ignore the promise value and return the one
26+
// coming from the latest resolved value.
27+
//
28+
// +----------------------------------+
29+
// | 100ms |
30+
// | run(1) +---> R1 |
31+
// | 300ms |
32+
// | run(2) +-------------> R2 (SKIP) |
33+
// | 200ms |
34+
// | run(3) +--------> R3 |
35+
// +----------------------------------+
36+
if (latestResolvedValue && currentPromiseId < latestResolvedId) {
37+
return latestResolvedValue;
38+
}
39+
40+
latestResolvedId = currentPromiseId;
41+
latestResolvedValue = x;
42+
43+
return x;
44+
});
45+
};
46+
}

‎src/lib/utils/debounce.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
type Func = (...args: any[]) => any;
2+
3+
type DebouncedFunction<TFunction extends Func> = (
4+
this: ThisParameterType<TFunction>,
5+
...args: Parameters<TFunction>
6+
) => Promise<ReturnType<TFunction>>;
7+
8+
// Debounce a function call to the trailing edge.
9+
// The debounced function returns a promise.
10+
export function debounce<TFunction extends Func>(
11+
func: TFunction,
12+
wait: number
13+
): DebouncedFunction<TFunction> {
14+
let lastTimeout: ReturnType<typeof setTimeout> | null = null;
15+
return function(...args) {
16+
// @ts-ignore-next-line
17+
return new Promise((resolve, reject) => {
18+
if (lastTimeout) {
19+
clearTimeout(lastTimeout);
20+
}
21+
lastTimeout = setTimeout(() => {
22+
lastTimeout = null;
23+
Promise.resolve(func(...args))
24+
.then(resolve)
25+
.catch(reject);
26+
}, wait);
27+
});
28+
};
29+
}

‎src/lib/utils/hits-absolute-position.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { Hits } from '../../types';
1+
import { Hit } from '../../types';
22

3-
export const addAbsolutePosition = (
4-
hits: Hits,
3+
export const addAbsolutePosition = <THit = Hit>(
4+
hits: THit[],
55
page: number,
66
hitsPerPage: number
7-
): Hits => {
7+
): THit[] => {
88
return hits.map((hit, idx) => ({
99
...hit,
1010
__position: hitsPerPage * page + idx + 1,

‎src/lib/utils/hits-query-id.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { Hits } from '../../types';
1+
import { Hit } from '../../types';
22

3-
export const addQueryID = (hits: Hits, queryID?: string): Hits => {
3+
export function addQueryID<THit = Hit>(hits: THit[], queryID?: string): THit[] {
44
if (!queryID) {
55
return hits;
66
}
77
return hits.map(hit => ({
88
...hit,
99
__queryID: queryID,
1010
}));
11-
};
11+
}

‎src/lib/utils/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@ export * from './createSendEventForFacet';
5252
export * from './createSendEventForHits';
5353
export { getAppIdAndApiKey } from './getAppIdAndApiKey';
5454
export { convertNumericRefinementsToFilters } from './convertNumericRefinementsToFilters';
55+
export { createConcurrentSafePromise } from './createConcurrentSafePromise';
56+
export { debounce } from './debounce';

‎src/middlewares/__tests__/createMetadataMiddleware.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createMetadataMiddleware } from '..';
22
import { createSearchClient } from '../../../test/mock/createSearchClient';
3+
import { wait } from '../../../test/utils/wait';
34
import instantsearch from '../../lib/main';
45
import { configure, hits, index, pagination, searchBox } from '../../widgets';
56
import { isMetadataEnabled } from '../createMetadataMiddleware';
@@ -31,8 +32,6 @@ Object.defineProperty(
3132
}))(window.navigator.userAgent)
3233
);
3334

34-
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
35-
3635
const defaultUserAgent =
3736
'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';
3837
const algoliaUserAgent = 'Algolia Crawler 5.3.2';

‎src/types/algoliasearch.ts

+13
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import algoliasearch, {
1616
SearchForFacetValues as SearchForFacetValuesV3,
1717
} from 'algoliasearch';
1818
import {
19+
FindAnswersResponse as FindAnswersResponseV4,
1920
SearchResponse as SearchResponseV4,
21+
FindAnswersOptions as FindAnswersOptionsV4,
2022
// no comma, TS is particular about which nodes expose comments
2123
// eslint-disable-next-line prettier/prettier
2224
SearchForFacetValuesResponse as SearchForFacetValuesResponseV4
@@ -25,6 +27,16 @@ import {
2527
// eslint-disable-next-line import/no-unresolved
2628
} from '@algolia/client-search';
2729

30+
export type FindAnswersOptions = DefaultSearchClient extends DummySearchClientV4
31+
? FindAnswersOptionsV4
32+
: any;
33+
34+
export type FindAnswersResponse<
35+
TObject
36+
> = DefaultSearchClient extends DummySearchClientV4
37+
? FindAnswersResponseV4<TObject>
38+
: any;
39+
2840
type DummySearchClientV4 = {
2941
readonly transporter: any;
3042
};
@@ -39,6 +51,7 @@ export type SearchClient = {
3951
search: DefaultSearchClient['search'];
4052
searchForFacetValues: DefaultSearchClient['searchForFacetValues'];
4153
addAlgoliaAgent?: DefaultSearchClient['addAlgoliaAgent'];
54+
initIndex?: DefaultSearchClient['initIndex'];
4255
};
4356

4457
export type MultiResponse<THit = any> = {

‎src/types/widget.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ import {
6262
PaginationRendererOptions,
6363
PaginationConnectorParams,
6464
} from '../connectors/pagination/connectPagination';
65+
import {
66+
AnswersRendererOptions,
67+
AnswersConnectorParams,
68+
} from '../connectors/answers/connectAnswers';
6569
import {
6670
RangeConnectorParams,
6771
RangeRendererOptions,
@@ -364,6 +368,7 @@ export type IndexRenderState = Partial<{
364368
}
365369
>;
366370
};
371+
answers: WidgetRenderState<AnswersRendererOptions, AnswersConnectorParams>;
367372
smartSort: WidgetRenderState<
368373
SmartSortRendererOptions,
369374
SmartSortConnectorParams
@@ -389,6 +394,7 @@ export type Widget<
389394
*/
390395
$$type?:
391396
| 'ais.analytics'
397+
| 'ais.answers'
392398
| 'ais.autocomplete'
393399
| 'ais.breadcrumb'
394400
| 'ais.clearRefinements'
@@ -425,6 +431,7 @@ export type Widget<
425431
*/
426432
$$widgetType?:
427433
| 'ais.analytics'
434+
| 'ais.answers'
428435
| 'ais.autocomplete'
429436
| 'ais.breadcrumb'
430437
| 'ais.clearRefinements'
@@ -455,7 +462,6 @@ export type Widget<
455462
| 'ais.stats'
456463
| 'ais.toggleRefinement'
457464
| 'ais.voiceSearch';
458-
459465
/**
460466
* Called once before the first search
461467
*/

‎src/widgets/__tests__/index.test.ts

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import { PlacesInstance } from 'places.js';
22
import * as widgets from '..';
33
import { Widget } from '../../types';
44

5+
/**
6+
* Checklist when adding a new widget
7+
*
8+
* 1. Include $$type in the returned object from connector
9+
* 2. Include $$widgetType in widget
10+
* 3. Update $$type and $$widgetType in src/types/widget.ts
11+
*/
12+
513
// This is written in the test, since Object.entries is not allowed in the
614
// source code. Once we use Object.entries without polyfill, we can move this
715
// helper to the `typedObject` file.
@@ -111,6 +119,10 @@ function initiateAllWidgets(): Array<[WidgetNames, Widget]> {
111119
attributes: ['attr1', 'attr2'],
112120
});
113121
}
122+
case 'EXPERIMENTAL_answers': {
123+
const EXPERIMENTAL_answers = widget as Widgets['EXPERIMENTAL_answers'];
124+
return EXPERIMENTAL_answers({ container, queryLanguages: ['en'] });
125+
}
114126
default: {
115127
return widget({ container, attribute: 'attr' });
116128
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/** @jsx h */
2+
3+
import algoliasearchHelper from 'algoliasearch-helper';
4+
import { fireEvent } from '@testing-library/preact';
5+
import instantsearch from '../../../index.es';
6+
import { createSearchClient } from '../../../../test/mock/createSearchClient';
7+
import { runAllMicroTasks } from '../../../../test/utils/runAllMicroTasks';
8+
import answers from '../answers';
9+
import searchBox from '../../search-box/search-box';
10+
11+
describe('answers', () => {
12+
describe('Usage', () => {
13+
it('throws without `container`', () => {
14+
expect(() => {
15+
// @ts-ignore
16+
answers({});
17+
}).toThrowErrorMatchingInlineSnapshot(`
18+
"The \`container\` option is required.
19+
20+
See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/"
21+
`);
22+
});
23+
24+
it('throws without `queryLanguages`', () => {
25+
const container = document.createElement('div');
26+
expect(() => {
27+
// @ts-ignore
28+
answers({ container });
29+
}).toThrowErrorMatchingInlineSnapshot(`
30+
"The \`queryLanguages\` expects an array of strings.
31+
32+
See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/#connector"
33+
`);
34+
});
35+
36+
it('throws when searchClient does not support findAnswers', () => {
37+
const container = document.createElement('div');
38+
const searchClient = createSearchClient({
39+
// @ts-ignore-next-line
40+
initIndex() {
41+
return {};
42+
},
43+
});
44+
const helper = algoliasearchHelper(searchClient, '', {});
45+
const search = instantsearch({
46+
indexName: 'instant_search',
47+
searchClient,
48+
});
49+
const widget = answers({
50+
container,
51+
queryLanguages: ['en'],
52+
attributesForPrediction: ['description'],
53+
});
54+
expect(() => {
55+
// @ts-ignore-next-line
56+
widget.init({
57+
state: helper.state,
58+
helper,
59+
instantSearchInstance: search,
60+
});
61+
}).toThrowErrorMatchingInlineSnapshot(`
62+
"\`algoliasearch\` >= 4.8.0 required.
63+
64+
See documentation: https://www.algolia.com/doc/api-reference/widgets/answers/js/#connector"
65+
`);
66+
});
67+
});
68+
69+
describe('render', () => {
70+
it('renders with empty state', async () => {
71+
const container = document.createElement('div');
72+
const search = instantsearch({
73+
indexName: 'instant_search',
74+
searchClient: createSearchClient({
75+
// @ts-ignore-next-line
76+
initIndex() {
77+
return {
78+
findAnswers: () => Promise.resolve({ hits: [] }),
79+
};
80+
},
81+
}),
82+
});
83+
search.addWidgets([
84+
answers({
85+
container,
86+
queryLanguages: ['en'],
87+
attributesForPrediction: ['description'],
88+
}),
89+
]);
90+
search.start();
91+
await runAllMicroTasks();
92+
expect(container.innerHTML).toMatchInlineSnapshot(
93+
`"<div class=\\"ais-Answers ais-Answers--empty\\"><div class=\\"ais-Answers-header\\"></div><ul class=\\"ais-Answers-list\\"></ul></div>"`
94+
);
95+
});
96+
97+
it('renders the answers', async done => {
98+
const answersContainer = document.createElement('div');
99+
const searchBoxContainer = document.createElement('div');
100+
const search = instantsearch({
101+
indexName: 'instant_search',
102+
searchClient: createSearchClient({
103+
// @ts-ignore-next-line
104+
initIndex() {
105+
return {
106+
findAnswers: () => {
107+
return Promise.resolve({ hits: [{ title: 'Hello' }] });
108+
},
109+
};
110+
},
111+
}),
112+
});
113+
search.addWidgets([
114+
answers({
115+
container: answersContainer,
116+
queryLanguages: ['en'],
117+
attributesForPrediction: ['description'],
118+
renderDebounceTime: 10,
119+
searchDebounceTime: 10,
120+
cssClasses: {
121+
root: 'root',
122+
loader: 'loader',
123+
emptyRoot: 'empty',
124+
item: 'item',
125+
},
126+
templates: {
127+
loader: 'loading...',
128+
item: hit => `title: ${hit.title}`,
129+
},
130+
}),
131+
searchBox({
132+
container: searchBoxContainer,
133+
}),
134+
]);
135+
search.start();
136+
await runAllMicroTasks();
137+
138+
fireEvent.input(searchBoxContainer.querySelector('input')!, {
139+
target: { value: 'a' },
140+
});
141+
142+
await runAllMicroTasks();
143+
expect(answersContainer.querySelector('.loader')!.innerHTML).toEqual(
144+
'loading...'
145+
);
146+
expect(answersContainer.querySelector('.root')).toHaveClass('empty');
147+
148+
setTimeout(() => {
149+
// debounced render
150+
expect(answersContainer.querySelector('.root')).not.toHaveClass(
151+
'empty'
152+
);
153+
expect(answersContainer.querySelectorAll('.item').length).toEqual(1);
154+
expect(answersContainer.querySelector('.item')!.innerHTML).toEqual(
155+
'title: Hello'
156+
);
157+
done();
158+
}, 30);
159+
});
160+
});
161+
});

‎src/widgets/answers/answers.tsx

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/** @jsx h */
2+
3+
import { h, render } from 'preact';
4+
import cx from 'classnames';
5+
import { WidgetFactory, Template, Hit, Renderer } from '../../types';
6+
import defaultTemplates from './defaultTemplates';
7+
import {
8+
createDocumentationMessageGenerator,
9+
getContainerNode,
10+
prepareTemplateProps,
11+
} from '../../lib/utils';
12+
import { component } from '../../lib/suit';
13+
import Answers from '../../components/Answers/Answers';
14+
import connectAnswers, {
15+
AnswersRendererOptions,
16+
AnswersConnectorParams,
17+
} from '../../connectors/answers/connectAnswers';
18+
19+
const withUsage = createDocumentationMessageGenerator({ name: 'answers' });
20+
const suit = component('Answers');
21+
22+
const renderer = ({
23+
renderState,
24+
cssClasses,
25+
containerNode,
26+
templates,
27+
}): Renderer<AnswersRendererOptions, Partial<AnswersWidgetParams>> => (
28+
{ hits, isLoading, instantSearchInstance },
29+
isFirstRendering
30+
) => {
31+
if (isFirstRendering) {
32+
renderState.templateProps = prepareTemplateProps({
33+
defaultTemplates,
34+
templatesConfig: instantSearchInstance.templatesConfig,
35+
templates,
36+
});
37+
return;
38+
}
39+
40+
render(
41+
<Answers
42+
cssClasses={cssClasses}
43+
hits={hits}
44+
isLoading={isLoading}
45+
templateProps={renderState.templateProps}
46+
/>,
47+
containerNode
48+
);
49+
};
50+
51+
export type AnswersTemplates = {
52+
/**
53+
* Template to use for the header. This template will receive an object containing `hits` and `isLoading`.
54+
*/
55+
header: Template<{
56+
hits: Hit[];
57+
isLoading: boolean;
58+
}>;
59+
60+
/**
61+
* Template to use for the loader.
62+
*/
63+
loader: Template;
64+
65+
/**
66+
* Template to use for each result. This template will receive an object containing a single record.
67+
*/
68+
item: Template<Hit>;
69+
};
70+
71+
export type AnswersCSSClasses = {
72+
/**
73+
* CSS class to add to the root element of the widget.
74+
*/
75+
root: string | string[];
76+
77+
/**
78+
* CSS class to add to the wrapping element when no results.
79+
*/
80+
emptyRoot: string | string[];
81+
82+
/**
83+
* CSS classes to add to the header.
84+
*/
85+
header: string | string[];
86+
87+
/**
88+
* CSS classes to add to the loader.
89+
*/
90+
loader: string | string[];
91+
92+
/**
93+
* CSS class to add to the list of results.
94+
*/
95+
list: string | string[];
96+
97+
/**
98+
* CSS class to add to each result.
99+
*/
100+
item: string | string[];
101+
};
102+
103+
export type AnswersWidgetParams = {
104+
/**
105+
* CSS Selector or HTMLElement to insert the widget.
106+
*/
107+
container: string | HTMLElement;
108+
109+
/**
110+
* The templates to use for the widget.
111+
*/
112+
templates?: Partial<AnswersTemplates>;
113+
114+
/**
115+
* The CSS classes to override.
116+
*/
117+
cssClasses?: Partial<AnswersCSSClasses>;
118+
};
119+
120+
export type AnswersWidget = WidgetFactory<
121+
AnswersRendererOptions,
122+
AnswersConnectorParams,
123+
AnswersWidgetParams
124+
>;
125+
126+
const answersWidget: AnswersWidget = widgetParams => {
127+
const {
128+
container,
129+
attributesForPrediction,
130+
queryLanguages,
131+
nbHits,
132+
searchDebounceTime,
133+
renderDebounceTime,
134+
escapeHTML,
135+
extraParameters,
136+
templates = defaultTemplates,
137+
cssClasses: userCssClasses = {},
138+
} = widgetParams || ({} as typeof widgetParams);
139+
140+
if (!container) {
141+
throw new Error(withUsage('The `container` option is required.'));
142+
}
143+
144+
const containerNode = getContainerNode(container);
145+
const cssClasses = {
146+
root: cx(suit(), userCssClasses.root),
147+
emptyRoot: cx(suit({ modifierName: 'empty' }), userCssClasses.emptyRoot),
148+
header: cx(suit({ descendantName: 'header' }), userCssClasses.header),
149+
loader: cx(suit({ descendantName: 'loader' }), userCssClasses.loader),
150+
list: cx(suit({ descendantName: 'list' }), userCssClasses.list),
151+
item: cx(suit({ descendantName: 'item' }), userCssClasses.item),
152+
};
153+
154+
const specializedRenderer = renderer({
155+
containerNode,
156+
cssClasses,
157+
templates,
158+
renderState: {},
159+
});
160+
161+
const makeWidget = connectAnswers(specializedRenderer, () =>
162+
render(null, containerNode)
163+
);
164+
165+
return {
166+
...makeWidget({
167+
attributesForPrediction,
168+
queryLanguages,
169+
nbHits,
170+
searchDebounceTime,
171+
renderDebounceTime,
172+
escapeHTML,
173+
extraParameters,
174+
}),
175+
$$widgetType: 'ais.answers',
176+
};
177+
};
178+
179+
export default answersWidget;
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
header: '',
3+
loader: '',
4+
item: item => JSON.stringify(item),
5+
};

‎src/widgets/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ export { default as queryRuleCustomData } from './query-rule-custom-data/query-r
2828
export { default as queryRuleContext } from './query-rule-context/query-rule-context';
2929
export { default as index } from './index/index';
3030
export { default as places } from './places/places';
31+
export { default as EXPERIMENTAL_answers } from './answers/answers';
3132
export { default as smartSort } from './smart-sort/smart-sort';

‎stories/answers.stories.ts

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { storiesOf } from '@storybook/html';
2+
import { withHits } from '../.storybook/decorators';
3+
import { EXPERIMENTAL_answers as answers } from '../src/widgets';
4+
import '../.storybook/static/answers.css';
5+
6+
const searchOptions = {
7+
appId: 'CKOEQ4XGMU',
8+
apiKey: '6560d3886292a5aec86d63b9a2cba447',
9+
indexName: 'ted',
10+
};
11+
12+
storiesOf('Results/Answers', module)
13+
.add(
14+
'default',
15+
withHits(({ search, container }) => {
16+
const p = document.createElement('p');
17+
p.innerText = `Try to search for "sarah jones"`;
18+
const answersContainer = document.createElement('div');
19+
container.appendChild(p);
20+
container.appendChild(answersContainer);
21+
22+
search.addWidgets([
23+
answers({
24+
container: answersContainer,
25+
queryLanguages: ['en'],
26+
attributesForPrediction: ['description'],
27+
templates: {
28+
item: hit => {
29+
return `<p>${hit._answer.extract}</p>`;
30+
},
31+
},
32+
}),
33+
]);
34+
}, searchOptions)
35+
)
36+
.add(
37+
'with header',
38+
withHits(({ search, container }) => {
39+
const p = document.createElement('p');
40+
p.innerText = `Try to search for "sarah jones"`;
41+
const answersContainer = document.createElement('div');
42+
container.appendChild(p);
43+
container.appendChild(answersContainer);
44+
45+
search.addWidgets([
46+
answers({
47+
container: answersContainer,
48+
queryLanguages: ['en'],
49+
attributesForPrediction: ['description'],
50+
templates: {
51+
header: ({ hits }) => {
52+
return hits.length === 0 ? '' : `<p>Answers</p>`;
53+
},
54+
item: hit => {
55+
return `<p>${hit._answer.extract}</p>`;
56+
},
57+
},
58+
}),
59+
]);
60+
}, searchOptions)
61+
)
62+
.add(
63+
'with loader',
64+
withHits(({ search, container }) => {
65+
const p = document.createElement('p');
66+
p.innerText = `Try to search for "sarah jones"`;
67+
const answersContainer = document.createElement('div');
68+
container.appendChild(p);
69+
container.appendChild(answersContainer);
70+
71+
search.addWidgets([
72+
answers({
73+
container: answersContainer,
74+
queryLanguages: ['en'],
75+
attributesForPrediction: ['description'],
76+
templates: {
77+
header: ({ hits }) => {
78+
return hits.length === 0 ? '' : `<p>Answers</p>`;
79+
},
80+
loader: `loading...`,
81+
item: hit => {
82+
return `<p>${hit._answer.extract}</p>`;
83+
},
84+
},
85+
}),
86+
]);
87+
}, searchOptions)
88+
)
89+
.add(
90+
'full example',
91+
withHits(({ search, container }) => {
92+
const p = document.createElement('p');
93+
p.innerText = `Try to search for "sarah jones"`;
94+
const answersContainer = document.createElement('div');
95+
container.appendChild(p);
96+
container.appendChild(answersContainer);
97+
98+
search.addWidgets([
99+
answers({
100+
container: answersContainer,
101+
queryLanguages: ['en'],
102+
attributesForPrediction: ['description'],
103+
cssClasses: {
104+
root: 'my-Answers',
105+
},
106+
templates: {
107+
loader: `
108+
<div class="card-skeleton">
109+
<div class="animated-background">
110+
<div class="skel-mask-container">
111+
<div class="skel-mask skel-mask-1"></div>
112+
<div class="skel-mask skel-mask-2"></div>
113+
<div class="skel-mask skel-mask-3"></div>
114+
</div>
115+
</div>
116+
</div>
117+
`,
118+
item: hit => {
119+
return `
120+
<p class="title one-line">${hit.title}</p>
121+
<div class="separator"></div>
122+
<p class="description three-lines">${hit._answer.extract}</p>
123+
`;
124+
},
125+
},
126+
}),
127+
]);
128+
}, searchOptions)
129+
);

‎test/utils/wait.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const wait = (ms: number) =>
2+
new Promise(resolve => setTimeout(resolve, ms));

0 commit comments

Comments
 (0)
Please sign in to comment.