diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 679804a245..0ea529773f 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,5 +1,9 @@ { - "sandboxes": ["/examples/js/multi-clients"], + "sandboxes": [ + "/examples/js/multi-clients", + "/examples/react/multi-clients", + "/examples/react/next-routing" + ], "buildCommand": "build --no-private --ignore *-maps --ignore *-native", "packages": [ "packages/instantsearch.js", diff --git a/babel.config.js b/babel.config.js index facc811fe7..ae1c913ebf 100644 --- a/babel.config.js +++ b/babel.config.js @@ -79,6 +79,12 @@ module.exports = (api) => { // false positive (babel doesn't know types) // this is actually only called on arrays 'String.prototype.includes', + + // Just for the PoC + 'Array.prototype.flatMap', + 'Object.entries', + 'Object.fromEntries', + 'Array.from', ]; if (defaultShouldInject && !exclude.includes(name)) { throw new Error( diff --git a/examples/js/multi-clients/index.html b/examples/js/multi-clients/index.html index 90103424b0..2fff57aae4 100644 --- a/examples/js/multi-clients/index.html +++ b/examples/js/multi-clients/index.html @@ -33,6 +33,9 @@

+
+
+
diff --git a/examples/js/multi-clients/src/app.js b/examples/js/multi-clients/src/app.js index 1a5a2b1647..f233e73034 100644 --- a/examples/js/multi-clients/src/app.js +++ b/examples/js/multi-clients/src/app.js @@ -5,10 +5,11 @@ import { panel, refinementList, searchBox, + frequentlyBoughtTogether, } from 'instantsearch.js/es/widgets'; import { search } from './search'; -import { hitItem } from './templates'; +import { hitItem, recommendItem } from './templates'; search.addWidgets([ configure({ @@ -33,6 +34,60 @@ search.addWidgets([ pagination({ container: '#pagination', }), + frequentlyBoughtTogether({ + container: '#fbt', + objectIDs: ['M0E20000000EAAK'], + cssClasses: { list: 'grid gap-2 grid-cols-3 lg:grid-cols-6' }, + templates: { + item: ({ item, html, sendEvent }) => { + return recommendItem({ + item, + html, + onAddToCart() { + sendEvent('conversion', item, 'Added To Cart', { + eventSubtype: 'addToCart', + objectData: [ + { + discount: 0, + price: item.price.value, + quantity: 1, + }, + ], + value: item.price.value, + currency: item.price.currency, + }); + }, + }); + }, + }, + }), + frequentlyBoughtTogether({ + container: '#fbt2', + objectIDs: ['M0E20000000E1HU'], + cssClasses: { list: 'grid gap-2 grid-cols-3 lg:grid-cols-6' }, + templates: { + item: ({ item, html, sendEvent }) => { + return recommendItem({ + item, + html, + onAddToCart() { + sendEvent('conversion', item, 'Added To Cart', { + eventSubtype: 'addToCart', + objectData: [ + { + discount: 0, + price: item.price.value, + quantity: 1, + }, + ], + value: item.price.value, + currency: item.price.currency, + }); + }, + }); + }, + }, + }), ]); search.start(); diff --git a/examples/js/multi-clients/src/search.js b/examples/js/multi-clients/src/search.js index 0c47a6a8f0..dcbbfe1e9b 100644 --- a/examples/js/multi-clients/src/search.js +++ b/examples/js/multi-clients/src/search.js @@ -1,5 +1,7 @@ import instantsearch from 'instantsearch.js'; -import { algolia } from '@algolia/client'; + +// In real life this would come from an @algolia/client package +import { algolia } from './algolia'; const client = algolia('XX85YRZZMV', '098f71f9e2267178bdfc08cc986d2999'); diff --git a/examples/react/multi-clients/.editorconfig b/examples/react/multi-clients/.editorconfig new file mode 100644 index 0000000000..dd7255e8a4 --- /dev/null +++ b/examples/react/multi-clients/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/examples/react/multi-clients/.gitignore b/examples/react/multi-clients/.gitignore new file mode 100644 index 0000000000..bf78c5a78c --- /dev/null +++ b/examples/react/multi-clients/.gitignore @@ -0,0 +1,23 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +.parcel-cache +*.local + +# Editor directories and files +.vscode +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/react/multi-clients/README.md b/examples/react/multi-clients/README.md new file mode 100644 index 0000000000..bb60a6370f --- /dev/null +++ b/examples/react/multi-clients/README.md @@ -0,0 +1,23 @@ +# react-instantsearch-app + +[![Edit getting-started](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/algolia/instantsearch/tree/master/examples/react/getting-started) + +_This project was generated with [create-instantsearch-app](https://github.com/algolia/instantsearch/tree/master/packages/create-instantsearch-app) by [Algolia](https://algolia.com)._ + +## Get started + +To run this project locally, install the dependencies and run the local server: + +```sh +npm install +npm start +``` + +Alternatively, you may use [Yarn](https://http://yarnpkg.com/): + +```sh +yarn +yarn start +``` + +Open http://localhost:3000 to see your app. diff --git a/examples/react/multi-clients/favicon.png b/examples/react/multi-clients/favicon.png new file mode 100644 index 0000000000..b9cee152b2 Binary files /dev/null and b/examples/react/multi-clients/favicon.png differ diff --git a/examples/react/multi-clients/index.html b/examples/react/multi-clients/index.html new file mode 100644 index 0000000000..71d4ecaed1 --- /dev/null +++ b/examples/react/multi-clients/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + React InstantSearch — Getting started + + + + + +
+ + + + diff --git a/examples/react/multi-clients/package.json b/examples/react/multi-clients/package.json new file mode 100644 index 0000000000..1dc23c8349 --- /dev/null +++ b/examples/react/multi-clients/package.json @@ -0,0 +1,24 @@ +{ + "name": "example-react-instantsearch-multi-clients", + "version": "6.46.0", + "private": true, + "scripts": { + "build": "BABEL_ENV=parcel parcel build index.html", + "start": "BABEL_ENV=parcel parcel index.html --port 3000" + }, + "dependencies": { + "@algolia/recommend": "4.20.0", + "algoliasearch": "4.14.3", + "instantsearch.js": "4.60.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-instantsearch": "7.3.0" + }, + "devDependencies": { + "@parcel/core": "2.10.0", + "@parcel/packager-raw-url": "2.10.0", + "@parcel/transformer-webmanifest": "2.10.0", + "parcel": "2.10.0", + "typescript": "5.1.3" + } +} diff --git a/examples/react/multi-clients/src/App.css b/examples/react/multi-clients/src/App.css new file mode 100644 index 0000000000..e01152732c --- /dev/null +++ b/examples/react/multi-clients/src/App.css @@ -0,0 +1,71 @@ +body, +h1 { + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, + Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; +} + +em { + background: cyan; + font-style: normal; +} + +.header { + display: flex; + align-items: center; + min-height: 50px; + padding: 0.5rem 1rem; + background-image: linear-gradient(to right, #8e43e7, #00aeff); + color: #fff; + margin-bottom: 1rem; +} + +.header a { + color: #fff; + text-decoration: none; +} + +.header-title { + font-size: 1.2rem; + font-weight: normal; +} + +.header-title::after { + content: ' ▸ '; + padding: 0 0.5rem; +} + +.header-subtitle { + font-size: 1.2rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 1rem; +} + +.search-panel { + display: flex; +} + +.search-panel__filters { + flex: 1; +} + +.search-panel__results { + flex: 3; +} + +.searchbox { + margin-bottom: 2rem; +} + +.pagination { + margin: 2rem auto; + text-align: center; +} diff --git a/examples/react/multi-clients/src/App.tsx b/examples/react/multi-clients/src/App.tsx new file mode 100644 index 0000000000..e3c2905a2b --- /dev/null +++ b/examples/react/multi-clients/src/App.tsx @@ -0,0 +1,106 @@ +import recommend from '@algolia/recommend'; +import algoliasearch from 'algoliasearch/lite'; +import { Hit } from 'instantsearch.js'; +import React from 'react'; +import { + Configure, + Highlight, + Hits, + InstantSearch, + Pagination, + RefinementList, + SearchBox, + FrequentlyBoughtTogether, + useFrequentlyBoughtTogether, +} from 'react-instantsearch'; + +import { Panel } from './Panel'; + +import './App.css'; + +export function algolia(appID: string, apiKey: string) { + const searchClient = algoliasearch(appID, apiKey); + const recommendClient = recommend(appID, apiKey); + + return { searchClient, recommendClient }; +} + +const client = algolia('XX85YRZZMV', '098f71f9e2267178bdfc08cc986d2999'); + +function CustomFrequentlyBoughtTogether({ + objectIDs, +}: { + objectIDs: string[]; +}) { + const { recommendations = [] } = useFrequentlyBoughtTogether({ objectIDs }); + return ( +
    + {recommendations.map((item) => ( +
  • {item.objectID}
  • + ))} +
+ ); +} + +export function App() { + return ( +
+
+

+ Multi-client +

+

+ using{' '} + + React InstantSearch + +

+
+ +
+ + {item.objectID}} + /> + + + +
+
+ + + +
+ +
+ + + +
+ +
+
+
+
+
+
+ ); +} + +type HitProps = { + hit: Hit; +}; + +function HitComponent({ hit }: HitProps) { + return ( +
+

+ +

+

+ +

+
+ ); +} diff --git a/examples/react/multi-clients/src/Panel.tsx b/examples/react/multi-clients/src/Panel.tsx new file mode 100644 index 0000000000..bbff1e71fa --- /dev/null +++ b/examples/react/multi-clients/src/Panel.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +type PanelProps = React.PropsWithChildren<{ + header: string; +}>; + +export function Panel({ header, children }: PanelProps) { + return ( +
+
+ {header} +
+
{children}
+
+ ); +} diff --git a/examples/react/multi-clients/src/env.js b/examples/react/multi-clients/src/env.js new file mode 100644 index 0000000000..3811e898da --- /dev/null +++ b/examples/react/multi-clients/src/env.js @@ -0,0 +1,6 @@ +// Parcel picks the `source` field of the monorepo packages and thus doesn't +// apply the Babel config. We therefore need to manually override the constants +// in the app. +// See https://twitter.com/devongovett/status/1134231234605830144 +global.__DEV__ = process.env.NODE_ENV !== 'production'; +global.__TEST__ = false; diff --git a/examples/react/multi-clients/src/index.tsx b/examples/react/multi-clients/src/index.tsx new file mode 100644 index 0000000000..b5cce1263c --- /dev/null +++ b/examples/react/multi-clients/src/index.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; + +createRoot(document.getElementById('root')!).render(); diff --git a/examples/react/next-routing/package.json b/examples/react/next-routing/package.json index fc238b5e1a..bddcb74a57 100644 --- a/examples/react/next-routing/package.json +++ b/examples/react/next-routing/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@algolia/recommend": "4.22.1", "algoliasearch": "4.14.3", "instantsearch.css": "8.1.0", "next": "13.5.1", diff --git a/examples/react/next-routing/pages/[pid].tsx b/examples/react/next-routing/pages/[pid].tsx new file mode 100644 index 0000000000..73baa843c2 --- /dev/null +++ b/examples/react/next-routing/pages/[pid].tsx @@ -0,0 +1,84 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { + Configure, + Hits, + Index, + useFrequentlyBoughtTogether, +} from 'react-instantsearch'; + +import { HitProps } from '../types'; + +import type { NextPage } from 'next'; + +function CustomFrequentlyBoughtTogether({ + objectIDs, +}: { + objectIDs: string[]; +}) { + const { recommendations } = useFrequentlyBoughtTogether({ objectIDs }); + return ( + <> +

Frequently bought with…

+
    + {recommendations?.map((item) => ( +
  • + + + +
  • + ))} +
+ + ); +} + +function HitComponent({ hit }: HitProps) { + return ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {hit.name} +
+
+

{hit.name}

+

+ By {hit.brand}, in {hit.list_categories.join(' > ')} +

+

+ {hit.price.currency} {hit.price.value.toLocaleString()} +

+
+ + ); +} + +const OtherPage: NextPage = () => { + const { query } = useRouter(); + + if (!query.pid) { + return null; + } + + return ( + +

+ + ← 🏠 + + {' · '} + Object ID: {query.pid} +

+ + + +
+ ); +}; + +export default OtherPage; diff --git a/examples/react/next-routing/pages/_app.js b/examples/react/next-routing/pages/_app.js index 49298fa4da..f18db3a8b7 100644 --- a/examples/react/next-routing/pages/_app.js +++ b/examples/react/next-routing/pages/_app.js @@ -1,9 +1,36 @@ +import recommend from '@algolia/recommend'; +import algoliasearch from 'algoliasearch'; +import singletonRouter from 'next/router'; import React from 'react'; +import { InstantSearch } from 'react-instantsearch'; +import { createInstantSearchRouterNext } from 'react-instantsearch-router-nextjs'; + import '../styles/globals.css'; import 'instantsearch.css/themes/satellite-min.css'; +export function algolia(appID, apiKey) { + const searchClient = algoliasearch(appID, apiKey); + const recommendClient = recommend(appID, apiKey); + + return { searchClient, recommendClient }; +} + +const client = algolia('XX85YRZZMV', '098f71f9e2267178bdfc08cc986d2999'); + function MyApp({ Component, pageProps }) { - return ; + return ( + + + + ); } export default MyApp; diff --git a/examples/react/next-routing/pages/index.tsx b/examples/react/next-routing/pages/index.tsx index ee50bc2a2e..7b30c8ff5d 100644 --- a/examples/react/next-routing/pages/index.tsx +++ b/examples/react/next-routing/pages/index.tsx @@ -1,87 +1,60 @@ -import algoliasearch from 'algoliasearch/lite'; -import { Hit as AlgoliaHit } from 'instantsearch.js'; -import { GetServerSideProps } from 'next'; -import Head from 'next/head'; import Link from 'next/link'; -import singletonRouter from 'next/router'; import React from 'react'; -import { renderToString } from 'react-dom/server'; import { - DynamicWidgets, - InstantSearch, Hits, Highlight, RefinementList, SearchBox, - InstantSearchServerState, - InstantSearchSSRProvider, - getServerState, + useFrequentlyBoughtTogether, + FrequentlyBoughtTogether, } from 'react-instantsearch'; -import { createInstantSearchRouterNext } from 'react-instantsearch-router-nextjs'; import { Panel } from '../components/Panel'; -const client = algoliasearch('latency', '6be0576ff61c053d5f9a3225e2a90f76'); - -type HitProps = { - hit: AlgoliaHit<{ - name: string; - price: number; - }>; -}; +import type { HitProps } from '../types'; function Hit({ hit }: HitProps) { return ( <> - - - - + + - ${hit.price} + {hit.description} ); } -type HomePageProps = { - serverState?: InstantSearchServerState; - url?: string; -}; - -export default function HomePage({ serverState, url }: HomePageProps) { +function CustomFrequentlyBoughtTogether({ + objectIDs, +}: { + objectIDs: string[]; +}) { + const { recommendations = [] } = useFrequentlyBoughtTogether({ objectIDs }); return ( - - - React InstantSearch - Next.js - - - {/* If you have navigation links outside of InstantSearch */} - - Prefilled query - +
    + {recommendations.map((item) => ( +
  • {item.objectID}
  • + ))} +
+ ); +} - -
-
- -
-
- - -
-
-
-
+export default function HomePage() { + return ( +
+
+ +
+
+ + {item.objectID}} + /> + + +
+
); } @@ -92,19 +65,3 @@ function FallbackComponent({ attribute }: { attribute: string }) { ); } - -export const getServerSideProps: GetServerSideProps = - async function getServerSideProps({ req }) { - const protocol = req.headers.referer?.split('://')[0] || 'https'; - const url = `${protocol}://${req.headers.host}${req.url}`; - const serverState = await getServerState(, { - renderToString, - }); - - return { - props: { - serverState, - url, - }, - }; - }; diff --git a/examples/react/next-routing/pages/other-page.tsx b/examples/react/next-routing/pages/other-page.tsx deleted file mode 100644 index 1325a9dd4c..0000000000 --- a/examples/react/next-routing/pages/other-page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; - -import type { NextPage } from 'next'; - -const OtherPage: NextPage = () => { - return <>Other page; -}; - -export default OtherPage; diff --git a/examples/react/next-routing/types.ts b/examples/react/next-routing/types.ts new file mode 100644 index 0000000000..105db42e86 --- /dev/null +++ b/examples/react/next-routing/types.ts @@ -0,0 +1,15 @@ +import { Hit as AlgoliaHit } from 'instantsearch.js'; + +export type HitProps = { + hit: AlgoliaHit<{ + name: string; + description: string; + brand: string; + image_urls: string[]; + list_categories: string[]; + price: { + currency: string; + value: number; + }; + }>; +}; diff --git a/packages/instantsearch.js/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts b/packages/instantsearch.js/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts index 205196910f..f61640c390 100644 --- a/packages/instantsearch.js/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts +++ b/packages/instantsearch.js/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts @@ -1,13 +1,16 @@ -import { getFrequentlyBoughtTogether } from '@algolia/recommend-core'; +//@ts-nocheck + import { - SendEventForHits, checkRendering, createDocumentationMessageGenerator, createSendEventForHits, noop, } from '../../lib/utils'; -import type { Connector, Hit, WidgetRenderState } from '../../types'; +import type { RecommendHits } from '../../lib/RecommendHelper'; +import type { SendEventForHits } from '../../lib/utils'; +import type { Connector, WidgetRenderState } from '../../types'; +import { createConnector } from '../../lib/createConnector'; const withUsage = createDocumentationMessageGenerator({ name: 'frequentlyBoughtTogether', @@ -15,7 +18,7 @@ const withUsage = createDocumentationMessageGenerator({ }); export type FrequentlyBoughtTogetherRenderState = { - recommendations: Hit[]; + recommendations: RecommendHits; sendEvent: SendEventForHits; }; @@ -39,103 +42,50 @@ export type FrequentlyBoughtTogetherConnector = Connector< FrequentlyBoughtTogetherConnectorParams >; -const connectFrequentlyBoughtTogether: FrequentlyBoughtTogetherConnector = - function connectFrequentlyBoughtTogether(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - let sendEvent: SendEventForHits; - const { objectIDs } = widgetParams || {}; +let sendEvent: SendEventForHits; - let recommendations = [] as any[]; +const connectFrequentlyBoughtTogether = createConnector< + FrequentlyBoughtTogetherWidgetDescription, + FrequentlyBoughtTogetherConnectorParams +>((widgetParams) => { + const { objectIDs } = widgetParams || {}; + + return { + name: 'frequentlyBoughtTogether', + dependsOn: 'recommend', + getWidgetParameters(state) { + objectIDs.forEach((objectID) => { + state.frequentlyBoughtTogether.add(objectID); + }); + + return state; + }, + + getWidgetRenderState({ helper, instantSearchInstance }) { + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + index: helper.getIndex(), + widgetType: 'ais.frequentlyBoughtTogether', // this.$$type, + }); + } return { - $$type: 'ais.frequentlyBoughtTogether', - - init(initOptions) { - const { state, instantSearchInstance } = initOptions; - - getFrequentlyBoughtTogether({ - objectIDs, - recommendClient: instantSearchInstance.recommendClient, - indexName: state.index, - // The Insights middleware hasn't run yet so forcing it to `true` - // for now for demo purposes - queryParameters: { clickAnalytics: true }, - // @ts-ignore - }).then(({ recommendations: _recommendations, results }) => { - recommendations = _recommendations.map((recommendation, index) => ({ - ...recommendation, - __position: index, - __queryID: results[0].queryID, - })); - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - true - ); - }); - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const renderState = this.getWidgetRenderState(renderOptions); - - renderFn( - { - ...renderState, - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - - renderState.sendEvent('view:internal', renderState.recommendations); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - frequentlyBoughtTogether: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ helper, instantSearchInstance }) { - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - index: helper.getIndex(), - widgetType: this.$$type, - }); - } - - return { - recommendations, - sendEvent, - widgetParams, - }; - }, - - dispose({ state }) { - unmountFn(); - - return state; - }, - - getWidgetSearchParameters(state) { - return state; - }, + recommendations: instantSearchInstance.recommendHelper.currentResults + ? instantSearchInstance.recommendHelper.currentResults[objectIDs[0]] + : [], + sendEvent, + widgetParams, }; - }; + }, + + shouldRender({ instantSearchInstance }) { + const { lastResults, currentResults } = + instantSearchInstance.recommendHelper; + + return lastResults !== currentResults; + }, }; +}); export default connectFrequentlyBoughtTogether; diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 23e020c583..e543c996e4 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -10,6 +10,7 @@ import { createRouterMiddleware } from '../middlewares/createRouterMiddleware'; import index from '../widgets/index/index'; import createHelpers from './createHelpers'; +import RecommendHelper from './RecommendHelper'; import { createDocumentationMessageGenerator, createDocumentationLink, @@ -39,6 +40,7 @@ import type { InitialResults, } from '../types'; import type { IndexWidget } from '../widgets/index/index'; +import type { RecommendClient } from '@algolia/recommend'; import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; const withUsage = createDocumentationMessageGenerator({ @@ -192,7 +194,8 @@ class InstantSearch< TRouteState = TUiState > extends EventEmitter { public client: NonNullable; - public recommendClient: any; + public recommendClient: RecommendClient; + public recommendHelper: RecommendHelper; public indexName: string; public insightsClient: AlgoliaInsightsClient | null; public onStateChange: InstantSearchOptions['onStateChange'] | null = @@ -335,7 +338,8 @@ See documentation: ${createDocumentationLink({ } this.client = (client?.searchClient || searchClient) as SearchClient; - this.recommendClient = client?.recommendClient!; + this.recommendClient = client?.recommendClient; + this.recommendHelper = new RecommendHelper(this.recommendClient); this.future = future; this.insightsClient = insightsClient; this.indexName = indexName; @@ -676,6 +680,7 @@ See documentation: ${createDocumentationLink({ // the results, but this is an optimization that has a very low impact for now. else if (this.mainIndex.getWidgets().length > 0) { this.scheduleSearch(); + this.scheduleRecommend(); } // Keep the previous reference for legacy purpose, some pattern use @@ -718,6 +723,7 @@ See documentation: ${createDocumentationLink({ */ public dispose(): void { this.scheduleSearch.cancel(); + this.scheduleRecommend.cancel(); this.scheduleRender.cancel(); clearTimeout(this._searchStalledTimer); @@ -747,6 +753,14 @@ See documentation: ${createDocumentationLink({ } }); + public scheduleRecommend = defer(() => { + if (this.started) { + this.recommendHelper.fetch().then(() => { + this.scheduleRender(); + }); + } + }); + public scheduleRender = defer((shouldResetStatus: boolean = true) => { if (!this.mainHelper?.hasPendingRequests()) { clearTimeout(this._searchStalledTimer); @@ -763,6 +777,8 @@ See documentation: ${createDocumentationLink({ }); this.emit('render'); + + this.recommendHelper.lastResults = this.recommendHelper.currentResults; }); public scheduleStalledRender() { diff --git a/packages/instantsearch.js/src/lib/RecommendHelper.ts b/packages/instantsearch.js/src/lib/RecommendHelper.ts new file mode 100644 index 0000000000..f1388fb8fa --- /dev/null +++ b/packages/instantsearch.js/src/lib/RecommendHelper.ts @@ -0,0 +1,50 @@ +import EventEmitter from '@algolia/events'; + +import type { SearchResponse } from '@algolia/client-search'; +import type { RecommendClient, RecommendationsQuery } from '@algolia/recommend'; + +export type RecommendParams = { + frequentlyBoughtTogether: Set; +}; + +export type RecommendHits = SearchResponse['hits']; +export type RecommendResults = Record; + +export const RECOMMEND_DEFAULT_PARAMS: RecommendParams = { + frequentlyBoughtTogether: new Set(), +}; + +export default class RecommendHelper extends EventEmitter { + private client: RecommendClient; + private queries: Record = {}; + + public lastResults: RecommendResults | null = null; + public currentResults: RecommendResults | null = null; + + constructor(client: RecommendClient) { + super(); + this.client = client; + } + + public register(indexName: string, params: RecommendParams) { + this.queries[indexName] = params; + } + + public fetch(): Promise { + const requests = Object.entries(this.queries).flatMap( + ([indexName, { frequentlyBoughtTogether }]) => + Array.from(frequentlyBoughtTogether).map((objectID: string) => ({ + indexName, + model: 'bought-together', + objectID, + })) + ) as RecommendationsQuery[]; + + return this.client.getRecommendations(requests).then(({ results }) => { + this.currentResults = Object.fromEntries( + results.map((result, i) => [requests[i].objectID, result.hits]) + ); + this.emit('result', this.currentResults); + }); + } +} diff --git a/packages/instantsearch.js/src/lib/createConnector.ts b/packages/instantsearch.js/src/lib/createConnector.ts new file mode 100644 index 0000000000..548c94563e --- /dev/null +++ b/packages/instantsearch.js/src/lib/createConnector.ts @@ -0,0 +1,126 @@ +//@ts-nocheck + +import { SearchParameters } from 'algoliasearch-helper'; +import { + Connector, + DisposeOptions, + InitOptions, + RenderOptions, + WidgetDescription, +} from '../types'; +import { noop } from './utils'; +import { RecommendParams } from './RecommendHelper'; + +type CreateConnectorParams< + TWidgetDescription extends WidgetDescription, + TConnectorParams, + TWidgetParams +> = (widgetParams: TConnectorParams & TWidgetParams) => { + name: string; + dependsOn: 'search' | 'recommend'; + getWidgetParameters( + state: SearchParameters | RecommendParams + ): SearchParameters | RecommendParams; + getWidgetRenderState( + renderOptions: InitOptions | RenderOptions + ): TWidgetDescription['renderState']; + shouldRender?(renderOptions: RenderOptions): boolean; +}; + +export function createConnector< + TWidgetDescription extends WidgetDescription, + TConnectorParams extends object, + TWidgetParams = {} +>( + paramsFn: ( + widgetParams: TConnectorParams & TWidgetParams + ) => CreateConnectorParams< + TWidgetDescription, + TConnectorParams, + TWidgetParams + > +): Connector { + return function connectDynamicWidgets(renderFn, unmountFn = noop) { + return (widgetParams) => { + const params = paramsFn(widgetParams); + + const $$type = `ais.${params.name}`; + + const shouldRender = params.shouldRender || (() => true); + + const getWidgetRenderState = params.getWidgetRenderState; + + // @ts-ignore + function getRenderState(renderState, renderOptions) { + return { + ...renderState, + [params.name]: getWidgetRenderState(renderOptions), + }; + } + + function init(initOptions: InitOptions) { + const renderState = getWidgetRenderState(initOptions); + + renderFn( + { + ...renderState, + instantSearchInstance: initOptions.instantSearchInstance, + widgetParams, + }, + true + ); + } + + function render(renderOptions: RenderOptions) { + if (!shouldRender(renderOptions)) { + return; + } + + const renderState = getWidgetRenderState(renderOptions); + + renderFn( + { + ...renderState, + instantSearchInstance: renderOptions.instantSearchInstance, + widgetParams, + }, + false + ); + + // @ts-ignore + renderState.sendEvent('view:internal', renderState.recommendations); + } + + function dispose({ state }: DisposeOptions) { + unmountFn(); + + return state; + } + + function getWidgetSearchParameters( + state: SearchParameters | RecommendParams + ) { + return params.dependsOn === 'recommend' + ? state // (state: SearchParameters) => state + : params.getWidgetParameters(state); + } + + function getWidgetRecommendParameters(state: RecommendParams) { + return params.getWidgetParameters(state); + } + + return { + $$type, + init, + shouldRender, + render, + getRenderState, + dispose, + getWidgetSearchParameters, + ...(params.dependsOn === 'recommend' + ? { getWidgetRecommendParameters } + : undefined), + }; + }; + }; +} diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index b8ad3e0b5f..40d86af477 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -1,3 +1,4 @@ +import type { RecommendParams } from '../lib/RecommendHelper'; import type { IndexWidget } from '../widgets/index/index'; import type { InstantSearch } from './instantsearch'; import type { IndexRenderState, WidgetRenderState } from './render-state'; @@ -216,6 +217,8 @@ type RequiredUiStateLifeCycle = { >; } ) => SearchParameters; + + getWidgetRecommendParameters?: (state: RecommendParams) => RecommendParams; }; type UiStateLifeCycle = diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index 13c130aadd..de66628e7d 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -1,5 +1,6 @@ import algoliasearchHelper from 'algoliasearch-helper'; +import { RECOMMEND_DEFAULT_PARAMS } from '../../lib/RecommendHelper'; import { checkIndexUiState, createDocumentationMessageGenerator, @@ -331,6 +332,17 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { _uiState: localUiState, }); + localInstantSearchInstance.recommendHelper.register( + indexName, + localWidgets.reduce( + (acc, widget) => + widget.getWidgetRecommendParameters + ? widget.getWidgetRecommendParameters(acc) + : acc, + RECOMMEND_DEFAULT_PARAMS + ) + ); + // We compute the render state before calling `init` in a separate loop // to construct the whole render state object that is then passed to // `init`. @@ -366,6 +378,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { }); localInstantSearchInstance.scheduleSearch(); + localInstantSearchInstance.scheduleRecommend(); } return this; @@ -454,6 +467,16 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { index: indexName, }), }); + instantSearchInstance.recommendHelper.register( + indexName, + localWidgets.reduce( + (acc, widget) => + widget.getWidgetRecommendParameters + ? widget.getWidgetRecommendParameters(acc) + : acc, + { frequentlyBoughtTogether: new Set() } + ) + ); // This Helper is only used for state management we do not care about the // `searchClient`. Only the "main" Helper created at the `InstantSearch` diff --git a/packages/react-instantsearch-core/src/connectors/useFrequentlyBoughtTogether.ts b/packages/react-instantsearch-core/src/connectors/useFrequentlyBoughtTogether.ts new file mode 100644 index 0000000000..9127630d08 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/useFrequentlyBoughtTogether.ts @@ -0,0 +1,22 @@ +import connectFrequentlyBoughtTogether from 'instantsearch.js/es/connectors/frequently-bought-together/connectFrequentlyBoughtTogether'; + +import { useConnector } from '../hooks/useConnector'; + +import type { AdditionalWidgetProperties } from '../hooks/useConnector'; +import type { + FrequentlyBoughtTogetherConnectorParams, + FrequentlyBoughtTogetherWidgetDescription, +} from 'instantsearch.js/es/connectors/frequently-bought-together/connectFrequentlyBoughtTogether'; + +export type UseFrequentlyBoughtTogetherProps = + FrequentlyBoughtTogetherConnectorParams; + +export function useFrequentlyBoughtTogether( + props: UseFrequentlyBoughtTogetherProps, + additionalWidgetProperties?: AdditionalWidgetProperties +) { + return useConnector< + FrequentlyBoughtTogetherConnectorParams, + FrequentlyBoughtTogetherWidgetDescription + >(connectFrequentlyBoughtTogether, props, additionalWidgetProperties); +} diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index 1f89e8b6c8..7cda3b5765 100644 --- a/packages/react-instantsearch-core/src/index.ts +++ b/packages/react-instantsearch-core/src/index.ts @@ -26,6 +26,7 @@ export * from './connectors/useSearchBox'; export * from './connectors/useSortBy'; export * from './connectors/useStats'; export * from './connectors/useToggleRefinement'; +export * from './connectors/useFrequentlyBoughtTogether'; export * from './hooks/useConnector'; export * from './hooks/useInstantSearch'; export * from './lib/wrapPromiseWithState'; diff --git a/packages/react-instantsearch/package.json b/packages/react-instantsearch/package.json index 16af5bc934..ea07528081 100644 --- a/packages/react-instantsearch/package.json +++ b/packages/react-instantsearch/package.json @@ -47,6 +47,7 @@ "test:exports": "node ./test/module/is-es-module.mjs && node ./test/module/is-cjs-module.cjs" }, "dependencies": { + "@algolia/recommend-vdom": "1.10.0", "@babel/runtime": "^7.1.2", "instantsearch.js": "4.60.0", "react-instantsearch-core": "7.3.0" diff --git a/packages/react-instantsearch/src/widgets/FrequentlyBoughtTogether.tsx b/packages/react-instantsearch/src/widgets/FrequentlyBoughtTogether.tsx new file mode 100644 index 0000000000..26d84834cf --- /dev/null +++ b/packages/react-instantsearch/src/widgets/FrequentlyBoughtTogether.tsx @@ -0,0 +1,45 @@ +import { createFrequentlyBoughtTogetherComponent } from '@algolia/recommend-vdom'; +import React, { createElement, Fragment } from 'react'; +import { useFrequentlyBoughtTogether } from 'react-instantsearch-core'; + +import type { FrequentlyBoughtTogetherProps as FrequentlyBoughtTogetherUiProps } from '@algolia/recommend-vdom'; +import type { UseFrequentlyBoughtTogetherProps } from 'react-instantsearch-core'; + +type UiProps = Pick< + FrequentlyBoughtTogetherUiProps>, + 'items' | 'status' +>; + +export type FrequentlyBoughtTogetherProps = Omit< + FrequentlyBoughtTogetherUiProps>, + keyof UiProps +> & + UseFrequentlyBoughtTogetherProps; + +const FrequentlyBoughtTogetherUiComponent = + createFrequentlyBoughtTogetherComponent({ + // @ts-ignore + createElement, + Fragment, + }); + +export function FrequentlyBoughtTogether({ + objectIDs, + ...props +}: FrequentlyBoughtTogetherProps) { + const { recommendations = [] } = useFrequentlyBoughtTogether( + { + objectIDs, + }, + { + $$widgetType: 'ais.currentRefinements', + } + ); + + const uiProps: UiProps = { + items: recommendations, + status: 'idle', + }; + + return ; +} diff --git a/packages/react-instantsearch/src/widgets/index.ts b/packages/react-instantsearch/src/widgets/index.ts index 294d244ed1..82d3a94b12 100644 --- a/packages/react-instantsearch/src/widgets/index.ts +++ b/packages/react-instantsearch/src/widgets/index.ts @@ -16,3 +16,4 @@ export * from './Snippet'; export * from './SortBy'; export * from './Stats'; export * from './ToggleRefinement'; +export * from './FrequentlyBoughtTogether';