Skip to content

Commit 387f41f

Browse files
authored
feat(Insights): Insights inside Instantsearch (#3598)
* feat(Insights): integrate Insights (Click Analytics) with InstantSearch feat(Insights): add withInsightsClient connector wrapper This PR adds a withInsightsClient which is an HOC for connectors. ```js const connectHitsWithInsightsClient = withInsightsClient(connectHits) ``` This connector will be used by default in the widget Hits and InfiniteHits to wrap connectHits and connectInfiniteHits respectively. When this PR is merged and released we can start using withInsightsClient in the flavours. feat(Insights): add template helpers feat(Insights): add withInsightsListener This commit adds withInsightsListener which - wraps Hits and InfiniteHits component, - listens to inner clicks targetting elements with data-insights attributes - calls the insights client exposed by `withInsightsClient` ```js const HitsWithInsightsListener = withInsightsListener(Hits) ``` feat(Insights): allow passing insightsClient to instantsearch instance feat(Insights): add listener to Hits feat(Insights): add listener to InfiniteHits feat(Insights): add storybook example for hits feat(Insights & typescript): type all the things extract *WithInsightsListeners components to upper scope rename withInsightsClient to withInsights feat(Insights): fix positions in infiniteScroll feat(Insights): extract addAbsolutePositions to make it connector responsibility * feat(Insights): expose `insights` helper on instantsearch instance This is mimicking the way we allow usage for `highlight` and `snippet`. ```js Instantsearch.widgets.hits({ // ... templates: { item(hit) { return ` <h2> ${hit.name} </h2> <button ${ Instantsearch.insights('clickedObjectIDsAfterSearch', { eventName: "Add to favorite", objectIDs: [hit.objectID] }) }> Add to favorite </button> `; }, }, }); ``` * feat(Insights): expose connectHitsWithInsights and connectInfiniteHitsWithInsights The intention is to make it easier and more straight forward to create custom InfiniteHits and Hits with the connector. If we exposed `withInsights` directly, this is what we'd need to decide on a definitive name that implies it works only on Hits (like withHitsInsights) ```js const connectHitsWithInsights = Instantsearch.connectors.withHitsInsights(Instantsearch.connectors.connectHits); const connectInfiniteHitsWithInsights = Instantsearch.connectors.withHitsInsights(Instantsearch.connectors.connectInfiniteHits); ``` If we expose `connectHitsWithInsights` and `connectInfiniteHitsWithInsights` directly, we can keep `withInsights` totally private, and all custom components example are more simple.
1 parent b3c2154 commit 387f41f

38 files changed

+1422
-21
lines changed

src/connectors/hits/__tests__/connectHits-test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import jsHelper, { SearchResults } from 'algoliasearch-helper';
22
import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight';
33
import connectHits from '../connectHits';
44

5+
jest.mock('../../../lib/utils/hits-absolute-position', () => ({
6+
addAbsolutePosition: hits => hits,
7+
}));
8+
59
describe('connectHits', () => {
610
it('throws without render function', () => {
711
expect(() => {
@@ -223,6 +227,45 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hits/js/#co
223227
);
224228
});
225229

230+
it('adds queryID if provided to results', () => {
231+
const rendering = jest.fn();
232+
const makeWidget = connectHits(rendering);
233+
const widget = makeWidget({});
234+
235+
const helper = jsHelper({}, '', {});
236+
helper.search = jest.fn();
237+
238+
widget.init({
239+
helper,
240+
state: helper.state,
241+
createURL: () => '#',
242+
onHistoryChange: () => {},
243+
});
244+
245+
const hits = [{ name: 'name 1' }, { name: 'name 2' }];
246+
247+
const results = new SearchResults(helper.state, [
248+
{ hits, queryID: 'theQueryID' },
249+
]);
250+
widget.render({
251+
results,
252+
state: helper.state,
253+
helper,
254+
createURL: () => '#',
255+
});
256+
257+
expect(rendering).toHaveBeenNthCalledWith(
258+
2,
259+
expect.objectContaining({
260+
hits: [
261+
{ name: 'name 1', __queryID: 'theQueryID' },
262+
{ name: 'name 2', __queryID: 'theQueryID' },
263+
],
264+
}),
265+
expect.anything()
266+
);
267+
});
268+
226269
it('transform items after escaping', () => {
227270
const rendering = jest.fn();
228271
const makeWidget = connectHits(rendering);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import jsHelper, { SearchResults } from 'algoliasearch-helper';
2+
import connectHitsWithInsights from '../connectHitsWithInsights';
3+
import { Client } from '../../../types';
4+
5+
jest.mock('../../../lib/utils/hits-absolute-position', () => ({
6+
addAbsolutePosition: hits => hits,
7+
}));
8+
9+
describe('connectHitsWithInsights', () => {
10+
it('should expose `insights` props', () => {
11+
const rendering = jest.fn();
12+
const makeWidget = connectHitsWithInsights(rendering, jest.fn());
13+
const widget: any = makeWidget({});
14+
15+
const helper = jsHelper({} as Client, '', {});
16+
helper.search = jest.fn();
17+
18+
widget.init({
19+
helper,
20+
state: helper.state,
21+
createURL: () => '#',
22+
onHistoryChange: () => {},
23+
instantSearchInstance: {
24+
insightsClient: jest.fn(),
25+
},
26+
});
27+
28+
const firstRenderingOptions = rendering.mock.calls[0][0];
29+
expect(firstRenderingOptions.insights).toBeUndefined();
30+
31+
const hits = [{ fake: 'data' }, { sample: 'infos' }];
32+
const results = new SearchResults(helper.state, [{ hits }]);
33+
widget.render({
34+
results,
35+
state: helper.state,
36+
helper,
37+
createURL: () => '#',
38+
instantSearchInstance: {
39+
insightsClient: jest.fn(),
40+
},
41+
});
42+
43+
const secondRenderingOptions = rendering.mock.calls[1][0];
44+
expect(secondRenderingOptions.insights).toBeInstanceOf(Function);
45+
});
46+
47+
it('should preserve props exposed by connectHits', () => {
48+
const rendering = jest.fn();
49+
const makeWidget = connectHitsWithInsights(rendering, jest.fn());
50+
const widget: any = makeWidget({});
51+
52+
const helper = jsHelper({} as Client, '', {});
53+
helper.search = jest.fn();
54+
55+
widget.init({
56+
helper,
57+
state: helper.state,
58+
createURL: () => '#',
59+
onHistoryChange: () => {},
60+
instantSearchInstance: {
61+
insightsClient: jest.fn(),
62+
},
63+
});
64+
65+
const hits = [{ fake: 'data' }, { sample: 'infos' }];
66+
const results = new SearchResults(helper.state, [{ hits }]);
67+
widget.render({
68+
results,
69+
state: helper.state,
70+
helper,
71+
createURL: () => '#',
72+
instantSearchInstance: {
73+
insightsClient: jest.fn(),
74+
},
75+
});
76+
77+
const secondRenderingOptions = rendering.mock.calls[1][0];
78+
expect(secondRenderingOptions.hits).toEqual(expect.objectContaining(hits));
79+
expect(secondRenderingOptions.results).toEqual(results);
80+
});
81+
});

src/connectors/hits/connectHits.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight';
22
import {
33
checkRendering,
44
createDocumentationMessageGenerator,
5+
addAbsolutePosition,
6+
addQueryID,
57
} from '../../lib/utils';
68

79
const withUsage = createDocumentationMessageGenerator({
@@ -76,6 +78,14 @@ export default function connectHits(renderFn, unmountFn) {
7678
results.hits = escapeHits(results.hits);
7779
}
7880

81+
results.hits = addAbsolutePosition(
82+
results.hits,
83+
results.page,
84+
results.hitsPerPage
85+
);
86+
87+
results.hits = addQueryID(results.hits, results.queryID);
88+
7989
results.hits = transformItems(results.hits);
8090

8191
renderFn(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { withInsights } from '../../lib/insights';
2+
import connectHits from './connectHits';
3+
4+
const connectHitsWithInsights = withInsights(connectHits);
5+
6+
export default connectHitsWithInsights;

src/connectors/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@ export {
88
default as connectHierarchicalMenu,
99
} from './hierarchical-menu/connectHierarchicalMenu';
1010
export { default as connectHits } from './hits/connectHits';
11+
export {
12+
default as connectHitsWithInsights,
13+
} from './hits/connectHitsWithInsights';
1114
export {
1215
default as connectHitsPerPage,
1316
} from './hits-per-page/connectHitsPerPage';
1417
export {
1518
default as connectInfiniteHits,
1619
} from './infinite-hits/connectInfiniteHits';
20+
export {
21+
default as connectInfiniteHitsWithInsights,
22+
} from './infinite-hits/connectInfiniteHitsWithInsights';
1723
export { default as connectMenu } from './menu/connectMenu';
1824
export {
1925
default as connectNumericMenu,

src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight';
33

44
import connectInfiniteHits from '../connectInfiniteHits';
55

6+
jest.mock('../../../lib/utils/hits-absolute-position', () => ({
7+
addAbsolutePosition: hits => hits,
8+
}));
9+
610
describe('connectInfiniteHits', () => {
711
it('throws without render function', () => {
812
expect(() => {
@@ -360,6 +364,58 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/infinite-hi
360364
);
361365
});
362366

367+
it('adds queryID if provided to results', () => {
368+
const rendering = jest.fn();
369+
const makeWidget = connectInfiniteHits(rendering);
370+
const widget = makeWidget({});
371+
372+
const helper = jsHelper({}, '', {});
373+
helper.search = jest.fn();
374+
375+
widget.init({
376+
helper,
377+
state: helper.state,
378+
createURL: () => '#',
379+
onHistoryChange: () => {},
380+
});
381+
382+
const hits = [
383+
{
384+
name: 'name 1',
385+
},
386+
{
387+
name: 'name 2',
388+
},
389+
];
390+
391+
const results = new SearchResults(helper.state, [
392+
{ hits, queryID: 'theQueryID' },
393+
]);
394+
widget.render({
395+
results,
396+
state: helper.state,
397+
helper,
398+
createURL: () => '#',
399+
});
400+
401+
expect(rendering).toHaveBeenNthCalledWith(
402+
2,
403+
expect.objectContaining({
404+
hits: [
405+
{
406+
name: 'name 1',
407+
__queryID: 'theQueryID',
408+
},
409+
{
410+
name: 'name 2',
411+
__queryID: 'theQueryID',
412+
},
413+
],
414+
}),
415+
false
416+
);
417+
});
418+
363419
it('does not render the same page twice', () => {
364420
const rendering = jest.fn();
365421
const makeWidget = connectInfiniteHits(rendering);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import jsHelper, { SearchResults } from 'algoliasearch-helper';
2+
import connectInfiniteHitsWithInsights from '../connectInfiniteHitsWithInsights';
3+
import { Client } from '../../../types';
4+
5+
jest.mock('../../../lib/utils/hits-absolute-position', () => ({
6+
addAbsolutePosition: hits => hits,
7+
}));
8+
9+
describe('connectInfiniteHitsWithInsights', () => {
10+
it('should expose `insights` props', () => {
11+
const rendering = jest.fn();
12+
const makeWidget = connectInfiniteHitsWithInsights(rendering, jest.fn());
13+
const widget: any = makeWidget({});
14+
15+
const helper = jsHelper({} as Client, '', {});
16+
helper.search = jest.fn();
17+
18+
widget.init({
19+
helper,
20+
state: helper.state,
21+
createURL: () => '#',
22+
onHistoryChange: () => {},
23+
instantSearchInstance: {
24+
insightsClient: jest.fn(),
25+
},
26+
});
27+
28+
const firstRenderingOptions = rendering.mock.calls[0][0];
29+
expect(firstRenderingOptions.insights).toBeUndefined();
30+
31+
const hits = [{ fake: 'data' }, { sample: 'infos' }];
32+
const results = new SearchResults(helper.state, [{ hits }]);
33+
widget.render({
34+
results,
35+
state: helper.state,
36+
helper,
37+
createURL: () => '#',
38+
instantSearchInstance: {
39+
insightsClient: jest.fn(),
40+
},
41+
});
42+
43+
const secondRenderingOptions = rendering.mock.calls[1][0];
44+
expect(secondRenderingOptions.insights).toBeInstanceOf(Function);
45+
});
46+
47+
it('should preserve props exposed by connectInfiniteHits', () => {
48+
const rendering = jest.fn();
49+
const makeWidget = connectInfiniteHitsWithInsights(rendering, jest.fn());
50+
const widget: any = makeWidget({});
51+
52+
const helper = jsHelper({} as Client, '', {});
53+
helper.search = jest.fn();
54+
55+
widget.init({
56+
helper,
57+
state: helper.state,
58+
createURL: () => '#',
59+
onHistoryChange: () => {},
60+
instantSearchInstance: {
61+
insightsClient: jest.fn(),
62+
},
63+
});
64+
65+
const hits = [{ fake: 'data' }, { sample: 'infos' }];
66+
const results = new SearchResults(helper.state, [{ hits }]);
67+
widget.render({
68+
results,
69+
state: helper.state,
70+
helper,
71+
createURL: () => '#',
72+
instantSearchInstance: {
73+
insightsClient: jest.fn(),
74+
},
75+
});
76+
77+
const secondRenderingOptions = rendering.mock.calls[1][0];
78+
expect(secondRenderingOptions.hits).toEqual(expect.objectContaining(hits));
79+
expect(secondRenderingOptions.results).toEqual(results);
80+
});
81+
});

src/connectors/infinite-hits/connectInfiniteHits.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight';
22
import {
33
checkRendering,
44
createDocumentationMessageGenerator,
5+
addAbsolutePosition,
6+
addQueryID,
57
} from '../../lib/utils';
68

79
const withUsage = createDocumentationMessageGenerator({
@@ -105,6 +107,14 @@ export default function connectInfiniteHits(renderFn, unmountFn) {
105107
results.hits = escapeHits(results.hits);
106108
}
107109

110+
results.hits = addAbsolutePosition(
111+
results.hits,
112+
results.page,
113+
results.hitsPerPage
114+
);
115+
116+
results.hits = addQueryID(results.hits, results.queryID);
117+
108118
results.hits = transformItems(results.hits);
109119

110120
if (lastReceivedPage < state.page) {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { withInsights } from '../../lib/insights';
2+
import connectInfiniteHits from './connectInfiniteHits';
3+
4+
const connectInfiniteHitsWithInsights = withInsights(connectInfiniteHits);
5+
6+
export default connectInfiniteHitsWithInsights;

0 commit comments

Comments
 (0)