Skip to content

Commit

Permalink
feat(recommend): introduce connectRelatedProducts connector (#6142)
Browse files Browse the repository at this point in the history
Co-authored-by: Dhaya <154633+dhayab@users.noreply.github.com>
Co-authored-by: Raed <raed.chammam@algolia.com>
Co-authored-by: Aymeric Giraudet <aymeric.giraudet@algolia.com>
  • Loading branch information
4 people committed May 21, 2024
1 parent 75a4b49 commit 89c4409
Show file tree
Hide file tree
Showing 13 changed files with 510 additions and 11 deletions.
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
"maxSize": "77.75 kB"
"maxSize": "78 kB"
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
Expand Down
27 changes: 27 additions & 0 deletions packages/instantsearch.js/src/__tests__/common-connectors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
connectRatingMenu,
connectRefinementList,
connectToggleRefinement,
connectRelatedProducts,
} from '../connectors';
import instantsearch from '../index.es';
import { refinementList } from '../widgets';
Expand Down Expand Up @@ -392,6 +393,31 @@ const testSetups: TestSetupsMap<TestSuites> = {
})
.start();
},
createRelatedProductsConnectorTests({ instantSearchOptions, widgetParams }) {
const customRelatedProducts = connectRelatedProducts<{
container: HTMLElement;
}>((renderOptions) => {
renderOptions.widgetParams.container.innerHTML = `
<ul>${renderOptions.recommendations
.map((recommendation) => `<li>${recommendation.objectID}</li>`)
.join('')}</ul>`;
});

instantsearch(instantSearchOptions)
.addWidgets([
customRelatedProducts({
container: document.body.appendChild(document.createElement('div')),
...widgetParams,
}),
])
.on('error', () => {
/*
* prevent rethrowing InstantSearch errors, so tests can be asserted.
* IRL this isn't needed, as the error doesn't stop execution.
*/
})
.start();
},
};

