Skip to content

Commit

Permalink
feat(Insights): Insights inside Instantsearch (#3598)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
tkrugg authored Apr 15, 2019
1 parent b3c2154 commit 387f41f
Show file tree
Hide file tree
Showing 38 changed files with 1,422 additions and 21 deletions.
43 changes: 43 additions & 0 deletions src/connectors/hits/__tests__/connectHits-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import jsHelper, { SearchResults } from 'algoliasearch-helper';
import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight';
import connectHits from '../connectHits';

jest.mock('../../../lib/utils/hits-absolute-position', () => ({
addAbsolutePosition: hits => hits,
}));

describe('connectHits', () => {
it('throws without render function', () => {
expect(() => {
Expand Down Expand Up @@ -223,6 +227,45 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hits/js/#co
);
});

it('adds queryID if provided to results', () => {
const rendering = jest.fn();
const makeWidget = connectHits(rendering);
const widget = makeWidget({});

const helper = jsHelper({}, '', {});
helper.search = jest.fn();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
});

const hits = [{ name: 'name 1' }, { name: 'name 2' }];

const results = new SearchResults(helper.state, [
{ hits, queryID: 'theQueryID' },
]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
});

expect(rendering).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
hits: [
{ name: 'name 1', __queryID: 'theQueryID' },
{ name: 'name 2', __queryID: 'theQueryID' },
],
}),
expect.anything()
);
});

