diff --git a/bundlesize.config.json b/bundlesize.config.json index cee295847c..5249b7802a 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -14,7 +14,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "175 kB" + "maxSize": "175.25 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", diff --git a/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx b/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx index d8a2c9a32a..50cd5ef87e 100644 --- a/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx +++ b/packages/instantsearch.js/src/__tests__/common-connectors.test.tsx @@ -17,6 +17,7 @@ import { connectToggleRefinement, connectRelatedProducts, connectFrequentlyBoughtTogether, + connectTrendingItems, } from '../connectors'; import instantsearch from '../index.es'; import { refinementList } from '../widgets'; @@ -448,6 +449,32 @@ const testSetups: TestSetupsMap = { }) .start(); }, + createTrendingItemsConnectorTests({ instantSearchOptions, widgetParams }) { + const customTrendingItems = connectTrendingItems<{ + container: HTMLElement; + }>((renderOptions) => { + renderOptions.widgetParams.container.innerHTML = ` + + `; + }); + + instantsearch(instantSearchOptions) + .addWidgets([ + customTrendingItems({ + 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 = { @@ -463,6 +490,7 @@ const testOptions: TestOptionsMap = { createToggleRefinementConnectorTests: undefined, createRelatedProductsConnectorTests: undefined, createFrequentlyBoughtTogetherConnectorTests: undefined, + createTrendingItemsConnectorTests: undefined, }; describe('Common connector tests (InstantSearch.js)', () => { diff --git a/packages/instantsearch.js/src/connectors/index.ts b/packages/instantsearch.js/src/connectors/index.ts index a8f2d9577c..d56e129840 100644 --- a/packages/instantsearch.js/src/connectors/index.ts +++ b/packages/instantsearch.js/src/connectors/index.ts @@ -36,6 +36,7 @@ export { default as connectSortBy } from './sort-by/connectSortBy'; export { default as connectRatingMenu } from './rating-menu/connectRatingMenu'; export { default as connectStats } from './stats/connectStats'; export { default as connectToggleRefinement } from './toggle-refinement/connectToggleRefinement'; +export { default as connectTrendingItems } from './trending-items/connectTrendingItems'; export { default as connectBreadcrumb } from './breadcrumb/connectBreadcrumb'; export { default as connectGeoSearch } from './geo-search/connectGeoSearch'; export { default as connectPoweredBy } from './powered-by/connectPoweredBy'; diff --git a/packages/instantsearch.js/src/connectors/trending-items/connectTrendingItems.ts b/packages/instantsearch.js/src/connectors/trending-items/connectTrendingItems.ts new file mode 100644 index 0000000000..dfd43027f3 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/trending-items/connectTrendingItems.ts @@ -0,0 +1,157 @@ +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: 'trending-items', + connector: true, +}); + +export type TrendingItemsRenderState = { + /** + * The matched recommendations from the Algolia API. + */ + recommendations: Array>; +}; + +export type TrendingItemsConnectorParams = ( + | { + /** + * The facet attribute to get recommendations for. + */ + facetName: string; + /** + * The facet value to get recommendations for. + */ + facetValue: string; + } + | { facetName?: never; facetValue?: never } +) & { + /** + * 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, { results: RecommendResultItem }>; +}; + +export type TrendingItemsWidgetDescription = { + $$type: 'ais.trendingItems'; + renderState: TrendingItemsRenderState; +}; + +export type TrendingItemsConnector = Connector< + TrendingItemsWidgetDescription, + TrendingItemsConnectorParams +>; + +const connectTrendingItems: TrendingItemsConnector = + function connectTrendingItems(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return function trendingItems(widgetParams) { + const { + facetName, + facetValue, + maxRecommendations, + threshold, + fallbackParameters, + queryParameters, + transformItems = ((items) => items) as NonNullable< + TrendingItemsConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + return { + dependsOn: 'recommend', + $$type: 'ais.trendingItems', + + 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) { + return state.addTrendingItems({ + facetName, + facetValue, + maxRecommendations, + threshold, + fallbackParameters, + queryParameters, + $$id: this.$$id!, + }); + }, + }; + }; + }; + +export default connectTrendingItems; diff --git a/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx b/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx index 0b8c636a76..0c7f46c2a8 100644 --- a/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx @@ -363,6 +363,7 @@ const testSetups: TestSetupsMap = { ); }, + createTrendingItemsConnectorTests: () => {}, }; const testOptions: TestOptionsMap = { @@ -384,6 +385,12 @@ const testOptions: TestOptionsMap = { createToggleRefinementConnectorTests: { act }, createRelatedProductsConnectorTests: { act }, createFrequentlyBoughtTogetherConnectorTests: { act }, + createTrendingItemsConnectorTests: { + act, + skippedTests: { + options: true, + }, + }, }; describe('Common connector tests (React InstantSearch)', () => { diff --git a/packages/vue-instantsearch/src/__tests__/common-connectors.test.js b/packages/vue-instantsearch/src/__tests__/common-connectors.test.js index 9ebb1e9e8f..6c5a7f6cf2 100644 --- a/packages/vue-instantsearch/src/__tests__/common-connectors.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-connectors.test.js @@ -341,6 +341,7 @@ const testSetups = { }, createRelatedProductsConnectorTests: () => {}, createFrequentlyBoughtTogetherConnectorTests: () => {}, + createTrendingItemsConnectorTests: () => {}, }; function createCustomWidget({ @@ -425,6 +426,11 @@ const testOptions = { options: true, }, }, + createTrendingItemsConnectorTests: { + skippedTests: { + options: true, + }, + }, }; describe('Common connector tests (Vue InstantSearch)', () => { diff --git a/tests/common/connectors/index.ts b/tests/common/connectors/index.ts index cf782aa6a6..96f372113e 100644 --- a/tests/common/connectors/index.ts +++ b/tests/common/connectors/index.ts @@ -10,3 +10,4 @@ export * from './toggle-refinement'; export * from './current-refinements'; export * from './related-products'; export * from './frequently-bought-together'; +export * from './trending-items'; diff --git a/tests/common/connectors/trending-items/index.ts b/tests/common/connectors/trending-items/index.ts new file mode 100644 index 0000000000..c9f3fde99c --- /dev/null +++ b/tests/common/connectors/trending-items/index.ts @@ -0,0 +1,23 @@ +import { fakeAct } from '../../common'; + +import { createOptionsTests } from './options'; + +import type { TestOptions, TestSetup } from '../../common'; +import type { TrendingItemsConnectorParams } from 'instantsearch.js/src/connectors/trending-items/connectTrendingItems'; + +export type TrendingItemsConnectorSetup = TestSetup<{ + widgetParams: TrendingItemsConnectorParams; +}>; + +export function createTrendingItemsConnectorTests( + setup: TrendingItemsConnectorSetup, + { act = fakeAct, skippedTests = {} }: TestOptions = {} +) { + beforeAll(() => { + document.body.innerHTML = ''; + }); + + describe('TrendingItems connector common tests', () => { + createOptionsTests(setup, { act, skippedTests }); + }); +} diff --git a/tests/common/connectors/trending-items/options.ts b/tests/common/connectors/trending-items/options.ts new file mode 100644 index 0000000000..f270007d1b --- /dev/null +++ b/tests/common/connectors/trending-items/options.ts @@ -0,0 +1,222 @@ +import { + createRecommendResponse, + createSearchClient, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils'; +import { screen } from '@testing-library/dom'; + +import { skippableDescribe } from '../../common'; + +import type { TrendingItemsConnectorSetup } from '.'; +import type { SetupOptions, TestOptions } from '../../common'; + +export function createOptionsTests( + setup: TrendingItemsConnectorSetup, + { act, skippedTests }: Required +) { + skippableDescribe('options', skippedTests, () => { + test('forwards parameters to the client', async () => { + const searchClient = createMockedSearchClient(); + const options: SetupOptions = { + instantSearchOptions: { indexName: 'indexName', searchClient }, + widgetParams: { + facetName: 'facetName', + facetValue: 'facetValue', + maxRecommendations: 2, + threshold: 3, + fallbackParameters: { facetFilters: ['test1'] }, + queryParameters: { analytics: true }, + }, + }; + + await setup(options); + + expect(searchClient.getRecommendations).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + maxRecommendations: 2, + threshold: 3, + fallbackParameters: { facetFilters: ['test1'] }, + queryParameters: { analytics: true }, + }), + ]) + ); + }); + + test('returns recommendations', async () => { + const options: SetupOptions = { + instantSearchOptions: { + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }, + widgetParams: {}, + }; + + await setup(options); + + expect(screen.getByRole('list')).toMatchInlineSnapshot(`
    `); + + await act(async () => { + await wait(0); + }); + + expect(screen.getByRole('list')).toMatchInlineSnapshot(` +
      +
    • + A0E200000002BLK +
    • +
    • + A0E200000001WFI +
    • +
    • + A0E2000000024R1 +
    • +
    + `); + }); + + test('transforms recommendations', async () => { + const options: SetupOptions = { + instantSearchOptions: { + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }, + widgetParams: { + transformItems(items) { + return items.map((item) => ({ + ...item, + objectID: item.objectID.toLowerCase(), + })); + }, + }, + }; + + await setup(options); + + expect(screen.getByRole('list')).toMatchInlineSnapshot(`
      `); + + await act(async () => { + await wait(0); + }); + + expect(screen.getByRole('list')).toMatchInlineSnapshot(` +
        +
      • + a0e200000002blk +
      • +
      • + a0e200000001wfi +
      • +
      • + a0e2000000024r1 +
      • +
      + `); + }); + }); +} + +function createMockedSearchClient() { + return createSearchClient({ + getRecommendations: jest.fn((requests) => + Promise.resolve( + createRecommendResponse( + // @ts-ignore + // `request` will be implicitly typed as `any` in type-check:v3 + // since `getRecommendations` is not available there + requests.map((request) => { + return createSingleSearchResponse({ + hits: + request.maxRecommendations === 0 + ? [] + : [ + { + _highlightResult: { + brand: { + matchLevel: 'none', + matchedWords: [], + value: 'Moschino Love', + }, + name: { + matchLevel: 'none', + matchedWords: [], + value: 'Moschino Love – Shoulder bag', + }, + }, + _score: 40.87, + brand: 'Moschino Love', + list_categories: ['Women', 'Bags', 'Shoulder bags'], + name: 'Moschino Love – Shoulder bag', + objectID: 'A0E200000002BLK', + parentID: 'JC4052PP10LB100A', + price: { + currency: 'EUR', + discount_level: -100, + discounted_value: 0, + on_sales: false, + value: 227.5, + }, + }, + { + _highlightResult: { + brand: { + matchLevel: 'none', + matchedWords: [], + value: 'Gabs', + }, + name: { + matchLevel: 'none', + matchedWords: [], + value: 'Bag “Sabrina“ medium Gabs', + }, + }, + _score: 40.91, + brand: 'Gabs', + list_categories: ['Women', 'Bags', 'Shoulder bags'], + name: 'Bag “Sabrina“ medium Gabs', + objectID: 'A0E200000001WFI', + parentID: 'SABRINA', + price: { + currency: 'EUR', + discount_level: -100, + discounted_value: 0, + on_sales: false, + value: 210, + }, + }, + { + _highlightResult: { + brand: { + matchLevel: 'none', + matchedWords: [], + value: 'La Carrie Bag', + }, + name: { + matchLevel: 'none', + matchedWords: [], + value: 'Bag La Carrie Bag small black', + }, + }, + _score: 39.92, + brand: 'La Carrie Bag', + list_categories: ['Women', 'Bags', 'Shoulder bags'], + name: 'Bag La Carrie Bag small black', + objectID: 'A0E2000000024R1', + parentID: '151', + price: { + currency: 'EUR', + discount_level: -100, + discounted_value: 0, + on_sales: false, + value: 161.25, + }, + }, + ], + }); + }) + ) + ) + ), + }); +}