const testOptions: TestOptionsMap<TestSuites> = {
Expand All @@ -405,6 +431,7 @@ const testOptions: TestOptionsMap<TestSuites> = {
createNumericMenuConnectorTests: undefined,
createRatingMenuConnectorTests: undefined,
createToggleRefinementConnectorTests: undefined,
createRelatedProductsConnectorTests: undefined,
};

describe('Common connector tests (InstantSearch.js)', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/instantsearch.js/src/connectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { default as connectNumericMenu } from './numeric-menu/connectNumericMenu
export { default as connectPagination } from './pagination/connectPagination';
export { default as connectRange } from './range/connectRange';
export { default as connectRefinementList } from './refinement-list/connectRefinementList';
export { default as connectRelatedProducts } from './related-products/connectRelatedProducts';
export { default as connectSearchBox } from './search-box/connectSearchBox';
export { default as connectSortBy } from './sort-by/connectSortBy';
export { default as connectRatingMenu } from './rating-menu/connectRatingMenu';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
createDocumentationMessageGenerator,
checkRendering,
noop,
} from '../../lib/utils';

import type { Connector, TransformItems, Hit, BaseHit } from '../../types';
import type {
PlainSearchParameters,
RecommendResultItem,
} from 'algoliasearch-helper';

const withUsage = createDocumentationMessageGenerator({
name: 'related-products',
connector: true,
});

export type RelatedProductsRenderState<THit extends BaseHit = BaseHit> = {
/**
* The matched recommendations from the Algolia API.
*/
recommendations: Array<Hit<THit>>;
};

export type RelatedProductsConnectorParams<THit extends BaseHit = BaseHit> = {
/**
* The `objectIDs` of the items to get related products from.
*/
objectIDs: string[];
/**
* The number of recommendations to retrieve.
*/
maxRecommendations?: number;
/**
* The threshold for the recommendations confidence score (between 0 and 100).
*/
threshold?: number;
/**
* List of search parameters to send.
*/
fallbackParameters?: Omit<
PlainSearchParameters,
'page' | 'hitsPerPage' | 'offset' | 'length'
>;
/**
* List of search parameters to send.
*/
queryParameters?: Omit<
PlainSearchParameters,
'page' | 'hitsPerPage' | 'offset' | 'length'
>;
/**
* Function to transform the items passed to the templates.
*/
transformItems?: TransformItems<Hit<THit>, { results: RecommendResultItem }>;
};

export type RelatedProductsWidgetDescription<THit extends BaseHit = BaseHit> = {
$$type: 'ais.relatedProducts';
renderState: RelatedProductsRenderState<THit>;
};

export type RelatedProductsConnector<THit extends BaseHit = BaseHit> =
Connector<
RelatedProductsWidgetDescription<THit>,
RelatedProductsConnectorParams<THit>
>;

const connectRelatedProducts: RelatedProductsConnector =
function connectRelatedProducts(renderFn, unmountFn = noop) {
checkRendering(renderFn, withUsage());

return function relatedProducts(widgetParams) {
const {
objectIDs,
maxRecommendations,
threshold,
fallbackParameters,
queryParameters,
transformItems = ((items) => items) as NonNullable<
RelatedProductsConnectorParams['transformItems']
>,
} = widgetParams || {};

if (!objectIDs || objectIDs.length === 0) {
throw new Error(withUsage('The `objectIDs` option is required.'));
}

return {
dependsOn: 'recommend',
$$type: 'ais.relatedProducts',

init(initOptions) {
renderFn(
{
...this.getWidgetRenderState(initOptions),
instantSearchInstance: initOptions.instantSearchInstance,
},
true
);
},

render(renderOptions) {
const renderState = this.getWidgetRenderState(renderOptions);

renderFn(
{
...renderState,
instantSearchInstance: renderOptions.instantSearchInstance,
},
false
);
},

getRenderState(renderState) {
return renderState;
},

getWidgetRenderState({ results }) {
if (results === null || results === undefined) {
return { recommendations: [], widgetParams };
}

return {
recommendations: transformItems(results.hits, {
results: results as RecommendResultItem,
}),
widgetParams,
};
},

dispose({ state }) {
unmountFn();

return state;
},

getWidgetParameters(state) {
// We only use the first `objectID` to get the recommendations for
// until we implement support for multiple `objectIDs` in the helper.
const objectID = objectIDs[0];

return state.addRelatedProducts({
objectID,
maxRecommendations,
threshold,
fallbackParameters,
queryParameters,
$$id: this.$$id!,
});
},
};
};
};

export default connectRelatedProducts;
11 changes: 11 additions & 0 deletions packages/instantsearch.js/src/lib/utils/addWidgetId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Widget } from '../../types';

let id = 0;

export function addWidgetId(widget: Widget) {
if (widget.dependsOn !== 'recommend') {
return;
}

widget.$$id = id++;
}
2 changes: 1 addition & 1 deletion packages/instantsearch.js/src/types/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ type RecommendWidget<
TWidgetDescription extends WidgetDescription & WidgetParams
> = {
dependsOn: 'recommend';
$$id: number;
$$id?: number;
getWidgetParameters: (
state: RecommendParameters,
widgetParametersOptions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,19 @@ describe('index', () => {
...args,
});

const createFrequentlyBoughtTogether = (args: Partial<Widget> = {}): Widget =>
const createFrequentlyBoughtTogether = (
args: Partial<Widget> = {}
): Widget & { $$id: number } =>
createWidget({
dependsOn: 'recommend',
getWidgetParameters: jest.fn((parameters) => {
getWidgetParameters(parameters) {
return parameters.addFrequentlyBoughtTogether({
$$id: 1,
$$id: this.$$id!,
objectID: 'abc',
});
}),
},
...args,
} as unknown as Widget);
} as Widget) as unknown as Widget & { $$id: number };

