Skip to content

Commit

Permalink
feat(Insights): integrate Insights (Click Analytics) with InstantSearch
Browse files Browse the repository at this point in the history
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

unshorten variable name ev -> event

rename isFirstRendering -> isFirstRender

type mouse event

avoid inline return

clean unsused comment

avoid inline return

inline cast document.querySelector<HTMLElement>

prettier things

remove document maniputation in tests

remove inferrable types in readDataAttributes

use post modern typing

type return values

Apply suggestions from code review

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

add space before describe

change inferPayload signature to use named params

remove perf comment

add space before Unmounter

Apply suggestions from code review

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

Update src/lib/__tests__/insights-client-test.js

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

remove param mutation

absolute imports before relative

convert to typescript client and listener tests

factorize Without inside 'types/utils'

simplify types

typo

feedback: expose addAbsolutePosition in utils

feedback: improved error message

add type on serializedPayload

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

casting div.firstElementChild with as keyword

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

feedback: move casting on another line

feedback: improve error message on incorrect data-insights-payload json string

prettier all the things

feedback: add type for Hits React component

fix typescript file linting

add query id

feat(insights): add __queryID to connectHits & connectInfiniteHits

simplified tests

feat(insights): encode payload to base64

typo

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

typo

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

feedback: fix insightsClient defaulting to jest.fn()

feedback: remove unused beforeEach

feedback: group expect statements for payloads

typo

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

feedback: add quotes around objectID

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

feedback: rename WidgetParams -> TWidgetParams

feedback: move clickAnalytics: true to configure

feedback: use toHaveBeenNthCalledWith

feedback: remove extra check for insightClient callability

feedback: remove proptypes

fix: typo

inline setup function

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: error message formatting

Co-Authored-By: tkrugg <tkrugg@users.noreply.github.com>

fix: update snapshots after error message reformatting
  • Loading branch information
tkrugg committed Apr 11, 2019
1 parent 172783c commit b5a6850
Show file tree
Hide file tree
Showing 31 changed files with 1,237 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
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
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
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
167 changes: 167 additions & 0 deletions src/helpers/__tests__/insights-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import insights, {
writeDataAttributes,
readDataAttributes,
hasDataAttributes,
} from '../insights';

const makeDomElement = (html: string): HTMLElement => {
const div = document.createElement('div');
div.innerHTML = html;
return (div.firstElementChild as HTMLElement) || div;
};

describe('insights', () => {
test('default behaviour', () => {
expect(
insights('clickedObjectIDsAfterSearch', {
objectIDs: ['3'],
eventName: 'Add to Cart',
})
).toMatchInlineSnapshot(
`"data-insights-method=\\"clickedObjectIDsAfterSearch\\" data-insights-payload=\\"eyJvYmplY3RJRHMiOlsiMyJdLCJldmVudE5hbWUiOiJBZGQgdG8gQ2FydCJ9\\""`
);
});
});

describe('writeDataAttributes', () => {
it('should output a string containing data-insights-* attributes', () => {
expect(
writeDataAttributes({
method: 'clickedObjectIDsAfterSearch',
payload: {
objectIDs: ['3'],
eventName: 'Add to Cart',
},
})
).toMatchInlineSnapshot(
`"data-insights-method=\\"clickedObjectIDsAfterSearch\\" data-insights-payload=\\"eyJvYmplY3RJRHMiOlsiMyJdLCJldmVudE5hbWUiOiJBZGQgdG8gQ2FydCJ9\\""`
);
});
it('should reject undefined payloads', () => {
expect(() =>
// @ts-ignore
writeDataAttributes({
method: 'clickedObjectIDsAfterSearch',
})
).toThrowErrorMatchingInlineSnapshot(
`"The insights helper expects the payload to be an object."`
);
});
it('should reject non object payloads', () => {
expect(() =>
writeDataAttributes({
method: 'clickedObjectIDsAfterSearch',
// @ts-ignore
payload: 2,
})
).toThrowErrorMatchingInlineSnapshot(
`"The insights helper expects the payload to be an object."`
);
});
it('should reject non JSON serializable payloads', () => {
const circularObject: any = { a: {} };
circularObject.a.circle = circularObject;
expect(() =>
writeDataAttributes({
method: 'clickedObjectIDsAfterSearch',
payload: circularObject,
})
).toThrowErrorMatchingInlineSnapshot(
`"Could not JSON serialize the payload object."`
);
});
});

describe('hasDataAttributes', () => {
it('should return true when there is a data-insights-method attribute', () => {
const domElement = makeDomElement(
`<button
data-insights-method="clickedObjectIDsAfterSearch"
data-insights-payload='{"objectIDs":["3"],"eventName":"Add to Cart"}'
> Add to Cart </button>`
);

expect(hasDataAttributes(domElement)).toBe(true);
});
it("should return false when there isn't a data-insights-method attribute", () => {
const domElement = makeDomElement(
`<button
data-insights-payload='{"objectIDs":["3"],"eventName":"Add to Cart"}'
> Add to Cart </button>`
);

expect(hasDataAttributes(domElement)).toBe(false);
});
});

describe('readDataAttributes', () => {
describe('on handwritten data-insights-* attributes', () => {
let domElement: HTMLElement;

beforeEach(() => {
const payload = btoa(
JSON.stringify({ objectIDs: ['3'], eventName: 'Add to Cart' })
);
domElement = makeDomElement(
`<button
data-insights-method="clickedObjectIDsAfterSearch"
data-insights-payload="${payload}"
> Add to Cart </button>`
);
});

it('should extract the method name', () => {
expect(readDataAttributes(domElement).method).toEqual(
'clickedObjectIDsAfterSearch'
);
});

it('should extract the payload and parse it as a json object', () => {
expect(readDataAttributes(domElement).payload).toEqual({
objectIDs: ['3'],
eventName: 'Add to Cart',
});
});

it('should reject invalid payload', () => {
domElement = makeDomElement(
`<button
data-insights-method="clickedObjectIDsAfterSearch"
data-insights-payload='xxx'
> Add to Cart </button>`
);
expect(() =>
readDataAttributes(domElement)
).toThrowErrorMatchingInlineSnapshot(
`"The insights helper was unable to parse \`data-insights-payload\`."`
);
});
});

describe('on data-insights-* attributes generated with insights helper', () => {
let domElement: HTMLElement;

beforeEach(() => {
domElement = makeDomElement(
`<button
${insights('clickedObjectIDsAfterSearch', {
objectIDs: ['3'],
eventName: 'Add to Cart',
})}> Add to Cart </button>`
);
});

it('should extract the method name', () => {
expect(readDataAttributes(domElement).method).toEqual(
'clickedObjectIDsAfterSearch'
);
});

it('should extract the payload and parse it as a json object', () => {
expect(readDataAttributes(domElement).payload).toEqual({
objectIDs: ['3'],
eventName: 'Add to Cart',
});
});
});
});
Loading

0 comments on commit b5a6850

Please sign in to comment.