Skip to content

Commit

Permalink
feat(core): expose metadata of widgets (#4604)
Browse files Browse the repository at this point in the history
* feat(core): expose widgetParams to the head on crawler

when the user agent of the crawler is detected a meta tag is added to the document head with the widget parameters for the added widgets.

Co-authored-by: François Chalifour <[email protected]>
Co-authored-by: Eunjae Lee <[email protected]>
Co-authored-by: Yannick Croissant <[email protected]>
Co-authored-by: Clément Vannicatte <[email protected]>

* use newest helper

* fix eslint

* simplify

* remove unused $$params

* extract payload recursively & safer

* fix(index): add official widget disclaimer

* chore: consistent import

* test(telemetry): add unitary test

* rename officialWidget to official

* remove $$official (for $$widgetType in separate PR)

* move DOM modification to subscribe callback

* move telemetry enabled

* fix capitalisation

* no fallback for type (crawler does it)

* rename to metadata

* test(InstantSearch): assert that middleware gets added in right condition

* create meta tag on middleware creation instead of "creator"

* add comment

* chore: use empty scopedResults

this prevents it from being classed as a response

Co-authored-by: François Chalifour <[email protected]>
Co-authored-by: Eunjae Lee <[email protected]>
Co-authored-by: Yannick Croissant <[email protected]>
Co-authored-by: Clément Vannicatte <[email protected]>
  • Loading branch information
5 people authored Jan 19, 2021
1 parent 8feef58 commit 1fcf716
Show file tree
Hide file tree
Showing 34 changed files with 651 additions and 271 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,12 @@ See https://www.algolia.com/doc/api-reference/api-parameters/optionalFilters/
),
};

const makeConfigure = connectConfigure(renderFn, unmountFn);
const makeWidget = connectConfigure(renderFn, unmountFn);

return {
// required, since widget parameters differ between these connectors
// and we don't want to have the parameters of configure here
...makeConfigure({ searchParameters } as any),
...makeWidget({ searchParameters } as any),
$$type: 'ais.configureRelatedItems',
};
};
Expand Down
9 changes: 8 additions & 1 deletion src/lib/InstantSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import {
RouterProps,
} from '../middlewares/createRouterMiddleware';
import { InsightsEvent } from '../middlewares/createInsightsMiddleware';
import {
createMetadataMiddleware,
isMetadataEnabled,
} from '../middlewares/createMetadataMiddleware';