const virtualSearchBox = connectSearchBox(() => {});
const virtualPagination = connectPagination(() => {});
Expand Down Expand Up @@ -226,6 +228,17 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge
`);
});

it('adds generated `$$id` to widgets that depend on `recommend`', () => {
const instance = index({ indexName: 'indexName' });
const fbt1 = createFrequentlyBoughtTogether({});
const fbt2 = createFrequentlyBoughtTogether({});

instance.addWidgets([fbt1, fbt2]);

expect(fbt1.$$id).toBe(0);
expect(fbt2.$$id).toBe(1);
});

describe('with a started instance', () => {
it('updates the internal state with added widgets', () => {
const instance = index({ indexName: 'indexName' });
Expand Down Expand Up @@ -270,9 +283,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge
instance.init(createIndexInitOptions({ parent: null }));

const fbt = createFrequentlyBoughtTogether({});
const getWidgetParameters = jest.spyOn(fbt, 'getWidgetParameters');
instance.addWidgets([fbt]);

expect(fbt.getWidgetParameters).toHaveBeenCalledTimes(1);
expect(getWidgetParameters).toHaveBeenCalledTimes(1);
});

it('calls getWidgetParameters after init on widgets that depend on search and implement the function', () => {
Expand Down Expand Up @@ -1250,11 +1264,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge
const instance = index({ indexName: 'indexName' });

const fbt = createFrequentlyBoughtTogether({});
const getWidgetParameters = jest.spyOn(fbt, 'getWidgetParameters');
instance.addWidgets([fbt]);

instance.init(createIndexInitOptions({ parent: null }));

expect(fbt.getWidgetParameters).toHaveBeenCalledTimes(1);
expect(getWidgetParameters).toHaveBeenCalledTimes(1);
});

it('calls getWidgetParameters on widgets that depend on search and implement the function', () => {
Expand Down Expand Up @@ -3012,9 +3027,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge
});

const fbt = createFrequentlyBoughtTogether({
$$id: 1,
dependsOn: 'recommend',
shouldRender: () => true,
render: jest.fn(),
});
instance.addWidgets([fbt]);

Expand Down
11 changes: 10 additions & 1 deletion packages/instantsearch.js/src/widgets/index/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createInitArgs,
createRenderArgs,
} from '../../lib/utils';
import { addWidgetId } from '../../lib/utils/addWidgetId';

import type {
InstantSearch,
Expand Down Expand Up @@ -306,7 +307,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => {
if (
widget.dependsOn !== 'recommend' ||
isIndexWidget(widget) ||
!widget.$$id
widget.$$id === undefined
) {
return this.getResults();
}
Expand Down Expand Up @@ -386,6 +387,14 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => {
);
}

widgets.forEach((widget) => {
if (isIndexWidget(widget)) {
return;
}

addWidgetId(widget);
});

localWidgets = localWidgets.concat(widgets);

if (localInstantSearchInstance && Boolean(widgets.length)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ const testSetups: TestSetupsMap<TestSuites> = {
</InstantSearch>
);
},
createRelatedProductsConnectorTests: () => {},
};

const testOptions: TestOptionsMap<TestSuites> = {
Expand All @@ -332,6 +333,12 @@ const testOptions: TestOptionsMap<TestSuites> = {
createNumericMenuConnectorTests: { act },
createRatingMenuConnectorTests: { act },
createToggleRefinementConnectorTests: { act },
createRelatedProductsConnectorTests: {
act,
skippedTests: {
options: true,
},
},
};

describe('Common connector tests (React InstantSearch)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ const testSetups = {

await nextTick();
},
createRelatedProductsConnectorTests: () => {},
};

function createCustomWidget({
Expand Down Expand Up @@ -413,6 +414,11 @@ const testOptions = {
createNumericMenuConnectorTests: undefined,
createRatingMenuConnectorTests: undefined,
createToggleRefinementConnectorTests: undefined,
createRelatedProductsConnectorTests: {
skippedTests: {
options: true,
},
},
};

describe('Common connector tests (Vue InstantSearch)', () => {
Expand Down
Loading

0 comments on commit 89c4409

Please sign in to comment.