it('transform items after escaping', () => {
const rendering = jest.fn();
const makeWidget = connectHits(rendering);
Expand Down
81 changes: 81 additions & 0 deletions src/connectors/hits/__tests__/connectHitsWithInsights-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import jsHelper, { SearchResults } from 'algoliasearch-helper';
import connectHitsWithInsights from '../connectHitsWithInsights';
import { Client } from '../../../types';

jest.mock('../../../lib/utils/hits-absolute-position', () => ({
addAbsolutePosition: hits => hits,
}));

describe('connectHitsWithInsights', () => {
it('should expose `insights` props', () => {
const rendering = jest.fn();
const makeWidget = connectHitsWithInsights(rendering, jest.fn());
const widget: any = makeWidget({});

const helper = jsHelper({} as Client, '', {});
helper.search = jest.fn();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const firstRenderingOptions = rendering.mock.calls[0][0];
expect(firstRenderingOptions.insights).toBeUndefined();

const hits = [{ fake: 'data' }, { sample: 'infos' }];
const results = new SearchResults(helper.state, [{ hits }]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const secondRenderingOptions = rendering.mock.calls[1][0];
expect(secondRenderingOptions.insights).toBeInstanceOf(Function);
});

it('should preserve props exposed by connectHits', () => {
const rendering = jest.fn();
const makeWidget = connectHitsWithInsights(rendering, jest.fn());
const widget: any = makeWidget({});

const helper = jsHelper({} as Client, '', {});
helper.search = jest.fn();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const hits = [{ fake: 'data' }, { sample: 'infos' }];
const results = new SearchResults(helper.state, [{ hits }]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const secondRenderingOptions = rendering.mock.calls[1][0];
expect(secondRenderingOptions.hits).toEqual(expect.objectContaining(hits));
expect(secondRenderingOptions.results).toEqual(results);
});
});
10 changes: 10 additions & 0 deletions src/connectors/hits/connectHits.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight';
import {
checkRendering,
createDocumentationMessageGenerator,
addAbsolutePosition,
addQueryID,
} from '../../lib/utils';

const withUsage = createDocumentationMessageGenerator({
Expand Down Expand Up @@ -76,6 +78,14 @@ export default function connectHits(renderFn, unmountFn) {
results.hits = escapeHits(results.hits);
}

results.hits = addAbsolutePosition(
results.hits,
results.page,
results.hitsPerPage
);

results.hits = addQueryID(results.hits, results.queryID);

results.hits = transformItems(results.hits);

renderFn(
Expand Down
6 changes: 6 additions & 0 deletions src/connectors/hits/connectHitsWithInsights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { withInsights } from '../../lib/insights';
import connectHits from './connectHits';

const connectHitsWithInsights = withInsights(connectHits);

export default connectHitsWithInsights;
6 changes: 6 additions & 0 deletions src/connectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ export {
default as connectHierarchicalMenu,
} from './hierarchical-menu/connectHierarchicalMenu';
export { default as connectHits } from './hits/connectHits';
export {
default as connectHitsWithInsights,
} from './hits/connectHitsWithInsights';
export {
default as connectHitsPerPage,
} from './hits-per-page/connectHitsPerPage';
export {
default as connectInfiniteHits,
} from './infinite-hits/connectInfiniteHits';
export {
default as connectInfiniteHitsWithInsights,
} from './infinite-hits/connectInfiniteHitsWithInsights';
export { default as connectMenu } from './menu/connectMenu';
export {
default as connectNumericMenu,
Expand Down
56 changes: 56 additions & 0 deletions src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight';

import connectInfiniteHits from '../connectInfiniteHits';

jest.mock('../../../lib/utils/hits-absolute-position', () => ({
addAbsolutePosition: hits => hits,
}));

describe('connectInfiniteHits', () => {
it('throws without render function', () => {
expect(() => {
Expand Down Expand Up @@ -360,6 +364,58 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/infinite-hi
);
});

it('adds queryID if provided to results', () => {
const rendering = jest.fn();
const makeWidget = connectInfiniteHits(rendering);
const widget = makeWidget({});

const helper = jsHelper({}, '', {});
helper.search = jest.fn();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
});

const hits = [
{
name: 'name 1',
},
{
name: 'name 2',
},
];

const results = new SearchResults(helper.state, [
{ hits, queryID: 'theQueryID' },
]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
});

expect(rendering).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
hits: [
{
name: 'name 1',
__queryID: 'theQueryID',
},
{
name: 'name 2',
__queryID: 'theQueryID',
},
],
}),
false
);
});

it('does not render the same page twice', () => {
const rendering = jest.fn();
const makeWidget = connectInfiniteHits(rendering);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import jsHelper, { SearchResults } from 'algoliasearch-helper';
import connectInfiniteHitsWithInsights from '../connectInfiniteHitsWithInsights';
import { Client } from '../../../types';

jest.mock('../../../lib/utils/hits-absolute-position', () => ({
addAbsolutePosition: hits => hits,
}));

describe('connectInfiniteHitsWithInsights', () => {
it('should expose `insights` props', () => {
const rendering = jest.fn();
const makeWidget = connectInfiniteHitsWithInsights(rendering, jest.fn());
const widget: any = makeWidget({});

const helper = jsHelper({} as Client, '', {});
helper.search = jest.fn();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const firstRenderingOptions = rendering.mock.calls[0][0];
expect(firstRenderingOptions.insights).toBeUndefined();

const hits = [{ fake: 'data' }, { sample: 'infos' }];
const results = new SearchResults(helper.state, [{ hits }]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const secondRenderingOptions = rendering.mock.calls[1][0];
expect(secondRenderingOptions.insights).toBeInstanceOf(Function);
});

it('should preserve props exposed by connectInfiniteHits', () => {
const rendering = jest.fn();
const makeWidget = connectInfiniteHitsWithInsights(rendering, jest.fn());
const widget: any = makeWidget({});

const helper = jsHelper({} as Client, '', {});
helper.search = jest.fn();

widget.init({
helper,
state: helper.state,
createURL: () => '#',
onHistoryChange: () => {},
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const hits = [{ fake: 'data' }, { sample: 'infos' }];
const results = new SearchResults(helper.state, [{ hits }]);
widget.render({
results,
state: helper.state,
helper,
createURL: () => '#',
instantSearchInstance: {
insightsClient: jest.fn(),
},
});

const secondRenderingOptions = rendering.mock.calls[1][0];
expect(secondRenderingOptions.hits).toEqual(expect.objectContaining(hits));
expect(secondRenderingOptions.results).toEqual(results);
});
});
10 changes: 10 additions & 0 deletions src/connectors/infinite-hits/connectInfiniteHits.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight';
import {
checkRendering,
createDocumentationMessageGenerator,
addAbsolutePosition,
addQueryID,
} from '../../lib/utils';

const withUsage = createDocumentationMessageGenerator({
Expand Down Expand Up @@ -105,6 +107,14 @@ export default function connectInfiniteHits(renderFn, unmountFn) {
results.hits = escapeHits(results.hits);
}

results.hits = addAbsolutePosition(
results.hits,
results.page,
results.hitsPerPage
);

results.hits = addQueryID(results.hits, results.queryID);

results.hits = transformItems(results.hits);

if (lastReceivedPage < state.page) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { withInsights } from '../../lib/insights';
import connectInfiniteHits from './connectInfiniteHits';

const connectInfiniteHitsWithInsights = withInsights(connectInfiniteHits);

export default connectInfiniteHitsWithInsights;
Loading

0 comments on commit 387f41f

Please sign in to comment.