const withUsage = createDocumentationMessageGenerator({
name: 'instantsearch',
Expand Down Expand Up @@ -218,7 +222,6 @@ See ${createDocumentationLink({

this.client = searchClient;
this.insightsClient = insightsClient;

this.indexName = indexName;
this.helper = null;
this.mainHelper = null;
Expand Down Expand Up @@ -250,6 +253,10 @@ See ${createDocumentationLink({
const routerOptions = typeof routing === 'boolean' ? undefined : routing;
this.use(createRouterMiddleware(routerOptions));
}

if (isMetadataEnabled()) {
this.use(createMetadataMiddleware());
}
}

/**
Expand Down
48 changes: 48 additions & 0 deletions src/lib/__tests__/InstantSearch-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ beforeEach(() => {
algoliasearchHelper.mockClear();
});

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';

const { window } = global;
Object.defineProperty(
window.navigator,
'userAgent',
(value => ({
get() {
return value;
},
set(v) {
value = v;
},
}))(window.navigator.userAgent)
);

describe('Usage', () => {
beforeEach(() => {
warning.cache = {};
Expand Down Expand Up @@ -367,6 +385,36 @@ See https://www.algolia.com/doc/api-reference/widgets/configure/js/`);
// could be null if we don't pretend the main helper is the one who searched
expect(search.helper.lastResults).not.toBe(null);
});

it("doesn't add metadata middleware by default", () => {
global.navigator.userAgent = defaultUserAgent;

const useSpy = jest.spyOn(InstantSearch.prototype, 'use');

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const search = new InstantSearch({
searchClient: createSearchClient(),
indexName: 'test',
});

expect(useSpy).toHaveBeenCalledTimes(0);
global.navigator.userAgent = defaultUserAgent;
});

it('adds metadata middleware on the Crawler user agent', () => {
global.navigator.userAgent = algoliaUserAgent;

const useSpy = jest.spyOn(InstantSearch.prototype, 'use');

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const search = new InstantSearch({
searchClient: createSearchClient(),
indexName: 'test',
});

expect(useSpy).toHaveBeenCalledTimes(1);
global.navigator.userAgent = defaultUserAgent;
});
});

describe('addWidget(s)', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/middlewares/__tests__/createInsightsMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import algoliasearch from 'algoliasearch';
import algoliasearchHelper from 'algoliasearch-helper';
import { createInsightsMiddleware } from '../createInsightsMiddleware';
import { createInsightsMiddleware } from '../';
import { createInstantSearch } from '../../../test/mock/createInstantSearch';
import {
createAlgoliaAnalytics,
Expand Down
149 changes: 149 additions & 0 deletions src/middlewares/__tests__/createMetadataMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createMetadataMiddleware } from '..';
import { createSearchClient } from '../../../test/mock/createSearchClient';
import instantsearch from '../../lib/main';
import { configure, hits, index, pagination, searchBox } from '../../widgets';
import { isMetadataEnabled } from '../createMetadataMiddleware';

declare global {
// using namespace so it's only in this file
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface Global {
navigator: {
userAgent: string;
};
window: Window;
}
}
}

const { window } = global;
Object.defineProperty(
window.navigator,
'userAgent',
(value => ({
get() {
return value;
},
set(v: string) {
value = v;
},
}))(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';

describe('createMetadataMiddleware', () => {
beforeEach(() => {
document.head.innerHTML = '';
});

describe('metadata disabled', () => {
it('does not enable on normal user agent', () => {
global.navigator.userAgent = defaultUserAgent;

expect(isMetadataEnabled()).toBe(false);
});

it("does not enable when there's no window", () => {
global.navigator.userAgent = algoliaUserAgent;

// @ts-ignore
delete global.window;

createMetadataMiddleware();

expect(isMetadataEnabled()).toBe(false);

global.window = window;
});
});

describe('metadata enabled', () => {
beforeEach(() => {
global.navigator.userAgent = algoliaUserAgent;
});

it('metadata enabled returns true', () => {
expect(isMetadataEnabled()).toBe(true);
});

it('does not add meta before subscribe', () => {
createMetadataMiddleware();

expect(document.head).toMatchInlineSnapshot(`<head />`);
});

it('fills it with widgets after start', async () => {
// not using createMetadataMiddleware() here,
// since metadata is built into instantsearch
const search = instantsearch({
searchClient: createSearchClient(),
indexName: 'test',
});

search.addWidgets([
searchBox({ container: document.createElement('div') }),
searchBox({ container: document.createElement('div') }),
hits({ container: document.createElement('div'), escapeHTML: true }),
index({ indexName: 'test2' }).addWidgets([
pagination({ container: document.createElement('div') }),
configure({ distinct: true, filters: 'hehe secret string!' }),
]),
]);

search.start();

await wait(100);

expect(document.head).toMatchInlineSnapshot(`
<head>
<meta
content="{\\"widgets\\":[{\\"type\\":\\"ais.searchBox\\",\\"params\\":[]},{\\"type\\":\\"ais.searchBox\\",\\"params\\":[]},{\\"type\\":\\"ais.hits\\",\\"params\\":[\\"escapeHTML\\"]},{\\"type\\":\\"ais.index\\",\\"params\\":[]},{\\"type\\":\\"ais.pagination\\",\\"params\\":[]},{\\"type\\":\\"ais.configure\\",\\"params\\":[\\"searchParameters\\"]}]}"
name="instantsearch:widgets"
/>
</head>
`);

expect(JSON.parse(document.head.querySelector('meta')!.content))
.toMatchInlineSnapshot(`
Object {
"widgets": Array [
Object {
"params": Array [],
"type": "ais.searchBox",
},
Object {
"params": Array [],
"type": "ais.searchBox",
},
Object {
"params": Array [
"escapeHTML",
],
"type": "ais.hits",
},
Object {
"params": Array [],
"type": "ais.index",
},
Object {
"params": Array [],
"type": "ais.pagination",
},
Object {
"params": Array [
"searchParameters",
],
"type": "ais.configure",
},
],
}
`);
});
});
});
110 changes: 110 additions & 0 deletions src/middlewares/createMetadataMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { InstantSearch, Middleware, Widget } from '../types';
import { Index } from '../widgets/index/index';

type WidgetMetaData = {
type: string | undefined;
params: string[];
};

type Payload = {
widgets: WidgetMetaData[];
};

function extractPayload(
widgets: Array<Widget<{ renderState: any }>>,
instantSearchInstance: InstantSearch,
payload: Payload
) {
const parent = instantSearchInstance.mainIndex;

const initOptions = {
instantSearchInstance,
parent,
scopedResults: [],
state: parent.getHelper()!.state,
helper: parent.getHelper()!,
createURL: parent.createURL,
uiState: instantSearchInstance._initialUiState,
renderState: instantSearchInstance.renderState,
templatesConfig: instantSearchInstance.templatesConfig,
searchMetadata: {
isSearchStalled: instantSearchInstance._isSearchStalled,
},
};

widgets.forEach(widget => {
let widgetParams = {};

if (widget.getWidgetRenderState) {
const renderState = widget.getWidgetRenderState(initOptions);

if (renderState && renderState.widgetParams) {
widgetParams = renderState.widgetParams;
}
}

// since we destructure in all widgets, the parameters with defaults are set to "undefined"
const params = Object.keys(widgetParams).filter(
key => widgetParams[key] !== undefined
);

payload.widgets.push({
type: widget.$$type,
params,
});

if (widget.$$type === 'ais.index') {
extractPayload(
(widget as Index).getWidgets(),
instantSearchInstance,
payload
);
}
});
}

export function isMetadataEnabled() {
return (
typeof window !== 'undefined' &&
window.navigator.userAgent.indexOf('Algolia Crawler') > -1
);
}

/**
* Exposes the metadata of mounted widgets in a custom
* `<meta name="instantsearch:widgets" />` tag. The metadata per widget is:
* - applied parameters
* - widget name
* - connector name
*/
export function createMetadataMiddleware(): Middleware {
return ({ instantSearchInstance }) => {
const payload: Payload = {
widgets: [],
};
const payloadContainer = document.createElement('meta');
const refNode = document.querySelector('head')!;
payloadContainer.name = 'instantsearch:widgets';

return {
onStateChange() {},
subscribe() {
// using setTimeout here to delay extraction until widgets have been added in a tick (e.g. Vue)
setTimeout(() => {
extractPayload(
instantSearchInstance.mainIndex.getWidgets(),
instantSearchInstance,
payload
);

payloadContainer.content = JSON.stringify(payload);
refNode.appendChild(payloadContainer);
}, 0);
},

unsubscribe() {
payloadContainer.parentNode?.removeChild(payloadContainer);
},
};
};
}
1 change: 1 addition & 0 deletions src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './createInsightsMiddleware';
export * from './createRouterMiddleware';
export * from './createMetadataMiddleware';
5 changes: 5 additions & 0 deletions src/types/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,11 @@ export type WidgetRenderState<
export type Widget<
TWidgetOptions extends { renderState: unknown } = { renderState: unknown }
> = {
/**
* Identifier for official widgets
*/
$$type?:
| 'ais.analytics'
| 'ais.autocomplete'
| 'ais.breadcrumb'
| 'ais.clearRefinements'
Expand Down Expand Up @@ -394,6 +398,7 @@ export type Widget<
| 'ais.stats'
| 'ais.toggleRefinement'
| 'ais.voiceSearch';

/**
* Called once before the first search
*/
Expand Down
Loading

0 comments on commit 1fcf716

Please sign in to comment.