From 0750f28a31a3774bb66f5e59198e452321391c4b Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 7 May 2026 15:49:22 +0100 Subject: [PATCH 01/14] init --- jest.config.js | 3 + packages/algolia-experiences/package.json | 3 +- packages/algolia-experiences/src/banner.tsx | 2 +- packages/algolia-experiences/src/render.tsx | 4 +- .../src/setup-instantsearch.ts | 2 +- packages/instantsearch-core/README.md | 3 + packages/instantsearch-core/package.json | 52 + packages/instantsearch-core/rollup.config.mjs | 36 + .../instantsearch-core/scripts/version.cjs | 11 + .../src/connectors/answers/connectAnswers.ts | 267 ++++ .../autocomplete/connectAutocomplete.ts | 318 +++++ .../breadcrumb/connectBreadcrumb.ts | 359 +++++ .../src/connectors/chat/connectChat.ts | 766 +++++++++++ .../connectClearRefinements.ts | 272 ++++ .../connectors/configure/connectConfigure.ts | 203 +++ .../connectCurrentRefinements.ts | 430 ++++++ .../dynamic-widgets/connectDynamicWidgets.ts | 262 ++++ .../src/connectors/feeds/FeedContainer.ts | 310 +++++ .../src/connectors/feeds/connectFeeds.ts | 210 +++ .../connectFilterSuggestions.ts | 449 +++++++ .../connectFrequentlyBoughtTogether.ts | 243 ++++ .../connectors/geo-search/connectGeoSearch.ts | 427 ++++++ .../connectHierarchicalMenu.ts | 530 ++++++++ .../hits-per-page/connectHitsPerPage.ts | 311 +++++ .../src/connectors/hits/connectHits.ts | 236 ++++ .../hits/connectHitsWithInsights.ts | 21 + .../src/connectors/index.ts | 71 + .../infinite-hits/connectInfiniteHits.ts | 510 +++++++ .../connectInfiniteHitsWithInsights.ts | 24 + .../looking-similar/connectLookingSimilar.ts | 235 ++++ .../src/connectors/menu/connectMenu.ts | 422 ++++++ .../numeric-menu/connectNumericMenu.ts | 507 +++++++ .../src/connectors/pagination/Paginator.ts | 72 + .../pagination/connectPagination.ts | 207 +++ .../connectors/powered-by/connectPoweredBy.ts | 111 ++ .../query-rules/connectQueryRules.ts | 277 ++++ .../src/connectors/range/connectRange.ts | 484 +++++++ .../rating-menu/connectRatingMenu.ts | 498 +++++++ .../refinement-list/connectRefinementList.ts | 591 ++++++++ .../connectRelatedProducts.ts | 236 ++++ .../relevant-sort/connectRelevantSort.ts | 142 ++ .../connectors/search-box/connectSearchBox.ts | 176 +++ .../src/connectors/sort-by/connectSortBy.ts | 396 ++++++ .../src/connectors/stats/connectStats.ts | 146 ++ .../connectToggleRefinement.ts | 505 +++++++ .../src/connectors/toggle-refinement/types.ts | 6 + .../trending-facets/connectTrendingFacets.ts | 205 +++ .../trending-items/connectTrendingItems.ts | 253 ++++ .../voice-search/connectVoiceSearch.ts | 222 +++ .../get-insights-anonymous-user-token.ts | 39 + .../instantsearch-core/src/helpers/index.ts | 1 + packages/instantsearch-core/src/index.ts | 44 + .../src/lib/ai-lite/abstract-chat.ts | 1196 ++++++++++++++++ .../src/lib/ai-lite/index.ts | 74 + .../src/lib/ai-lite/stream-parser.ts | 148 ++ .../src/lib/ai-lite/transport.ts | 251 ++++ .../src/lib/ai-lite/types.ts | 512 +++++++ .../src/lib/ai-lite/utils.ts | 94 ++ .../instantsearch-core/src/lib/chat/chat.ts | 166 +++ .../instantsearch-core/src/lib/chat/index.ts | 13 + .../src/lib/infiniteHitsCache/index.ts | 1 + .../lib/infiniteHitsCache/sessionStorage.ts | 74 + .../src/lib/insights/client.ts | 135 ++ .../src/lib/insights/index.ts | 1 + .../src/lib/public/addWidgetId.ts | 15 + .../src/lib/public/capitalize.ts | 3 + .../src/lib/public/checkIndexUiState.ts | 198 +++ .../src/lib/public/checkRendering.ts | 16 + .../src/lib/public/clearRefinements.ts | 43 + .../src/lib/public/concatHighlightedParts.ts | 15 + .../lib/public/createConcurrentSafePromise.ts | 46 + .../src/lib/public/createSendEventForFacet.ts | 67 + .../src/lib/public/createSendEventForHits.ts | 222 +++ .../src/lib/public/debounce.ts | 30 + .../src/lib/public/defer.ts | 51 + .../src/lib/public/documentation.ts | 29 + .../src/lib/public/escape-highlight.ts | 79 ++ .../src/lib/public/escape-html.ts | 61 + .../src/lib/public/escapeFacetValue.ts | 21 + .../instantsearch-core/src/lib/public/find.ts | 21 + .../src/lib/public/findIndex.ts | 21 + .../instantsearch-core/src/lib/public/flat.ts | 3 + .../src/lib/public/geo-search.ts | 74 + .../src/lib/public/getAlgoliaAgent.ts | 10 + .../src/lib/public/getAppIdAndApiKey.ts | 23 + .../lib/public/getHighlightFromSiblings.ts | 20 + .../src/lib/public/getHighlightedParts.ts | 30 + .../src/lib/public/getObjectType.ts | 3 + .../src/lib/public/getPropertyByPath.ts | 8 + .../src/lib/public/getRefinements.ts | 217 +++ .../src/lib/public/getWidgetAttribute.ts | 31 + .../src/lib/public/hits-absolute-position.ts | 12 + .../src/lib/public/hits-query-id.ts | 14 + .../src/lib/public/hydrateRecommendCache.ts | 20 + .../src/lib/public/hydrateSearchClient.ts | 190 +++ .../src/lib/public/index.ts | 55 + .../src/lib/public/isEqual.ts | 36 + .../src/lib/public/isFacetRefined.ts | 15 + .../src/lib/public/isFiniteNumber.ts | 7 + .../src/lib/public/isIndexWidget.ts | 9 + .../src/lib/public/isPlainObject.ts | 40 + .../src/lib/public/isSpecialClick.ts | 11 + .../src/lib/public/isTwoPassWidget.ts | 11 + .../src/lib/public/logger.ts | 69 + .../src/lib/public/mergeSearchParameters.ts | 155 +++ .../instantsearch-core/src/lib/public/noop.ts | 1 + .../instantsearch-core/src/lib/public/omit.ts | 26 + .../src/lib/public/range.ts | 21 + .../src/lib/public/render-args.ts | 79 ++ .../src/lib/public/resolveSearchParameters.ts | 16 + .../src/lib/public/reverseHighlightedParts.ts | 14 + .../src/lib/public/safelyRunOnBrowser.ts | 26 + .../src/lib/public/sendChatMessageFeedback.ts | 33 + .../src/lib/public/serializer.ts | 7 + .../src/lib/public/setIndexHelperState.ts | 29 + .../src/lib/public/toArray.ts | 5 + .../src/lib/public/typedObject.ts | 7 + .../instantsearch-core/src/lib/public/uniq.ts | 3 + .../instantsearch-core/src/lib/public/uuid.ts | 15 + .../src/lib/public/walkIndex.ts | 19 + .../src/lib/routers/history.ts | 371 +++++ .../src/lib/routers/index.ts | 3 + packages/instantsearch-core/src/lib/server.ts | 150 +++ .../src/lib/stateMappings/index.ts | 4 + .../src/lib/stateMappings/simple.ts | 45 + .../src/lib/stateMappings/singleIndex.ts | 26 + .../src/lib/utils/addWidgetId.ts | 1 + .../src/lib/utils/capitalize.ts | 1 + .../src/lib/utils/checkIndexUiState.ts | 1 + .../src/lib/utils/checkRendering.ts | 1 + .../src/lib/utils/clearRefinements.ts | 1 + .../src/lib/utils/concatHighlightedParts.ts | 1 + .../lib/utils/createConcurrentSafePromise.ts | 1 + .../src/lib/utils/createSendEventForFacet.ts | 1 + .../src/lib/utils/createSendEventForHits.ts | 1 + .../src/lib/utils/debounce.ts | 1 + .../instantsearch-core/src/lib/utils/defer.ts | 1 + .../src/lib/utils/documentation.ts | 1 + .../src/lib/utils/escape-highlight.ts | 1 + .../src/lib/utils/escape-html.ts | 1 + .../src/lib/utils/escapeFacetValue.ts | 1 + .../instantsearch-core/src/lib/utils/find.ts | 1 + .../src/lib/utils/findIndex.ts | 1 + .../instantsearch-core/src/lib/utils/flat.ts | 1 + .../src/lib/utils/geo-search.ts | 1 + .../src/lib/utils/getAlgoliaAgent.ts | 1 + .../src/lib/utils/getAppIdAndApiKey.ts | 1 + .../src/lib/utils/getHighlightFromSiblings.ts | 1 + .../src/lib/utils/getHighlightedParts.ts | 1 + .../src/lib/utils/getObjectType.ts | 1 + .../src/lib/utils/getPropertyByPath.ts | 1 + .../src/lib/utils/getRefinements.ts | 1 + .../src/lib/utils/getWidgetAttribute.ts | 1 + .../src/lib/utils/hits-absolute-position.ts | 1 + .../src/lib/utils/hits-query-id.ts | 1 + .../src/lib/utils/hydrateRecommendCache.ts | 1 + .../src/lib/utils/hydrateSearchClient.ts | 1 + .../instantsearch-core/src/lib/utils/index.ts | 1 + .../src/lib/utils/isEqual.ts | 1 + .../src/lib/utils/isFacetRefined.ts | 1 + .../src/lib/utils/isFiniteNumber.ts | 1 + .../src/lib/utils/isIndexWidget.ts | 1 + .../src/lib/utils/isPlainObject.ts | 1 + .../src/lib/utils/isSpecialClick.ts | 1 + .../src/lib/utils/isTwoPassWidget.ts | 1 + .../src/lib/utils/logger.ts | 1 + .../src/lib/utils/mergeSearchParameters.ts | 1 + .../instantsearch-core/src/lib/utils/noop.ts | 1 + .../instantsearch-core/src/lib/utils/omit.ts | 1 + .../instantsearch-core/src/lib/utils/range.ts | 1 + .../src/lib/utils/render-args.ts | 1 + .../src/lib/utils/resolveSearchParameters.ts | 1 + .../src/lib/utils/reverseHighlightedParts.ts | 1 + .../src/lib/utils/safelyRunOnBrowser.ts | 1 + .../src/lib/utils/sendChatMessageFeedback.ts | 1 + .../src/lib/utils/serializer.ts | 1 + .../src/lib/utils/setIndexHelperState.ts | 1 + .../src/lib/utils/toArray.ts | 1 + .../src/lib/utils/typedObject.ts | 1 + .../instantsearch-core/src/lib/utils/uniq.ts | 1 + .../instantsearch-core/src/lib/utils/uuid.ts | 1 + .../src/lib/utils/walkIndex.ts | 1 + .../src/lib/voiceSearchHelper/index.ts | 131 ++ .../src/lib/voiceSearchHelper/types.ts | 34 + .../middlewares/createInsightsMiddleware.ts | 487 +++++++ .../middlewares/createMetadataMiddleware.ts | 139 ++ .../src/middlewares/createRouterMiddleware.ts | 126 ++ .../src/middlewares/index.ts | 3 + .../src/types/algoliasearch.ts | 4 + .../instantsearch-core/src/types/connector.ts | 83 ++ .../instantsearch-core/src/types/index.ts | 13 + .../instantsearch-core/src/types/insights.ts | 63 + .../src/types/instantsearch.ts | 120 ++ .../src/types/middleware.ts | 42 + .../instantsearch-core/src/types/recommend.ts | 12 + .../src/types/render-state.ts | 73 + .../instantsearch-core/src/types/results.ts | 118 ++ .../instantsearch-core/src/types/router.ts | 75 ++ .../instantsearch-core/src/types/ui-state.ts | 44 + .../instantsearch-core/src/types/utils.ts | 26 + .../src/types/widget-factory.ts | 21 + .../instantsearch-core/src/types/widget.ts | 388 ++++++ packages/instantsearch-core/src/version.ts | 1 + .../src/widgets/analytics/analytics.ts | 25 + .../instantsearch-core/src/widgets/index.ts | 2 + .../src/widgets/index/index.ts | 1083 +++++++++++++++ .../src/widgets/places/places.ts | 18 + .../tsconfig.declaration.json | 3 + packages/instantsearch.js/package.json | 3 +- .../autocomplete/connectAutocomplete.ts | 320 +---- .../breadcrumb/connectBreadcrumb.ts | 361 +---- .../chat/__tests__/connectChat-test.ts | 15 +- .../src/connectors/chat/connectChat.ts | 768 +---------- .../connectClearRefinements.ts | 274 +--- .../connectors/configure/connectConfigure.ts | 205 +-- .../connectCurrentRefinements.ts | 432 +----- .../dynamic-widgets/connectDynamicWidgets.ts | 264 +--- .../src/connectors/feeds/FeedContainer.ts | 312 +---- .../src/connectors/feeds/connectFeeds.ts | 212 +-- .../connectFilterSuggestions.ts | 451 +------ .../connectFrequentlyBoughtTogether.ts | 245 +--- .../connectors/geo-search/connectGeoSearch.ts | 429 +----- .../connectHierarchicalMenu.ts | 532 +------- .../hits-per-page/connectHitsPerPage.ts | 313 +---- .../hits/__tests__/connectHits-test.ts | 2 +- .../__tests__/connectHitsWithInsights-test.ts | 2 +- .../src/connectors/hits/connectHits.ts | 238 +--- .../hits/connectHitsWithInsights.ts | 23 +- .../instantsearch.js/src/connectors/index.ts | 279 +++- .../__tests__/connectInfiniteHits-test.ts | 2 +- .../connectInfiniteHitsWithInsights-test.ts | 2 +- .../infinite-hits/connectInfiniteHits.ts | 512 +------ .../connectInfiniteHitsWithInsights.ts | 26 +- .../looking-similar/connectLookingSimilar.ts | 237 +--- .../src/connectors/menu/connectMenu.ts | 424 +----- .../numeric-menu/connectNumericMenu.ts | 509 +------ .../pagination/connectPagination.ts | 209 +-- .../connectors/powered-by/connectPoweredBy.ts | 113 +- .../query-rules/connectQueryRules.ts | 279 +--- .../src/connectors/range/connectRange.ts | 486 +------ .../rating-menu/connectRatingMenu.ts | 500 +------ .../refinement-list/connectRefinementList.ts | 593 +------- .../connectRelatedProducts.ts | 238 +--- .../relevant-sort/connectRelevantSort.ts | 144 +- .../connectors/search-box/connectSearchBox.ts | 178 +-- .../src/connectors/sort-by/connectSortBy.ts | 398 +----- .../src/connectors/stats/connectStats.ts | 148 +- .../connectToggleRefinement.ts | 507 +------ .../trending-facets/connectTrendingFacets.ts | 207 +-- .../trending-items/connectTrendingItems.ts | 255 +--- .../__tests__/connectVoiceSearch-test.ts | 4 +- .../voice-search/connectVoiceSearch.ts | 224 +-- .../instantsearch.js/src/lib/InstantSearch.ts | 22 +- .../instantsearch-core-interface.test.ts | 22 + .../src/lib/ai-lite/abstract-chat.ts | 1197 +---------------- .../instantsearch.js/src/lib/ai-lite/index.ts | 75 +- .../src/lib/ai-lite/stream-parser.ts | 149 +- .../src/lib/ai-lite/transport.ts | 252 +--- .../instantsearch.js/src/lib/ai-lite/types.ts | 513 +------ .../instantsearch.js/src/lib/ai-lite/utils.ts | 95 +- .../instantsearch.js/src/lib/chat/chat.ts | 167 +-- .../instantsearch.js/src/lib/chat/index.ts | 13 +- .../src/lib/infiniteHitsCache/index.ts | 2 +- .../lib/infiniteHitsCache/sessionStorage.ts | 76 +- .../src/lib/routers/history.ts | 373 +---- .../instantsearch.js/src/lib/routers/index.ts | 2 +- packages/instantsearch.js/src/lib/server.ts | 151 +-- .../src/lib/stateMappings/index.ts | 3 +- .../src/lib/stateMappings/simple.ts | 47 +- .../src/lib/stateMappings/singleIndex.ts | 28 +- .../__tests__/createSendEventForFacet-test.ts | 4 +- .../src/lib/utils/addWidgetId.ts | 16 +- .../src/lib/utils/capitalize.ts | 4 +- .../src/lib/utils/checkIndexUiState.ts | 199 +-- .../src/lib/utils/checkRendering.ts | 17 +- .../src/lib/utils/clearRefinements.ts | 44 +- .../src/lib/utils/concatHighlightedParts.ts | 16 +- .../lib/utils/createConcurrentSafePromise.ts | 47 +- .../src/lib/utils/createSendEventForFacet.ts | 68 +- .../src/lib/utils/createSendEventForHits.ts | 223 +-- .../src/lib/utils/debounce.ts | 31 +- .../instantsearch.js/src/lib/utils/defer.ts | 52 +- .../src/lib/utils/documentation.ts | 30 +- .../src/lib/utils/escape-highlight.ts | 80 +- .../src/lib/utils/escape-html.ts | 62 +- .../src/lib/utils/escapeFacetValue.ts | 22 +- .../instantsearch.js/src/lib/utils/find.ts | 22 +- .../src/lib/utils/findIndex.ts | 22 +- .../instantsearch.js/src/lib/utils/flat.ts | 4 +- .../src/lib/utils/geo-search.ts | 75 +- .../src/lib/utils/getAlgoliaAgent.ts | 11 +- .../src/lib/utils/getAppIdAndApiKey.ts | 24 +- .../src/lib/utils/getHighlightFromSiblings.ts | 21 +- .../src/lib/utils/getHighlightedParts.ts | 31 +- .../src/lib/utils/getObjectType.ts | 4 +- .../src/lib/utils/getPropertyByPath.ts | 9 +- .../src/lib/utils/getRefinements.ts | 218 +-- .../src/lib/utils/getWidgetAttribute.ts | 32 +- .../src/lib/utils/hits-absolute-position.ts | 13 +- .../src/lib/utils/hits-query-id.ts | 15 +- .../src/lib/utils/hydrateRecommendCache.ts | 21 +- .../src/lib/utils/hydrateSearchClient.ts | 191 +-- .../instantsearch.js/src/lib/utils/index.ts | 53 +- .../instantsearch.js/src/lib/utils/isEqual.ts | 37 +- .../src/lib/utils/isFacetRefined.ts | 16 +- .../src/lib/utils/isFiniteNumber.ts | 8 +- .../src/lib/utils/isIndexWidget.ts | 10 +- .../src/lib/utils/isPlainObject.ts | 41 +- .../src/lib/utils/isSpecialClick.ts | 12 +- .../src/lib/utils/isTwoPassWidget.ts | 12 +- .../instantsearch.js/src/lib/utils/logger.ts | 70 +- .../src/lib/utils/mergeSearchParameters.ts | 156 +-- .../instantsearch.js/src/lib/utils/noop.ts | 2 +- .../instantsearch.js/src/lib/utils/omit.ts | 27 +- .../instantsearch.js/src/lib/utils/range.ts | 22 +- .../src/lib/utils/render-args.ts | 80 +- .../src/lib/utils/resolveSearchParameters.ts | 17 +- .../src/lib/utils/reverseHighlightedParts.ts | 15 +- .../src/lib/utils/safelyRunOnBrowser.ts | 27 +- .../src/lib/utils/sendChatMessageFeedback.ts | 34 +- .../src/lib/utils/serializer.ts | 8 +- .../src/lib/utils/setIndexHelperState.ts | 30 +- .../instantsearch.js/src/lib/utils/toArray.ts | 6 +- .../src/lib/utils/typedObject.ts | 8 +- .../instantsearch.js/src/lib/utils/uniq.ts | 4 +- .../instantsearch.js/src/lib/utils/uuid.ts | 16 +- .../src/lib/utils/walkIndex.ts | 20 +- .../src/lib/voiceSearchHelper/index.ts | 133 +- .../src/lib/voiceSearchHelper/types.ts | 35 +- .../middlewares/createInsightsMiddleware.ts | 489 +------ .../middlewares/createMetadataMiddleware.ts | 141 +- .../src/middlewares/createRouterMiddleware.ts | 128 +- .../instantsearch.js/src/middlewares/index.ts | 18 +- .../src/types/algoliasearch.ts | 5 +- .../instantsearch.js/src/types/connector.ts | 84 +- packages/instantsearch.js/src/types/index.ts | 25 +- .../instantsearch.js/src/types/insights.ts | 64 +- .../instantsearch.js/src/types/middleware.ts | 43 +- .../instantsearch.js/src/types/recommend.ts | 13 +- .../src/types/render-state.ts | 74 +- .../instantsearch.js/src/types/results.ts | 119 +- packages/instantsearch.js/src/types/router.ts | 76 +- .../instantsearch.js/src/types/ui-state.ts | 45 +- packages/instantsearch.js/src/types/utils.ts | 27 +- .../src/types/widget-factory.ts | 22 +- packages/instantsearch.js/src/types/widget.ts | 389 +----- .../src/widgets/autocomplete/autocomplete.tsx | 2 +- .../instantsearch.js/src/widgets/index.ts | 1 + .../src/widgets/index/index.ts | 1085 +-------------- .../react-instantsearch-core/package.json | 3 +- .../src/components/DynamicWidgets.tsx | 2 +- .../src/components/Feeds.tsx | 6 +- .../src/components/InstantSearch.tsx | 2 +- .../components/InstantSearchSSRProvider.tsx | 2 +- .../components/InstantSearchServerContext.ts | 2 +- .../__tests__/DynamicWidgets.test.tsx | 2 +- .../__tests__/Feeds.integration.test.tsx | 2 +- .../src/components/__tests__/Feeds.test.tsx | 6 +- .../src/components/__tests__/Index.test.tsx | 2 +- .../__tests__/InstantSearch.test.tsx | 4 +- .../InstantSearchSSRProvider.test.tsx | 6 +- .../__tests__/routing/dispose-start.test.tsx | 2 +- .../routing/external-influence.test.tsx | 2 +- .../__tests__/routing/modal.test.tsx | 2 +- .../__tests__/routing/spa-debounced.test.tsx | 2 +- .../routing/spa-replace-state.test.tsx | 2 +- .../components/__tests__/routing/spa.test.tsx | 2 +- .../src/connectors/useAutocomplete.ts | 4 +- .../src/connectors/useBreadcrumb.ts | 4 +- .../src/connectors/useChat.ts | 6 +- .../src/connectors/useClearRefinements.ts | 4 +- .../src/connectors/useConfigure.ts | 4 +- .../src/connectors/useCurrentRefinements.ts | 4 +- .../src/connectors/useDynamicWidgets.ts | 4 +- .../src/connectors/useFeeds.ts | 4 +- .../src/connectors/useFilterSuggestions.ts | 4 +- .../connectors/useFrequentlyBoughtTogether.ts | 6 +- .../src/connectors/useGeoSearch.ts | 6 +- .../src/connectors/useHierarchicalMenu.ts | 4 +- .../src/connectors/useHits.ts | 6 +- .../src/connectors/useHitsPerPage.ts | 4 +- .../src/connectors/useInfiniteHits.ts | 6 +- .../src/connectors/useLookingSimilar.ts | 6 +- .../src/connectors/useMenu.ts | 4 +- .../src/connectors/useNumericMenu.ts | 4 +- .../src/connectors/usePagination.ts | 4 +- .../src/connectors/usePoweredBy.ts | 4 +- .../src/connectors/useQueryRules.ts | 4 +- .../src/connectors/useRange.ts | 4 +- .../src/connectors/useRefinementList.ts | 4 +- .../src/connectors/useRelatedProducts.ts | 6 +- .../src/connectors/useSearchBox.ts | 4 +- .../src/connectors/useSortBy.ts | 4 +- .../src/connectors/useStats.ts | 4 +- .../src/connectors/useToggleRefinement.ts | 4 +- .../src/connectors/useTrendingFacets.ts | 4 +- .../src/connectors/useTrendingItems.ts | 6 +- .../src/hooks/__tests__/useConnector.test.tsx | 6 +- .../src/hooks/useConnector.ts | 2 +- .../src/hooks/useInstantSearch.ts | 2 +- .../src/lib/IndexContext.ts | 2 +- .../src/lib/InstantSearchContext.ts | 2 +- .../src/lib/InstantSearchSSRContext.ts | 2 +- .../src/lib/getIndexSearchResults.ts | 2 +- .../src/lib/useAppIdAndApiKey.ts | 2 +- .../src/lib/useIndex.ts | 4 +- .../src/lib/useIndexContext.ts | 2 +- .../src/lib/useInstantSearchApi.ts | 14 +- .../src/lib/useInstantSearchContext.ts | 2 +- .../src/lib/useInstantSearchSSRContext.ts | 2 +- .../src/lib/useInstantSearchServerContext.ts | 2 +- .../src/lib/useSearchResults.ts | 4 +- .../src/lib/useSearchState.ts | 2 +- .../src/lib/useWidget.ts | 6 +- .../server/__tests__/getServerState.test.tsx | 2 +- .../src/server/getServerState.tsx | 6 +- .../react-instantsearch-nextjs/package.json | 3 +- .../src/InitializePromise.ts | 6 +- .../src/InstantSearchNext.tsx | 6 +- .../InitializePromise-composition.test.tsx | 4 +- .../src/__tests__/InitializePromise.test.tsx | 6 +- .../src/__tests__/InstantSearchNext.test.tsx | 2 +- .../src/createInsertHTML.tsx | 2 +- .../src/useInstantSearchRouting.ts | 6 +- .../package.json | 3 +- .../src/index.ts | 6 +- packages/react-instantsearch/package.json | 3 +- .../src/__tests__/common-connectors.test.tsx | 4 +- .../src/__tests__/common-widgets.test.tsx | 4 +- .../src/ui/CurrentRefinements.tsx | 2 +- .../src/ui/HitsPerPage.tsx | 2 +- .../src/ui/InfiniteHits.tsx | 4 +- packages/react-instantsearch/src/ui/Menu.tsx | 4 +- .../react-instantsearch/src/ui/Pagination.tsx | 2 +- .../src/ui/RefinementList.tsx | 4 +- .../src/ui/__tests__/InfiniteHits.test.tsx | 2 +- .../src/widgets/Autocomplete.tsx | 8 +- .../react-instantsearch/src/widgets/Chat.tsx | 6 +- .../src/widgets/FrequentlyBoughtTogether.tsx | 2 +- .../src/widgets/Highlight.tsx | 4 +- .../react-instantsearch/src/widgets/Hits.tsx | 4 +- .../src/widgets/InfiniteHits.tsx | 2 +- .../src/widgets/LookingSimilar.tsx | 2 +- .../src/widgets/RefinementList.tsx | 4 +- .../src/widgets/RelatedProducts.tsx | 2 +- .../src/widgets/ReverseHighlight.tsx | 4 +- .../src/widgets/SearchBox.tsx | 2 +- .../src/widgets/Snippet.tsx | 4 +- .../src/widgets/TrendingItems.tsx | 2 +- .../src/widgets/__tests__/Hits.test.tsx | 2 +- .../widgets/__tests__/InfiniteHits.test.tsx | 2 +- .../src/widgets/__tests__/Pagination.test.tsx | 2 +- .../src/widgets/__tests__/SearchBox.test.tsx | 2 +- .../__tests__/__utils__/all-widgets.tsx | 2 +- .../widgets/chat/tools/SearchIndexTool.tsx | 2 +- packages/vue-instantsearch/package.json | 3 +- packages/vue-instantsearch/rollup.config.mjs | 1 + .../src/__tests__/common-connectors.test.js | 2 +- .../src/__tests__/common-shared.test.js | 2 +- .../src/components/Autocomplete.vue | 2 +- .../src/components/Breadcrumb.vue | 2 +- .../src/components/ClearRefinements.vue | 2 +- .../src/components/Configure.js | 2 +- .../src/components/CurrentRefinements.vue | 2 +- .../src/components/DynamicWidgets.js | 2 +- .../vue-instantsearch/src/components/Feeds.js | 4 +- .../src/components/HierarchicalMenu.vue | 2 +- .../src/components/Highlighter.js | 2 +- .../vue-instantsearch/src/components/Hits.js | 2 +- .../src/components/HitsPerPage.vue | 2 +- .../vue-instantsearch/src/components/Index.js | 2 +- .../src/components/InfiniteHits.vue | 2 +- .../vue-instantsearch/src/components/Menu.vue | 2 +- .../src/components/MenuSelect.vue | 2 +- .../src/components/NumericMenu.vue | 2 +- .../src/components/Pagination.vue | 2 +- .../src/components/QueryRuleContext.js | 2 +- .../src/components/QueryRuleCustomData.vue | 2 +- .../src/components/RangeInput.vue | 2 +- .../src/components/RatingMenu.vue | 2 +- .../src/components/RefinementList.vue | 2 +- .../src/components/RelevantSort.vue | 2 +- .../src/components/SearchBox.vue | 2 +- .../src/components/SortBy.vue | 2 +- .../src/components/Stats.vue | 2 +- .../src/components/ToggleRefinement.vue | 2 +- .../src/components/VoiceSearch.vue | 2 +- .../src/components/__Template.vue | 2 +- .../src/components/__tests__/Feeds.js | 2 +- .../src/util/createInstantSearchComponent.js | 2 +- .../src/util/createServerRootMixin.js | 2 +- .../src/util/parseAlgoliaHit.js | 2 +- .../stories/InfiniteHits.stories.js | 4 +- 493 files changed, 21578 insertions(+), 20193 deletions(-) create mode 100644 packages/instantsearch-core/README.md create mode 100644 packages/instantsearch-core/package.json create mode 100644 packages/instantsearch-core/rollup.config.mjs create mode 100755 packages/instantsearch-core/scripts/version.cjs create mode 100644 packages/instantsearch-core/src/connectors/answers/connectAnswers.ts create mode 100644 packages/instantsearch-core/src/connectors/autocomplete/connectAutocomplete.ts create mode 100644 packages/instantsearch-core/src/connectors/breadcrumb/connectBreadcrumb.ts create mode 100644 packages/instantsearch-core/src/connectors/chat/connectChat.ts create mode 100644 packages/instantsearch-core/src/connectors/clear-refinements/connectClearRefinements.ts create mode 100644 packages/instantsearch-core/src/connectors/configure/connectConfigure.ts create mode 100644 packages/instantsearch-core/src/connectors/current-refinements/connectCurrentRefinements.ts create mode 100644 packages/instantsearch-core/src/connectors/dynamic-widgets/connectDynamicWidgets.ts create mode 100644 packages/instantsearch-core/src/connectors/feeds/FeedContainer.ts create mode 100644 packages/instantsearch-core/src/connectors/feeds/connectFeeds.ts create mode 100644 packages/instantsearch-core/src/connectors/filter-suggestions/connectFilterSuggestions.ts create mode 100644 packages/instantsearch-core/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts create mode 100644 packages/instantsearch-core/src/connectors/geo-search/connectGeoSearch.ts create mode 100644 packages/instantsearch-core/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts create mode 100644 packages/instantsearch-core/src/connectors/hits-per-page/connectHitsPerPage.ts create mode 100644 packages/instantsearch-core/src/connectors/hits/connectHits.ts create mode 100644 packages/instantsearch-core/src/connectors/hits/connectHitsWithInsights.ts create mode 100644 packages/instantsearch-core/src/connectors/index.ts create mode 100644 packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHits.ts create mode 100644 packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts create mode 100644 packages/instantsearch-core/src/connectors/looking-similar/connectLookingSimilar.ts create mode 100644 packages/instantsearch-core/src/connectors/menu/connectMenu.ts create mode 100644 packages/instantsearch-core/src/connectors/numeric-menu/connectNumericMenu.ts create mode 100644 packages/instantsearch-core/src/connectors/pagination/Paginator.ts create mode 100644 packages/instantsearch-core/src/connectors/pagination/connectPagination.ts create mode 100644 packages/instantsearch-core/src/connectors/powered-by/connectPoweredBy.ts create mode 100644 packages/instantsearch-core/src/connectors/query-rules/connectQueryRules.ts create mode 100644 packages/instantsearch-core/src/connectors/range/connectRange.ts create mode 100644 packages/instantsearch-core/src/connectors/rating-menu/connectRatingMenu.ts create mode 100644 packages/instantsearch-core/src/connectors/refinement-list/connectRefinementList.ts create mode 100644 packages/instantsearch-core/src/connectors/related-products/connectRelatedProducts.ts create mode 100644 packages/instantsearch-core/src/connectors/relevant-sort/connectRelevantSort.ts create mode 100644 packages/instantsearch-core/src/connectors/search-box/connectSearchBox.ts create mode 100644 packages/instantsearch-core/src/connectors/sort-by/connectSortBy.ts create mode 100644 packages/instantsearch-core/src/connectors/stats/connectStats.ts create mode 100644 packages/instantsearch-core/src/connectors/toggle-refinement/connectToggleRefinement.ts create mode 100644 packages/instantsearch-core/src/connectors/toggle-refinement/types.ts create mode 100644 packages/instantsearch-core/src/connectors/trending-facets/connectTrendingFacets.ts create mode 100644 packages/instantsearch-core/src/connectors/trending-items/connectTrendingItems.ts create mode 100644 packages/instantsearch-core/src/connectors/voice-search/connectVoiceSearch.ts create mode 100644 packages/instantsearch-core/src/helpers/get-insights-anonymous-user-token.ts create mode 100644 packages/instantsearch-core/src/helpers/index.ts create mode 100644 packages/instantsearch-core/src/index.ts create mode 100644 packages/instantsearch-core/src/lib/ai-lite/abstract-chat.ts create mode 100644 packages/instantsearch-core/src/lib/ai-lite/index.ts create mode 100644 packages/instantsearch-core/src/lib/ai-lite/stream-parser.ts create mode 100644 packages/instantsearch-core/src/lib/ai-lite/transport.ts create mode 100644 packages/instantsearch-core/src/lib/ai-lite/types.ts create mode 100644 packages/instantsearch-core/src/lib/ai-lite/utils.ts create mode 100644 packages/instantsearch-core/src/lib/chat/chat.ts create mode 100644 packages/instantsearch-core/src/lib/chat/index.ts create mode 100644 packages/instantsearch-core/src/lib/infiniteHitsCache/index.ts create mode 100644 packages/instantsearch-core/src/lib/infiniteHitsCache/sessionStorage.ts create mode 100644 packages/instantsearch-core/src/lib/insights/client.ts create mode 100644 packages/instantsearch-core/src/lib/insights/index.ts create mode 100644 packages/instantsearch-core/src/lib/public/addWidgetId.ts create mode 100644 packages/instantsearch-core/src/lib/public/capitalize.ts create mode 100644 packages/instantsearch-core/src/lib/public/checkIndexUiState.ts create mode 100644 packages/instantsearch-core/src/lib/public/checkRendering.ts create mode 100644 packages/instantsearch-core/src/lib/public/clearRefinements.ts create mode 100644 packages/instantsearch-core/src/lib/public/concatHighlightedParts.ts create mode 100644 packages/instantsearch-core/src/lib/public/createConcurrentSafePromise.ts create mode 100644 packages/instantsearch-core/src/lib/public/createSendEventForFacet.ts create mode 100644 packages/instantsearch-core/src/lib/public/createSendEventForHits.ts create mode 100644 packages/instantsearch-core/src/lib/public/debounce.ts create mode 100644 packages/instantsearch-core/src/lib/public/defer.ts create mode 100644 packages/instantsearch-core/src/lib/public/documentation.ts create mode 100644 packages/instantsearch-core/src/lib/public/escape-highlight.ts create mode 100644 packages/instantsearch-core/src/lib/public/escape-html.ts create mode 100644 packages/instantsearch-core/src/lib/public/escapeFacetValue.ts create mode 100644 packages/instantsearch-core/src/lib/public/find.ts create mode 100644 packages/instantsearch-core/src/lib/public/findIndex.ts create mode 100644 packages/instantsearch-core/src/lib/public/flat.ts create mode 100644 packages/instantsearch-core/src/lib/public/geo-search.ts create mode 100644 packages/instantsearch-core/src/lib/public/getAlgoliaAgent.ts create mode 100644 packages/instantsearch-core/src/lib/public/getAppIdAndApiKey.ts create mode 100644 packages/instantsearch-core/src/lib/public/getHighlightFromSiblings.ts create mode 100644 packages/instantsearch-core/src/lib/public/getHighlightedParts.ts create mode 100644 packages/instantsearch-core/src/lib/public/getObjectType.ts create mode 100644 packages/instantsearch-core/src/lib/public/getPropertyByPath.ts create mode 100644 packages/instantsearch-core/src/lib/public/getRefinements.ts create mode 100644 packages/instantsearch-core/src/lib/public/getWidgetAttribute.ts create mode 100644 packages/instantsearch-core/src/lib/public/hits-absolute-position.ts create mode 100644 packages/instantsearch-core/src/lib/public/hits-query-id.ts create mode 100644 packages/instantsearch-core/src/lib/public/hydrateRecommendCache.ts create mode 100644 packages/instantsearch-core/src/lib/public/hydrateSearchClient.ts create mode 100644 packages/instantsearch-core/src/lib/public/index.ts create mode 100644 packages/instantsearch-core/src/lib/public/isEqual.ts create mode 100644 packages/instantsearch-core/src/lib/public/isFacetRefined.ts create mode 100644 packages/instantsearch-core/src/lib/public/isFiniteNumber.ts create mode 100644 packages/instantsearch-core/src/lib/public/isIndexWidget.ts create mode 100644 packages/instantsearch-core/src/lib/public/isPlainObject.ts create mode 100644 packages/instantsearch-core/src/lib/public/isSpecialClick.ts create mode 100644 packages/instantsearch-core/src/lib/public/isTwoPassWidget.ts create mode 100644 packages/instantsearch-core/src/lib/public/logger.ts create mode 100644 packages/instantsearch-core/src/lib/public/mergeSearchParameters.ts create mode 100644 packages/instantsearch-core/src/lib/public/noop.ts create mode 100644 packages/instantsearch-core/src/lib/public/omit.ts create mode 100644 packages/instantsearch-core/src/lib/public/range.ts create mode 100644 packages/instantsearch-core/src/lib/public/render-args.ts create mode 100644 packages/instantsearch-core/src/lib/public/resolveSearchParameters.ts create mode 100644 packages/instantsearch-core/src/lib/public/reverseHighlightedParts.ts create mode 100644 packages/instantsearch-core/src/lib/public/safelyRunOnBrowser.ts create mode 100644 packages/instantsearch-core/src/lib/public/sendChatMessageFeedback.ts create mode 100644 packages/instantsearch-core/src/lib/public/serializer.ts create mode 100644 packages/instantsearch-core/src/lib/public/setIndexHelperState.ts create mode 100644 packages/instantsearch-core/src/lib/public/toArray.ts create mode 100644 packages/instantsearch-core/src/lib/public/typedObject.ts create mode 100644 packages/instantsearch-core/src/lib/public/uniq.ts create mode 100644 packages/instantsearch-core/src/lib/public/uuid.ts create mode 100644 packages/instantsearch-core/src/lib/public/walkIndex.ts create mode 100644 packages/instantsearch-core/src/lib/routers/history.ts create mode 100644 packages/instantsearch-core/src/lib/routers/index.ts create mode 100644 packages/instantsearch-core/src/lib/server.ts create mode 100644 packages/instantsearch-core/src/lib/stateMappings/index.ts create mode 100644 packages/instantsearch-core/src/lib/stateMappings/simple.ts create mode 100644 packages/instantsearch-core/src/lib/stateMappings/singleIndex.ts create mode 100644 packages/instantsearch-core/src/lib/utils/addWidgetId.ts create mode 100644 packages/instantsearch-core/src/lib/utils/capitalize.ts create mode 100644 packages/instantsearch-core/src/lib/utils/checkIndexUiState.ts create mode 100644 packages/instantsearch-core/src/lib/utils/checkRendering.ts create mode 100644 packages/instantsearch-core/src/lib/utils/clearRefinements.ts create mode 100644 packages/instantsearch-core/src/lib/utils/concatHighlightedParts.ts create mode 100644 packages/instantsearch-core/src/lib/utils/createConcurrentSafePromise.ts create mode 100644 packages/instantsearch-core/src/lib/utils/createSendEventForFacet.ts create mode 100644 packages/instantsearch-core/src/lib/utils/createSendEventForHits.ts create mode 100644 packages/instantsearch-core/src/lib/utils/debounce.ts create mode 100644 packages/instantsearch-core/src/lib/utils/defer.ts create mode 100644 packages/instantsearch-core/src/lib/utils/documentation.ts create mode 100644 packages/instantsearch-core/src/lib/utils/escape-highlight.ts create mode 100644 packages/instantsearch-core/src/lib/utils/escape-html.ts create mode 100644 packages/instantsearch-core/src/lib/utils/escapeFacetValue.ts create mode 100644 packages/instantsearch-core/src/lib/utils/find.ts create mode 100644 packages/instantsearch-core/src/lib/utils/findIndex.ts create mode 100644 packages/instantsearch-core/src/lib/utils/flat.ts create mode 100644 packages/instantsearch-core/src/lib/utils/geo-search.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getAlgoliaAgent.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getAppIdAndApiKey.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getHighlightFromSiblings.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getHighlightedParts.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getObjectType.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getPropertyByPath.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getRefinements.ts create mode 100644 packages/instantsearch-core/src/lib/utils/getWidgetAttribute.ts create mode 100644 packages/instantsearch-core/src/lib/utils/hits-absolute-position.ts create mode 100644 packages/instantsearch-core/src/lib/utils/hits-query-id.ts create mode 100644 packages/instantsearch-core/src/lib/utils/hydrateRecommendCache.ts create mode 100644 packages/instantsearch-core/src/lib/utils/hydrateSearchClient.ts create mode 100644 packages/instantsearch-core/src/lib/utils/index.ts create mode 100644 packages/instantsearch-core/src/lib/utils/isEqual.ts create mode 100644 packages/instantsearch-core/src/lib/utils/isFacetRefined.ts create mode 100644 packages/instantsearch-core/src/lib/utils/isFiniteNumber.ts create mode 100644 packages/instantsearch-core/src/lib/utils/isIndexWidget.ts create mode 100644 packages/instantsearch-core/src/lib/utils/isPlainObject.ts create mode 100644 packages/instantsearch-core/src/lib/utils/isSpecialClick.ts create mode 100644 packages/instantsearch-core/src/lib/utils/isTwoPassWidget.ts create mode 100644 packages/instantsearch-core/src/lib/utils/logger.ts create mode 100644 packages/instantsearch-core/src/lib/utils/mergeSearchParameters.ts create mode 100644 packages/instantsearch-core/src/lib/utils/noop.ts create mode 100644 packages/instantsearch-core/src/lib/utils/omit.ts create mode 100644 packages/instantsearch-core/src/lib/utils/range.ts create mode 100644 packages/instantsearch-core/src/lib/utils/render-args.ts create mode 100644 packages/instantsearch-core/src/lib/utils/resolveSearchParameters.ts create mode 100644 packages/instantsearch-core/src/lib/utils/reverseHighlightedParts.ts create mode 100644 packages/instantsearch-core/src/lib/utils/safelyRunOnBrowser.ts create mode 100644 packages/instantsearch-core/src/lib/utils/sendChatMessageFeedback.ts create mode 100644 packages/instantsearch-core/src/lib/utils/serializer.ts create mode 100644 packages/instantsearch-core/src/lib/utils/setIndexHelperState.ts create mode 100644 packages/instantsearch-core/src/lib/utils/toArray.ts create mode 100644 packages/instantsearch-core/src/lib/utils/typedObject.ts create mode 100644 packages/instantsearch-core/src/lib/utils/uniq.ts create mode 100644 packages/instantsearch-core/src/lib/utils/uuid.ts create mode 100644 packages/instantsearch-core/src/lib/utils/walkIndex.ts create mode 100644 packages/instantsearch-core/src/lib/voiceSearchHelper/index.ts create mode 100644 packages/instantsearch-core/src/lib/voiceSearchHelper/types.ts create mode 100644 packages/instantsearch-core/src/middlewares/createInsightsMiddleware.ts create mode 100644 packages/instantsearch-core/src/middlewares/createMetadataMiddleware.ts create mode 100644 packages/instantsearch-core/src/middlewares/createRouterMiddleware.ts create mode 100644 packages/instantsearch-core/src/middlewares/index.ts create mode 100644 packages/instantsearch-core/src/types/algoliasearch.ts create mode 100644 packages/instantsearch-core/src/types/connector.ts create mode 100644 packages/instantsearch-core/src/types/index.ts create mode 100644 packages/instantsearch-core/src/types/insights.ts create mode 100644 packages/instantsearch-core/src/types/instantsearch.ts create mode 100644 packages/instantsearch-core/src/types/middleware.ts create mode 100644 packages/instantsearch-core/src/types/recommend.ts create mode 100644 packages/instantsearch-core/src/types/render-state.ts create mode 100644 packages/instantsearch-core/src/types/results.ts create mode 100644 packages/instantsearch-core/src/types/router.ts create mode 100644 packages/instantsearch-core/src/types/ui-state.ts create mode 100644 packages/instantsearch-core/src/types/utils.ts create mode 100644 packages/instantsearch-core/src/types/widget-factory.ts create mode 100644 packages/instantsearch-core/src/types/widget.ts create mode 100644 packages/instantsearch-core/src/version.ts create mode 100644 packages/instantsearch-core/src/widgets/analytics/analytics.ts create mode 100644 packages/instantsearch-core/src/widgets/index.ts create mode 100644 packages/instantsearch-core/src/widgets/index/index.ts create mode 100644 packages/instantsearch-core/src/widgets/places/places.ts create mode 100644 packages/instantsearch-core/tsconfig.declaration.json create mode 100644 packages/instantsearch.js/src/lib/__tests__/instantsearch-core-interface.test.ts diff --git a/jest.config.js b/jest.config.js index 85bcdae6c9f..31ad1cad669 100644 --- a/jest.config.js +++ b/jest.config.js @@ -54,6 +54,9 @@ const config = { '^instantsearch.js$': '/packages/instantsearch.js/src/', '^instantsearch.js/es(.*)$': '/packages/instantsearch.js/src$1', '^instantsearch.js/(.*)$': '/packages/instantsearch.js/$1', + '^instantsearch-core$': '/packages/instantsearch-core/src/', + '^instantsearch-core/dist/es(.*)$': + '/packages/instantsearch-core/src$1', '^instantsearch-ui-components$': '/packages/instantsearch-ui-components/src/', '^instantsearch-ui-components/(.*)$': diff --git a/packages/algolia-experiences/package.json b/packages/algolia-experiences/package.json index 5a6b1062a4f..7294f75c248 100644 --- a/packages/algolia-experiences/package.json +++ b/packages/algolia-experiences/package.json @@ -10,7 +10,8 @@ ], "dependencies": { "algoliasearch": "5.1.1", - "instantsearch.js": "4.96.2" + "instantsearch.js": "4.96.2", + "instantsearch-core": "0.1.0" }, "devDependencies": { "@instantsearch/testutils": "1.90.1" diff --git a/packages/algolia-experiences/src/banner.tsx b/packages/algolia-experiences/src/banner.tsx index e38ec6904e0..108d0f38a4b 100644 --- a/packages/algolia-experiences/src/banner.tsx +++ b/packages/algolia-experiences/src/banner.tsx @@ -8,7 +8,7 @@ import type { ComponentProps, HitsClassNames, } from 'instantsearch-ui-components'; -import type { HitsConnectorParams } from 'instantsearch.js/es/connectors/hits/connectHits'; +import type { HitsConnectorParams } from 'instantsearch-core'; export type BannerWidgetParams = { container: HTMLElement; diff --git a/packages/algolia-experiences/src/render.tsx b/packages/algolia-experiences/src/render.tsx index 166f10e5054..df28ea20701 100644 --- a/packages/algolia-experiences/src/render.tsx +++ b/packages/algolia-experiences/src/render.tsx @@ -1,5 +1,5 @@ /** @jsx h */ -import { getPropertyByPath } from 'instantsearch.js/es/lib/utils'; +import { getPropertyByPath } from 'instantsearch-core'; import { carousel } from 'instantsearch.js/es/templates'; import { index, panel } from 'instantsearch.js/es/widgets'; import { h, Fragment } from 'preact'; @@ -17,7 +17,7 @@ import type { TemplateText, TemplateWidgetTypes, } from './types'; -import type { Widget } from 'instantsearch.js'; +import type { Widget } from 'instantsearch-core'; import type { ComponentChildren, JSX } from 'preact'; export function injectStyles() { diff --git a/packages/algolia-experiences/src/setup-instantsearch.ts b/packages/algolia-experiences/src/setup-instantsearch.ts index 2798ba98c56..ea08f2bb092 100644 --- a/packages/algolia-experiences/src/setup-instantsearch.ts +++ b/packages/algolia-experiences/src/setup-instantsearch.ts @@ -9,7 +9,7 @@ import { configToIndex, injectStyles } from './render'; import { error } from './util'; import type { Settings } from './get-information'; -import type { IndexWidget } from 'instantsearch.js'; +import type { IndexWidget } from 'instantsearch-core'; declare global { interface Window { diff --git a/packages/instantsearch-core/README.md b/packages/instantsearch-core/README.md new file mode 100644 index 00000000000..e19a0d78a5d --- /dev/null +++ b/packages/instantsearch-core/README.md @@ -0,0 +1,3 @@ +# instantsearch-core + +Internal shared package for InstantSearch flavors. This package is published for dependency resolution and type resolution, but it is not intended for direct end-user use. diff --git a/packages/instantsearch-core/package.json b/packages/instantsearch-core/package.json new file mode 100644 index 00000000000..e38ff5cb7c2 --- /dev/null +++ b/packages/instantsearch-core/package.json @@ -0,0 +1,52 @@ +{ + "name": "instantsearch-core", + "version": "0.1.0", + "description": "Internal shared package for InstantSearch flavors. Not intended for direct use.", + "types": "dist/es/index.d.ts", + "main": "dist/cjs/index.js", + "module": "dist/es/index.js", + "type": "module", + "exports": { + ".": { + "types": "./dist/es/index.d.ts", + "import": "./dist/es/index.js", + "require": "./dist/cjs/index.js" + } + }, + "sideEffects": false, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/algolia/instantsearch" + }, + "author": { + "name": "Algolia, Inc.", + "url": "https://www.algolia.com" + }, + "keywords": [ + "algolia", + "instantsearch", + "search" + ], + "files": [ + "README.md", + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "BABEL_ENV=es,rollup BUILD_FORMAT=esm rollup -c rollup.config.mjs && BABEL_ENV=rollup BUILD_FORMAT=cjs rollup -c rollup.config.mjs && yarn build:types", + "build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/es", + "version": "./scripts/version.cjs", + "prepare": "yarn build", + "watch:es": "BABEL_ENV=es,rollup BUILD_FORMAT=esm rollup -c rollup.config.mjs --watch" + }, + "dependencies": { + "@algolia/events": "^4.0.1", + "@swc/helpers": "0.5.18", + "@types/dom-speech-recognition": "^0.0.1", + "@types/qs": "^6.5.3", + "algoliasearch-helper": "3.29.1", + "qs": "^6.5.1", + "search-insights": "^2.17.2" + } +} diff --git a/packages/instantsearch-core/rollup.config.mjs b/packages/instantsearch-core/rollup.config.mjs new file mode 100644 index 00000000000..93fd8068f9b --- /dev/null +++ b/packages/instantsearch-core/rollup.config.mjs @@ -0,0 +1,36 @@ +import { + createESMConfig, + createCJSConfig, + collectSourceEntries, +} from '../../scripts/build/rollup.base.mjs'; +import pkg from './package.json' with { type: 'json' }; + +const moduleInput = collectSourceEntries(); +const isESM = process.env.BUILD_FORMAT === 'esm'; +const isCJS = process.env.BUILD_FORMAT === 'cjs'; + +const configs = []; + +if (isESM || (!isESM && !isCJS)) { + configs.push( + createESMConfig({ + input: moduleInput, + pkg, + outputDir: 'dist/es', + preserveModules: true, + }) + ); +} + +if (isCJS || (!isESM && !isCJS)) { + configs.push( + createCJSConfig({ + input: moduleInput, + pkg, + outputDir: 'dist/cjs', + preserveModules: true, + }) + ); +} + +export default configs; diff --git a/packages/instantsearch-core/scripts/version.cjs b/packages/instantsearch-core/scripts/version.cjs new file mode 100755 index 00000000000..a7399a770ff --- /dev/null +++ b/packages/instantsearch-core/scripts/version.cjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const version = require('../package.json').version; + +fs.writeFileSync( + path.resolve(__dirname, '../src/version.ts'), + `export default '${version}';\n` +); diff --git a/packages/instantsearch-core/src/connectors/answers/connectAnswers.ts b/packages/instantsearch-core/src/connectors/answers/connectAnswers.ts new file mode 100644 index 00000000000..47bdadef0b8 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/answers/connectAnswers.ts @@ -0,0 +1,267 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + createConcurrentSafePromise, + addQueryID, + debounce, + addAbsolutePosition, + noop, + escapeHits, +} from '../../lib/utils'; + +import type { DebouncedFunction } from '../../lib/utils'; +import type { + Connector, + Hit, + FindAnswersOptions, + FindAnswersResponse, + WidgetRenderState, + FindAnswers, +} from '../../types'; + +type IndexWithAnswers = { + readonly findAnswers: any; +}; + +function hasFindAnswersMethod( + answersIndex: IndexWithAnswers | any +): answersIndex is IndexWithAnswers { + return typeof (answersIndex as IndexWithAnswers).findAnswers === 'function'; +} + +const withUsage = createDocumentationMessageGenerator({ + name: 'answers', + connector: true, +}); + +export type AnswersRenderState = { + /** + * The matched hits from Algolia API. + */ + hits: Hit[]; + + /** + * Whether it's still loading the results from the Answers API. + */ + isLoading: boolean; +}; + +export type AnswersConnectorParams = { + /** + * Attributes to use for predictions. + * If empty, we use all `searchableAttributes` to find answers. + * All your `attributesForPrediction` must be part of your `searchableAttributes`. + */ + attributesForPrediction?: string[]; + + /** + * The languages in the query. Currently only supports `en`. + */ + queryLanguages: ['en']; + + /** + * Maximum number of answers to retrieve from the Answers Engine. + * Cannot be greater than 1000. + * @default 1 + */ + nbHits?: number; + + /** + * Debounce time in milliseconds to debounce render + * @default 100 + */ + renderDebounceTime?: number; + + /** + * Debounce time in milliseconds to debounce search + * @default 100 + */ + searchDebounceTime?: number; + + /** + * Whether to escape HTML tags from hits string values. + * + * @default true + */ + escapeHTML?: boolean; + + /** + * Extra parameters to pass to findAnswers method. + * @default {} + */ + extraParameters?: FindAnswersOptions; +}; + +export type AnswersWidgetDescription = { + $$type: 'ais.answers'; + renderState: AnswersRenderState; + indexRenderState: { + answers: WidgetRenderState; + }; +}; + +export type AnswersConnector = Connector< + AnswersWidgetDescription, + AnswersConnectorParams +>; + +/** + * @deprecated the answers service is no longer offered, and this widget will be removed in InstantSearch.js v5 + */ +const connectAnswers: AnswersConnector = function connectAnswers( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + queryLanguages, + attributesForPrediction, + nbHits = 1, + renderDebounceTime = 100, + searchDebounceTime = 100, + // @MAJOR: this can default to false + escapeHTML = true, + extraParameters = {}, + } = widgetParams || {}; + + // @ts-expect-error checking for the wrong value + if (!queryLanguages || queryLanguages.length === 0) { + throw new Error( + withUsage('The `queryLanguages` expects an array of strings.') + ); + } + + const runConcurrentSafePromise = + createConcurrentSafePromise>(); + + let lastHits: FindAnswersResponse['hits'] = []; + let isLoading = false; + const debouncedRender = debounce(renderFn, renderDebounceTime); + + let debouncedRefine: DebouncedFunction; + + return { + $$type: 'ais.answers', + + init(initOptions) { + const { state, instantSearchInstance } = initOptions; + if (typeof instantSearchInstance.client.initIndex !== 'function') { + throw new Error(withUsage('`algoliasearch` <5 required.')); + } + const answersIndex = (instantSearchInstance.client.initIndex as any)( + state.index + ); + if (!hasFindAnswersMethod(answersIndex)) { + throw new Error(withUsage('`algoliasearch` >= 4.8.0 required.')); + } + debouncedRefine = debounce( + answersIndex.findAnswers as unknown as FindAnswers, + searchDebounceTime + ); + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const query = renderOptions.state.query; + if (!query) { + // renders nothing with empty query + lastHits = []; + isLoading = false; + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + return; + } + + // render the loader + lastHits = []; + isLoading = true; + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + + // call /answers API + runConcurrentSafePromise( + debouncedRefine(query, queryLanguages, { + ...extraParameters, + nbHits, + attributesForPrediction, + }) as unknown as Promise> + ).then((result) => { + if (!result) { + // It's undefined when it's debounced. + return; + } + + if (escapeHTML && result.hits.length > 0) { + result.hits = escapeHits(result.hits); + } + + const hitsWithAbsolutePosition = addAbsolutePosition( + result.hits, + 0, + nbHits + ); + + const hitsWithAbsolutePositionAndQueryID = addQueryID( + hitsWithAbsolutePosition, + result.queryID + ); + + lastHits = hitsWithAbsolutePositionAndQueryID; + isLoading = false; + debouncedRender( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + answers: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState() { + return { + hits: lastHits, + isLoading, + widgetParams, + }; + }, + + dispose({ state }) { + unmountFn(); + return state; + }, + + getWidgetSearchParameters(state) { + return state; + }, + }; + }; +}; + +export default connectAnswers; diff --git a/packages/instantsearch-core/src/connectors/autocomplete/connectAutocomplete.ts b/packages/instantsearch-core/src/connectors/autocomplete/connectAutocomplete.ts new file mode 100644 index 00000000000..5af906bc766 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/autocomplete/connectAutocomplete.ts @@ -0,0 +1,318 @@ +import { + addAbsolutePosition, + addQueryID, + escapeHits, + TAG_PLACEHOLDER, + checkRendering, + createDocumentationMessageGenerator, + createSendEventForHits, + noop, + warning, +} from '../../lib/utils'; + +import type { SendEventForHits } from '../../lib/utils'; +import type { Hit, Connector, WidgetRenderState } from '../../types'; +import type { SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'autocomplete', + connector: true, +}); + +export type TransformItemsIndicesConfig = { + indexName: string; + indexId: string; + hits: Hit[]; + results: SearchResults; +}; + +export type AutocompleteConnectorParams = { + /** + * Escapes HTML entities from hits string values. + * + * @default `true` + */ + escapeHTML?: boolean; + /** + * Transforms the items of all indices. + */ + transformItems?: ( + indices: TransformItemsIndicesConfig[] + ) => TransformItemsIndicesConfig[]; + /** + * Enable usage of future Autocomplete behavior. + */ + future?: { + /** + * When set to true, `currentRefinement` is `undefined` when no query has + * been set (instead of an empty string). This lets consumers distinguish + * between "initial/submitted state" and "user explicitly cleared the input". + * + * @default `false` + */ + undefinedEmptyQuery?: boolean; + }; +}; + +export type AutocompleteRenderState = { + /** + * The current value of the query. + * When `future.undefinedEmptyQuery` is `true`, this is `undefined` when no + * query has been set yet (e.g. on init or after submit). + */ + currentRefinement: string | undefined; + + /** + * The indices this widget has access to. + */ + indices: Array<{ + /** + * The name of the index + */ + indexName: string; + + /** + * The id of the index + */ + indexId: string; + + /** + * The resolved hits from the index matching the query. + */ + hits: Hit[]; + + /** + * The full results object from the Algolia API. + */ + results: SearchResults; + + /** + * Send event to insights middleware + */ + sendEvent: SendEventForHits; + }>; + + /** + * Searches into the indices with the provided query. + */ + refine: (query: string) => void; +}; + +export type AutocompleteWidgetDescription = { + $$type: 'ais.autocomplete'; + renderState: AutocompleteRenderState; + indexRenderState: { + autocomplete: WidgetRenderState< + AutocompleteRenderState, + AutocompleteConnectorParams + >; + }; + indexUiState: { query: string }; +}; + +export type AutocompleteConnector = Connector< + AutocompleteWidgetDescription, + AutocompleteConnectorParams +>; + +const connectAutocomplete: AutocompleteConnector = function connectAutocomplete( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + // @MAJOR: this can default to false + escapeHTML = true, + transformItems = ((indices) => indices) as NonNullable< + AutocompleteConnectorParams['transformItems'] + >, + future: { undefinedEmptyQuery = false } = {}, + } = widgetParams || {}; + + warning( + !(widgetParams as any).indices, + ` +The option \`indices\` has been removed from the Autocomplete connector. + +The indices to target are now inferred from the widgets tree. +${ + Array.isArray((widgetParams as any).indices) + ? ` +An alternative would be: + +const autocomplete = connectAutocomplete(renderer); + +search.addWidgets([ + ${(widgetParams as any).indices + .map(({ value }: { value: string }) => `index({ indexName: '${value}' }),`) + .join('\n ')} + autocomplete() +]); +` + : '' +} + ` + ); + + type ConnectorState = { + refine?: (query: string) => void; + }; + + const connectorState: ConnectorState = {}; + + return { + $$type: 'ais.autocomplete', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + const renderState = this.getWidgetRenderState(renderOptions); + + renderState.indices.forEach(({ sendEvent, hits }) => { + sendEvent('view:internal', hits); + }); + + renderFn( + { + ...renderState, + instantSearchInstance, + }, + false + ); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + autocomplete: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ + helper, + state, + scopedResults, + instantSearchInstance, + }) { + if (!connectorState.refine) { + connectorState.refine = (query: string) => { + helper.setQuery(query).search(); + }; + } + + const sendEventMap: Record = {}; + const indices = scopedResults.map((scopedResult) => { + // We need to escape the hits because highlighting + // exposes HTML tags to the end-user. + if (scopedResult.results) { + scopedResult.results.hits = escapeHTML + ? escapeHits(scopedResult.results.hits) + : scopedResult.results.hits; + } + + sendEventMap[scopedResult.indexId] = createSendEventForHits({ + instantSearchInstance, + helper: scopedResult.helper, + widgetType: this.$$type, + }); + + const hits = scopedResult.results + ? addQueryID( + addAbsolutePosition( + scopedResult.results.hits, + scopedResult.results.page, + scopedResult.results.hitsPerPage + ), + scopedResult.results.queryID + ) + : []; + + return { + indexId: scopedResult.indexId, + indexName: scopedResult.results?.index || '', + hits, + results: scopedResult.results || ({} as unknown as SearchResults), + }; + }); + + return { + currentRefinement: undefinedEmptyQuery + ? state.query + : state.query || '', + indices: transformItems(indices).map((transformedIndex) => ({ + ...transformedIndex, + sendEvent: sendEventMap[transformedIndex.indexId], + })), + refine: connectorState.refine, + widgetParams, + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + const query = undefinedEmptyQuery + ? searchParameters.query + : searchParameters.query || ''; + + if (!query || query === '' || (uiState && uiState.query === query)) { + return uiState; + } + + return { + ...uiState, + query, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const parameters = { + query: undefinedEmptyQuery ? uiState.query : uiState.query || '', + }; + + if (!escapeHTML) { + return searchParameters.setQueryParameters(parameters); + } + + return searchParameters.setQueryParameters({ + ...parameters, + ...TAG_PLACEHOLDER, + }); + }, + + dispose({ state }) { + unmountFn(); + + const stateWithoutQuery = state.setQueryParameter('query', undefined); + + if (!escapeHTML) { + return stateWithoutQuery; + } + + return stateWithoutQuery.setQueryParameters( + Object.keys(TAG_PLACEHOLDER).reduce( + (acc, key) => ({ + ...acc, + [key]: undefined, + }), + {} + ) + ); + }, + }; + }; +}; + +export default connectAutocomplete; diff --git a/packages/instantsearch-core/src/connectors/breadcrumb/connectBreadcrumb.ts b/packages/instantsearch-core/src/connectors/breadcrumb/connectBreadcrumb.ts new file mode 100644 index 00000000000..843215f994e --- /dev/null +++ b/packages/instantsearch-core/src/connectors/breadcrumb/connectBreadcrumb.ts @@ -0,0 +1,359 @@ +import { + checkRendering, + warning, + createDocumentationMessageGenerator, + isEqual, + noop, +} from '../../lib/utils'; + +import type { + Connector, + TransformItems, + CreateURL, + WidgetRenderState, + IndexUiState, +} from '../../types'; +import type { SearchParameters, SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'breadcrumb', + connector: true, +}); + +export type BreadcrumbConnectorParamsItem = { + /** + * Label of the category or subcategory. + */ + label: string; + + /** + * Value of breadcrumb item. + */ + value: string | null; +}; + +export type BreadcrumbConnectorParams = { + /** + * Attributes to use to generate the hierarchy of the breadcrumb. + */ + attributes: string[]; + + /** + * Prefix path to use if the first level is not the root level. + */ + rootPath?: string; + + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; + + /** + * The level separator used in the records. + * + * @default '>' + */ + separator?: string; +}; + +export type BreadcrumbRenderState = { + /** + * Creates the URL for a single item name in the list. + */ + createURL: CreateURL; + + /** + * Array of objects defining the different values and labels. + */ + items: BreadcrumbConnectorParamsItem[]; + + /** + * Sets the path of the hierarchical filter and triggers a new search. + */ + refine: (value: BreadcrumbConnectorParamsItem['value']) => void; + + /** + * True if refinement can be applied. + */ + canRefine: boolean; +}; + +export type BreadcrumbWidgetDescription = { + $$type: 'ais.breadcrumb'; + renderState: BreadcrumbRenderState; + indexRenderState: { + breadcrumb: { + [rootAttribute: string]: WidgetRenderState< + BreadcrumbRenderState, + BreadcrumbConnectorParams + >; + }; + }; +}; + +export type BreadcrumbConnector = Connector< + BreadcrumbWidgetDescription, + BreadcrumbConnectorParams +>; + +const connectBreadcrumb: BreadcrumbConnector = function connectBreadcrumb( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + type ConnectorState = { + refine?: BreadcrumbRenderState['refine']; + createURL?: BreadcrumbRenderState['createURL']; + }; + + const connectorState: ConnectorState = {}; + + return (widgetParams) => { + const { + attributes, + separator = ' > ', + rootPath = null, + transformItems = ((items) => items) as NonNullable< + BreadcrumbConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if (!attributes || !Array.isArray(attributes) || attributes.length === 0) { + throw new Error( + withUsage('The `attributes` option expects an array of strings.') + ); + } + + const [hierarchicalFacetName] = attributes; + + function getRefinedState( + state: SearchParameters, + facetValue: BreadcrumbConnectorParamsItem['value'] + ) { + if (!facetValue) { + const breadcrumb = state.getHierarchicalFacetBreadcrumb( + hierarchicalFacetName + ); + if (breadcrumb.length === 0) { + return state; + } else { + return state + .resetPage() + .toggleFacetRefinement(hierarchicalFacetName, breadcrumb[0]); + } + } + return state + .resetPage() + .toggleFacetRefinement(hierarchicalFacetName, facetValue); + } + + return { + $$type: 'ais.breadcrumb', + + init(initOptions) { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + breadcrumb: { + ...renderState.breadcrumb, + [hierarchicalFacetName]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState({ helper, createURL, results, state }) { + function getItems() { + // The hierarchicalFacets condition is required for flavors + // that render immediately with empty results, without relying + // on init() (like React InstantSearch). + if (!results || state.hierarchicalFacets.length === 0) { + return []; + } + + const facetValues = results.getFacetValues(hierarchicalFacetName, {}); + const facetItems = + facetValues && !Array.isArray(facetValues) && facetValues.data + ? facetValues.data + : []; + const items = transformItems( + shiftItemsValues(prepareItems(facetItems)), + { + results, + } + ); + + return items; + } + + const items = getItems(); + + if (!connectorState.createURL) { + connectorState.createURL = (facetValue) => { + return createURL((uiState) => + this.getWidgetUiState!(uiState, { + searchParameters: getRefinedState(helper.state, facetValue), + helper, + }) + ); + }; + } + + if (!connectorState.refine) { + connectorState.refine = (facetValue) => { + helper.setState(getRefinedState(helper.state, facetValue)).search(); + }; + } + + return { + canRefine: items.length > 0, + createURL: connectorState.createURL, + items, + refine: connectorState.refine, + widgetParams, + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + const path = searchParameters.getHierarchicalFacetBreadcrumb( + hierarchicalFacetName + ); + + return removeEmptyRefinementsFromUiState( + { + ...uiState, + hierarchicalMenu: { + ...uiState.hierarchicalMenu, + [hierarchicalFacetName]: path, + }, + }, + hierarchicalFacetName + ); + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const values = + uiState.hierarchicalMenu && + uiState.hierarchicalMenu[hierarchicalFacetName]; + + if ( + searchParameters.isConjunctiveFacet(hierarchicalFacetName) || + searchParameters.isDisjunctiveFacet(hierarchicalFacetName) + ) { + warning( + false, + `HierarchicalMenu: Attribute "${hierarchicalFacetName}" is already used by another widget applying conjunctive or disjunctive faceting. +As this is not supported, please make sure to remove this other widget or this HierarchicalMenu widget will not work at all.` + ); + + return searchParameters; + } + + if (searchParameters.isHierarchicalFacet(hierarchicalFacetName)) { + const facet = searchParameters.getHierarchicalFacetByName( + hierarchicalFacetName + ); + + warning( + isEqual(facet.attributes, attributes) && + facet.separator === separator && + facet.rootPath === rootPath, + 'Using Breadcrumb and HierarchicalMenu on the same facet with different options overrides the configuration of the HierarchicalMenu.' + ); + } + + const withFacetConfiguration = searchParameters + .removeHierarchicalFacet(hierarchicalFacetName) + .addHierarchicalFacet({ + name: hierarchicalFacetName, + attributes, + separator, + rootPath, + }); + + if (!values) { + return withFacetConfiguration.setQueryParameters({ + hierarchicalFacetsRefinements: { + ...withFacetConfiguration.hierarchicalFacetsRefinements, + [hierarchicalFacetName]: [], + }, + }); + } + + return withFacetConfiguration.addHierarchicalFacetRefinement( + hierarchicalFacetName, + values.join(separator) + ); + }, + }; + }; +}; + +function prepareItems(data: SearchResults.HierarchicalFacet[]) { + return data.reduce((result, currentItem) => { + if (currentItem.isRefined) { + result.push({ + label: currentItem.name, + value: currentItem.escapedValue, + }); + if (Array.isArray(currentItem.data)) { + result = result.concat(prepareItems(currentItem.data)); + } + } + return result; + }, []); +} + +function shiftItemsValues(array: BreadcrumbConnectorParamsItem[]) { + return array.map((x, idx) => ({ + label: x.label, + value: idx + 1 === array.length ? null : array[idx + 1].value, + })); +} + +function removeEmptyRefinementsFromUiState( + indexUiState: IndexUiState, + attribute: string +): IndexUiState { + if (!indexUiState.hierarchicalMenu) { + return indexUiState; + } + + if ( + !indexUiState.hierarchicalMenu[attribute] || + !indexUiState.hierarchicalMenu[attribute].length + ) { + delete indexUiState.hierarchicalMenu[attribute]; + } + + if (Object.keys(indexUiState.hierarchicalMenu).length === 0) { + delete indexUiState.hierarchicalMenu; + } + + return indexUiState; +} + +export default connectBreadcrumb; diff --git a/packages/instantsearch-core/src/connectors/chat/connectChat.ts b/packages/instantsearch-core/src/connectors/chat/connectChat.ts new file mode 100644 index 00000000000..2129acaa5ca --- /dev/null +++ b/packages/instantsearch-core/src/connectors/chat/connectChat.ts @@ -0,0 +1,766 @@ +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithToolCalls, +} from '../../lib/ai-lite'; +import { Chat, SearchIndexToolType } from '../../lib/chat'; +import { + checkRendering, + clearRefinements, + createDocumentationMessageGenerator, + createSendEventForHits, + getAlgoliaAgent, + getAppIdAndApiKey, + getRefinements, + noop, + sendChatMessageFeedback, + uniq, + warning, +} from '../../lib/utils'; +import { flat } from '../../lib/utils/flat'; + +import type { + AbstractChat, + ChatInit as ChatInitAi, + UIMessage, +} from '../../lib/chat'; +import type { SendEventForHits } from '../../lib/utils'; +import type { + Connector, + Renderer, + Unmounter, + UnknownWidgetParams, + InstantSearch, + IndexUiState, + IndexWidget, + WidgetRenderState, + IndexRenderState, +} from '../../types'; +import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; +import type { + AddToolResultWithOutput, + UserClientSideTool, + ClientSideTools, + ClientSideTool, +} from 'instantsearch-ui-components'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'chat', + connector: true, +}); + +export type ChatRenderState = { + indexUiState: IndexUiState; + input: string; + open: boolean; + /** + * Sends an event to the Insights middleware. + */ + sendEvent: SendEventForHits; + setIndexUiState: IndexWidget['setIndexUiState']; + setInput: (input: string) => void; + setOpen: (open: boolean) => void; + /** + * Opens the chat (if needed) and focuses the prompt input. + */ + focusInput: () => void; + /** + * Updates the `messages` state locally. This is useful when you want to + * edit the messages on the client, and then trigger the `reload` method + * manually to regenerate the AI response. + */ + setMessages: ( + messages: TUiMessage[] | ((m: TUiMessage[]) => TUiMessage[]) + ) => void; + /** + * Whether the chat is in the process of clearing messages. + */ + isClearing: boolean; + /** + * Clear all messages. + */ + clearMessages: () => void; + /** + * Callback to be called when the clear transition ends. + */ + onClearTransitionEnd: () => void; + /** + * Tools configuration with addToolResult bound, ready to be used by the UI. + */ + tools: ClientSideTools; + /** + * Suggestions received from the AI model. + */ + suggestions?: string[]; + /** + * Sends feedback (thumbs up/down) for an assistant message. + * Only available when using `agentId` and `feedback` is true. + * Returns `undefined` otherwise. + */ + sendChatMessageFeedback?: (messageId: string, vote: 0 | 1) => void; + /** + * Map of message IDs to their feedback state. + * 'sending' means the request is in flight, 0/1 means the vote was recorded. + */ + feedbackState: Record; +} & Pick< + AbstractChat, + | 'addToolResult' + | 'clearError' + | 'error' + | 'id' + | 'messages' + | 'regenerate' + | 'resumeStream' + | 'sendMessage' + | 'status' + | 'stop' +>; + +export type ChatInitWithoutTransport = Omit< + ChatInitAi, + 'transport' +>; + +export type ChatTransport = { + transport?: ConstructorParameters[0]; +} & ( + | { + agentId: string; + /** + * Whether to enable feedback (thumbs up/down) on assistant messages. + */ + feedback?: boolean; + } + | { agentId?: undefined; feedback?: never } +); + +export type ApplyFiltersParams = { + query?: string; + facetFilters?: string[][]; +}; + +export type ChatInit = + ChatInitWithoutTransport & ChatTransport; + +export type ChatConnectorParams = ( + | { chat: Chat } + | ChatInit +) & { + /** + * Whether to resume an ongoing chat generation stream. + */ + resume?: boolean; + /** + * Configuration for client-side tools. + */ + tools?: Record>; + /** + * Identifier of this type of chat widget. This is used for the key in renderState. + * @default 'chat' + */ + type?: string; + /** + * Additional context to send with each user message (e.g. current page info). + * This context is included in the message parts sent to the API but is not + * displayed in the chat UI. + * Can be a static object or a function that returns the context at send time. + */ + context?: Record | (() => Record); + /** + * A message to send automatically when the chat is initialized. + * + * This message is only sent when the chat has no existing messages yet. If + * messages were restored or otherwise already exist when the widget starts, + * this message is not sent. + * + * When `resume` is enabled, this message is not sent. + */ + initialUserMessage?: string; + /** + * Messages to pre-populate the chat with when it is initialized. + * + * These messages are set without triggering an AI response. They are only + * applied when the chat has no existing messages yet. If messages were + * restored or otherwise already exist when the widget starts, these messages + * are not applied. + * + * When `resume` is enabled, these messages are not applied. + * + * `initialUserMessage` is sent after `initialMessages` are applied, so an + * assistant welcome followed by a user prompt works. + */ + initialMessages?: TUiMessage[]; +}; + +export type ChatWidgetDescription = { + $$type: 'ais.chat'; + renderState: ChatRenderState; + indexRenderState: { + // In IndexRenderState, the key is always 'chat', but in the widgetParams you can customize it with the `type` parameter + chat: WidgetRenderState< + ChatRenderState, + ChatConnectorParams + >; + }; +}; + +export type ChatConnector = Connector< + ChatWidgetDescription, + ChatConnectorParams +>; + +function getAttributesToClear({ + results, + helper, +}: { + results: SearchResults; + helper: AlgoliaSearchHelper; +}) { + return uniq( + getRefinements(results, helper.state, true).map( + (refinement) => refinement.attribute + ) + ); +} + +function updateStateFromSearchToolInput( + params: ApplyFiltersParams, + helper: AlgoliaSearchHelper +) { + // clear all filters first + const attributesToClear = getAttributesToClear({ + results: helper.lastResults!, + helper, + }); + + helper.setState( + clearRefinements({ + helper, + attributesToClear, + }) + ); + + if (params.facetFilters) { + const attributes = flat(params.facetFilters).map((filter) => { + const [attribute, value] = filter.split(':'); + + return { attribute, value }; + }); + + attributes.forEach(({ attribute, value }) => { + if ( + !helper.state.isConjunctiveFacet(attribute) && + !helper.state.isHierarchicalFacet(attribute) && + !helper.state.isDisjunctiveFacet(attribute) + ) { + const s = helper.state.addDisjunctiveFacet(attribute); + helper.setState(s); + helper.toggleFacetRefinement(attribute, value); + } else { + const attr = + helper.state.hierarchicalFacets.find( + (facet) => facet.name === attribute + )?.name || attribute; + + helper.toggleFacetRefinement(attr, value); + } + }); + } + + if (params.query) { + helper.setQuery(params.query); + } + + helper.search(); + + return helper.state; +} + +export default (function connectChat( + renderFn: Renderer, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return ( + widgetParams: TWidgetParams & ChatConnectorParams + ) => { + warning(false, 'Chat is not yet stable and will change in the future.'); + + const { + resume = false, + tools = {}, + type = 'chat', + context, + initialUserMessage, + initialMessages, + ...options + } = widgetParams || {}; + + let _chatInstance: Chat; + let input = ''; + let open = false; + let isClearing = false; + let sendEvent: SendEventForHits; + let setInput: ChatRenderState['setInput']; + let setOpen: ChatRenderState['setOpen']; + let focusInput: ChatRenderState['focusInput']; + let setIsClearing: (value: boolean) => void; + let setFeedbackState: (messageId: string, state: 'sending' | 0 | 1) => void; + + const agentId = 'agentId' in options ? options.agentId : undefined; + let feedbackState: ChatRenderState['feedbackState'] = {}; + let _sendChatMessageFeedback: ChatRenderState['sendChatMessageFeedback']; + let feedbackAbortController: AbortController | undefined; + + // Extract suggestions from the last assistant message's data-suggestions part + const getSuggestionsFromMessages = (messages: TUiMessage[]) => { + // Find the last assistant message (iterate from end) + const lastAssistantMessage = [...messages] + .reverse() + .find((message) => message.role === 'assistant' && message.parts); + + if (!lastAssistantMessage?.parts) { + return undefined; + } + + // Find the data-suggestions part + const suggestionsPart = lastAssistantMessage.parts.find( + ( + part + ): part is { + type: `data-${string}`; + data: { suggestions: string[] }; + } => + 'type' in part && + part.type === 'data-suggestions' && + 'data' in part && + Array.isArray( + (part as { data?: { suggestions?: unknown } }).data?.suggestions + ) + ); + + return suggestionsPart?.data.suggestions; + }; + + const setMessages = ( + messagesParam: TUiMessage[] | ((m: TUiMessage[]) => TUiMessage[]) + ) => { + if (typeof messagesParam === 'function') { + messagesParam = messagesParam(_chatInstance.messages); + } + _chatInstance.messages = messagesParam; + }; + + const clearMessages = () => { + if (!_chatInstance.messages || _chatInstance.messages.length === 0) { + return; + } + const status = _chatInstance.status; + if (status === 'submitted' || status === 'streaming') { + _chatInstance.stop(); + } + setIsClearing(true); + }; + + const onClearTransitionEnd = () => { + setMessages([]); + _chatInstance.clearError(); + feedbackState = {}; + setIsClearing(false); + }; + + const makeChatInstance = (instantSearchInstance: InstantSearch) => { + let transport; + const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); + + // Filter out custom data parts (like data-suggestions) that the backend doesn't accept + const filterDataParts = (messages: UIMessage[]): UIMessage[] => + messages.map((message) => ({ + ...message, + parts: message.parts?.filter( + (part) => !('type' in part && part.type.startsWith('data-')) + ), + })); + + if ('transport' in options && options.transport) { + const originalPrepare = options.transport.prepareSendMessagesRequest; + transport = new DefaultChatTransport({ + ...options.transport, + prepareSendMessagesRequest: (params) => { + // Call the original prepareSendMessagesRequest if it exists, + // otherwise construct the default body + const preparedOrPromise = originalPrepare + ? originalPrepare(params) + : { body: { ...params } }; + // Then filter out data-* parts + const applyFilter = (prepared: { body: object }) => ({ + ...prepared, + body: { + ...prepared.body, + messages: filterDataParts( + (prepared.body as { messages: UIMessage[] }).messages + ), + }, + }); + + // Handle both sync and async cases + if (preparedOrPromise && 'then' in preparedOrPromise) { + return preparedOrPromise.then(applyFilter); + } + return applyFilter(preparedOrPromise); + }, + }); + } + if ('agentId' in options && options.agentId) { + if (!appId || !apiKey) { + throw new Error( + withUsage( + 'Could not extract Algolia credentials from the search client.' + ) + ); + } + + const baseApi = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`; + transport = new DefaultChatTransport({ + api: baseApi, + headers: { + 'x-algolia-application-id': appId, + 'x-algolia-api-Key': apiKey, + 'x-algolia-agent': getAlgoliaAgent(instantSearchInstance.client), + }, + prepareSendMessagesRequest: ({ messages, trigger, ...rest }) => { + return { + // Bypass cache when regenerating to ensure fresh responses + api: + trigger === 'regenerate-message' + ? `${baseApi}&cache=false` + : baseApi, + body: { + ...rest, + messages: filterDataParts(messages), + }, + }; + }, + }); + } + if (!transport) { + throw new Error( + withUsage('You need to provide either an `agentId` or a `transport`.') + ); + } + + if ('chat' in options) { + return options.chat; + } + + return new Chat({ + ...options, + transport, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + shouldRepairToolInput(toolName) { + let tool = tools[toolName]; + if (!tool && toolName.startsWith(`${SearchIndexToolType}_`)) { + tool = tools[SearchIndexToolType]; + } + if (!tool) return true; + return Boolean(tool.streamInput); + }, + onToolCall({ toolCall }) { + let tool = tools[toolCall.toolName]; + + // Compatibility shim with Algolia MCP Server search tool + if ( + !tool && + toolCall.toolName.startsWith(`${SearchIndexToolType}_`) + ) { + tool = tools[SearchIndexToolType]; + } + + if (!tool) { + if (__DEV__) { + throw new Error( + `No tool implementation found for "${toolCall.toolName}". Please provide a tool implementation in the \`tools\` prop.` + ); + } + + return _chatInstance.addToolResult({ + output: `No tool implemented for "${toolCall.toolName}".`, + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + }); + } + + if (tool.onToolCall) { + const addToolResult: AddToolResultWithOutput = ({ output }) => + _chatInstance.addToolResult({ + output, + tool: toolCall.toolName, + toolCallId: toolCall.toolCallId, + }); + + return tool.onToolCall({ + ...toolCall, + addToolResult, + }); + } + + return Promise.resolve(); + }, + } as ChatInitAi & { agentId?: string }); + }; + + return { + $$type: 'ais.chat', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + _chatInstance = makeChatInstance(instantSearchInstance); + + const render = () => { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + false + ); + }; + + setOpen = (o) => { + open = o; + render(); + }; + + focusInput = () => { + setOpen(true); + }; + + setInput = (i) => { + input = i; + render(); + }; + + setIsClearing = (value) => { + isClearing = value; + render(); + }; + + setFeedbackState = (messageId, state) => { + feedbackState = { ...feedbackState, [messageId]: state }; + render(); + }; + + const feedback = + 'feedback' in options ? options.feedback : undefined; + if (agentId && feedback) { + const [appId, apiKey] = getAppIdAndApiKey( + initOptions.instantSearchInstance.client + ); + + if (!appId || !apiKey) { + throw new Error( + withUsage( + 'Could not extract Algolia credentials from the search client.' + ) + ); + } + + feedbackAbortController = new AbortController(); + _sendChatMessageFeedback = (messageId: string, vote: 0 | 1) => { + if (feedbackState[messageId] !== undefined) { + return; + } + setFeedbackState(messageId, 'sending'); + sendChatMessageFeedback({ + agentId, + vote, + messageId, + appId, + apiKey, + }).finally(() => { + setFeedbackState(messageId, vote); + }); + }; + } + + const hasExistingMessages = _chatInstance.messages.length > 0; + + // Set initialMessages before registering callbacks to avoid + // triggering re-renders during init + if (initialMessages?.length && !resume && !hasExistingMessages) { + _chatInstance.messages = initialMessages; + } + + _chatInstance['~registerErrorCallback'](render); + _chatInstance['~registerMessagesCallback'](render); + _chatInstance['~registerStatusCallback'](render); + + if (resume) { + _chatInstance.resumeStream(); + } + + if (initialUserMessage && !resume && !hasExistingMessages) { + _chatInstance.sendMessage({ text: initialUserMessage }); + } + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + getRenderState( + renderState, + renderOptions + // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition + ): IndexRenderState & ChatWidgetDescription['indexRenderState'] { + return { + ...renderState, + // Type is casted to 'chat' here, because in the IndexRenderState the key is always 'chat' + [type as 'chat']: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState(renderOptions) { + const { instantSearchInstance, parent, helper } = renderOptions; + if (!_chatInstance) { + this.init!({ ...renderOptions, uiState: {}, results: undefined }); + } + + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance: renderOptions.instantSearchInstance, + helper: renderOptions.helper, + widgetType: this.$$type, + }); + } + + function applyFilters(params: ApplyFiltersParams) { + return updateStateFromSearchToolInput(params, helper); + } + + const toolsWithAddToolResult: ClientSideTools = {}; + Object.entries(tools).forEach(([key, tool]) => { + const toolWithAddToolResult: ClientSideTool = { + ...tool, + addToolResult: _chatInstance.addToolResult, + applyFilters, + sendEvent, + }; + toolsWithAddToolResult[key] = toolWithAddToolResult; + }); + + const sendMessageWithContext: typeof _chatInstance.sendMessage = ( + message, + ...rest + ) => { + if (!context || !message) { + return _chatInstance.sendMessage(message, ...rest); + } + + const resolvedContext = + typeof context === 'function' ? context() : context; + + let serializedContext: string; + try { + serializedContext = JSON.stringify(resolvedContext); + } catch { + warning( + false, + 'Could not serialize chat context. The message will be sent without context.' + ); + return _chatInstance.sendMessage(message, ...rest); + } + + const contextTextPart = { + type: 'text' as const, + text: ''.concat(serializedContext).concat(''), + }; + + if ('parts' in message && message.parts) { + return _chatInstance.sendMessage({ + ...message, + parts: [contextTextPart, ...message.parts], + text: undefined, + files: undefined, + }, ...rest); + } + + const textContent = + 'text' in message && message.text ? message.text : ''; + + return _chatInstance.sendMessage({ + parts: [ + contextTextPart, + { type: 'text' as const, text: textContent }, + ], + metadata: message.metadata, + messageId: message.messageId, + files: undefined, + text: undefined, + }, ...rest); + }; + + return { + indexUiState: instantSearchInstance.getUiState()[parent.getIndexId()], + input, + open, + sendEvent, + setIndexUiState: parent.setIndexUiState.bind(parent), + setInput, + setOpen, + focusInput, + setMessages, + suggestions: getSuggestionsFromMessages(_chatInstance.messages), + isClearing, + clearMessages, + onClearTransitionEnd, + tools: toolsWithAddToolResult, + sendChatMessageFeedback: _sendChatMessageFeedback, + feedbackState, + widgetParams, + + // Chat instance render state + addToolResult: _chatInstance.addToolResult, + clearError: _chatInstance.clearError, + error: _chatInstance.error, + id: _chatInstance.id, + messages: _chatInstance.messages, + regenerate: _chatInstance.regenerate, + resumeStream: _chatInstance.resumeStream, + sendMessage: sendMessageWithContext, + status: _chatInstance.status, + stop: _chatInstance.stop, + }; + }, + + dispose() { + feedbackAbortController?.abort(); + unmountFn(); + }, + + shouldRender() { + return true; + }, + + get chatInstance() { + return _chatInstance; + }, + }; + }; +} satisfies ChatConnector); diff --git a/packages/instantsearch-core/src/connectors/clear-refinements/connectClearRefinements.ts b/packages/instantsearch-core/src/connectors/clear-refinements/connectClearRefinements.ts new file mode 100644 index 00000000000..b5539cbfc94 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/clear-refinements/connectClearRefinements.ts @@ -0,0 +1,272 @@ +import { + checkRendering, + clearRefinements, + getRefinements, + createDocumentationMessageGenerator, + noop, + uniq, + mergeSearchParameters, +} from '../../lib/utils'; + +import type { + TransformItems, + CreateURL, + Connector, + WidgetRenderState, + ScopedResult, +} from '../../types'; +import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'clear-refinements', + connector: true, +}); + +export type ClearRefinementsConnectorParams = { + /** + * The attributes to include in the refinements to clear (all by default). Cannot be used with `excludedAttributes`. + */ + includedAttributes?: string[]; + + /** + * The attributes to exclude from the refinements to clear. Cannot be used with `includedAttributes`. + */ + excludedAttributes?: string[]; + + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; +}; + +export type ClearRefinementsRenderState = { + /** + * Triggers the clear of all the currently refined values. + */ + refine: () => void; + + /** + * Indicates if search state is refined. + * @deprecated prefer reading canRefine + */ + hasRefinements: boolean; + + /** + * Indicates if search state can be refined. + */ + canRefine: boolean; + + /** + * Creates a url for the next state when refinements are cleared. + */ + createURL: CreateURL; +}; + +export type ClearRefinementsWidgetDescription = { + $$type: 'ais.clearRefinements'; + renderState: ClearRefinementsRenderState; + indexRenderState: { + clearRefinements: WidgetRenderState< + ClearRefinementsRenderState, + ClearRefinementsConnectorParams + >; + }; +}; + +export type ClearRefinementsConnector = Connector< + ClearRefinementsWidgetDescription, + ClearRefinementsConnectorParams +>; + +type AttributesToClear = { + helper: AlgoliaSearchHelper; + items: string[]; +}; + +const connectClearRefinements: ClearRefinementsConnector = + function connectClearRefinements(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + includedAttributes = [], + excludedAttributes = ['query'], + transformItems = ((items) => items) as NonNullable< + ClearRefinementsConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if ( + widgetParams && + widgetParams.includedAttributes && + widgetParams.excludedAttributes + ) { + throw new Error( + withUsage( + 'The options `includedAttributes` and `excludedAttributes` cannot be used together.' + ) + ); + } + + type ConnectorState = { + refine: () => void; + createURL: () => string; + attributesToClear: AttributesToClear[]; + }; + + const connectorState: ConnectorState = { + refine: noop, + createURL: () => '', + attributesToClear: [], + }; + + const cachedRefine = () => connectorState.refine(); + const cachedCreateURL = () => connectorState.createURL(); + + return { + $$type: 'ais.clearRefinements', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + clearRefinements: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ createURL, scopedResults, results }) { + connectorState.attributesToClear = scopedResults.reduce< + Array> + >((attributesToClear, scopedResult) => { + return attributesToClear.concat( + getAttributesToClear({ + scopedResult, + includedAttributes, + excludedAttributes, + transformItems, + results, + }) + ); + }, []); + + connectorState.refine = () => { + connectorState.attributesToClear.forEach( + ({ helper: indexHelper, items }) => { + indexHelper + .setState( + clearRefinements({ + helper: indexHelper, + attributesToClear: items, + }) + ) + .search(); + } + ); + }; + + connectorState.createURL = () => { + return createURL( + mergeSearchParameters( + ...connectorState.attributesToClear.map( + ({ helper: indexHelper, items }) => { + return clearRefinements({ + helper: indexHelper, + attributesToClear: items, + }); + } + ) + ) + ); + }; + + const canRefine = connectorState.attributesToClear.some( + (attributeToClear) => attributeToClear.items.length > 0 + ); + + return { + canRefine, + hasRefinements: canRefine, + refine: cachedRefine, + createURL: cachedCreateURL, + widgetParams, + }; + }, + }; + }; + }; + +function getAttributesToClear({ + scopedResult, + includedAttributes, + excludedAttributes, + transformItems, + results, +}: { + scopedResult: ScopedResult; + includedAttributes: string[]; + excludedAttributes: string[]; + transformItems: TransformItems; + results: SearchResults | undefined | null; +}): AttributesToClear { + const includesQuery = + includedAttributes.indexOf('query') !== -1 || + excludedAttributes.indexOf('query') === -1; + + return { + helper: scopedResult.helper, + items: transformItems( + uniq( + getRefinements( + scopedResult.results, + scopedResult.helper.state, + includesQuery + ) + .map((refinement) => refinement.attribute) + .filter( + (attribute) => + // If the array is empty (default case), we keep all the attributes + includedAttributes.length === 0 || + // Otherwise, only add the specified attributes + includedAttributes.indexOf(attribute) !== -1 + ) + .filter( + (attribute) => + // If the query is included, we ignore the default `excludedAttributes = ['query']` + (attribute === 'query' && includesQuery) || + // Otherwise, ignore the excluded attributes + excludedAttributes.indexOf(attribute) === -1 + ) + ), + { results } + ), + }; +} + +export default connectClearRefinements; diff --git a/packages/instantsearch-core/src/connectors/configure/connectConfigure.ts b/packages/instantsearch-core/src/connectors/configure/connectConfigure.ts new file mode 100644 index 00000000000..95500753a55 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/configure/connectConfigure.ts @@ -0,0 +1,203 @@ +import algoliasearchHelper from 'algoliasearch-helper'; + +import { + createDocumentationMessageGenerator, + isPlainObject, + mergeSearchParameters, + noop, +} from '../../lib/utils'; + +import type { Connector, WidgetRenderState } from '../../types'; +import type { + SearchParameters, + PlainSearchParameters, + AlgoliaSearchHelper, +} from 'algoliasearch-helper'; + +/** + * Refine the given search parameters. + */ +type Refine = (searchParameters: PlainSearchParameters) => void; + +export type ConfigureConnectorParams = { + /** + * A list of [search parameters](https://www.algolia.com/doc/api-reference/search-api-parameters/) + * to enable when the widget mounts. + */ + searchParameters: PlainSearchParameters; +}; + +export type ConfigureRenderState = { + /** + * Refine the given search parameters. + */ + refine: Refine; +}; + +const withUsage = createDocumentationMessageGenerator({ + name: 'configure', + connector: true, +}); + +function getInitialSearchParameters( + state: SearchParameters, + widgetParams: ConfigureConnectorParams +): SearchParameters { + // We leverage the helper internals to remove the `widgetParams` from + // the state. The function `setQueryParameters` omits the values that + // are `undefined` on the next state. + return state.setQueryParameters( + Object.keys(widgetParams.searchParameters).reduce( + (acc, key) => ({ + ...acc, + [key]: undefined, + }), + {} + ) + ); +} + +export type ConfigureWidgetDescription = { + $$type: 'ais.configure'; + renderState: ConfigureRenderState; + indexRenderState: { + configure: WidgetRenderState< + ConfigureRenderState, + ConfigureConnectorParams + >; + }; + indexUiState: { + configure: PlainSearchParameters; + }; +}; + +export type ConfigureConnector = Connector< + ConfigureWidgetDescription, + ConfigureConnectorParams +>; + +const connectConfigure: ConfigureConnector = function connectConfigure( + renderFn = noop, + unmountFn = noop +) { + return (widgetParams) => { + if (!widgetParams || !isPlainObject(widgetParams.searchParameters)) { + throw new Error( + withUsage('The `searchParameters` option expects an object.') + ); + } + + type ConnectorState = { + refine?: Refine; + }; + + const connectorState: ConnectorState = {}; + + function refine(helper: AlgoliaSearchHelper): Refine { + return (searchParameters: PlainSearchParameters) => { + // Merge new `searchParameters` with the ones set from other widgets + const actualState = getInitialSearchParameters( + helper.state, + widgetParams + ); + const nextSearchParameters = mergeSearchParameters( + actualState, + new algoliasearchHelper.SearchParameters(searchParameters) + ); + + // Update original `widgetParams.searchParameters` to the new refined one + widgetParams.searchParameters = searchParameters; + + // Trigger a search with the resolved search parameters + helper.setState(nextSearchParameters).search(); + }; + } + + return { + $$type: 'ais.configure', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return getInitialSearchParameters(state, widgetParams); + }, + + getRenderState(renderState, renderOptions) { + const widgetRenderState = this.getWidgetRenderState(renderOptions); + return { + ...renderState, + configure: { + ...widgetRenderState, + widgetParams: { + ...widgetRenderState.widgetParams, + searchParameters: mergeSearchParameters( + new algoliasearchHelper.SearchParameters( + renderState.configure?.widgetParams.searchParameters + ), + new algoliasearchHelper.SearchParameters( + widgetRenderState.widgetParams.searchParameters + ) + ).getQueryParams(), + }, + }, + }; + }, + + getWidgetRenderState({ helper }) { + if (!connectorState.refine) { + connectorState.refine = refine(helper); + } + + return { + refine: connectorState.refine, + widgetParams, + }; + }, + + getWidgetSearchParameters(state, { uiState }) { + return mergeSearchParameters( + state, + new algoliasearchHelper.SearchParameters({ + ...uiState.configure, + ...widgetParams.searchParameters, + }) + ); + }, + + getWidgetUiState(uiState) { + return { + ...uiState, + configure: { + ...uiState.configure, + ...widgetParams.searchParameters, + }, + }; + }, + }; + }; +}; + +export default connectConfigure; diff --git a/packages/instantsearch-core/src/connectors/current-refinements/connectCurrentRefinements.ts b/packages/instantsearch-core/src/connectors/current-refinements/connectCurrentRefinements.ts new file mode 100644 index 00000000000..df541aaed43 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/current-refinements/connectCurrentRefinements.ts @@ -0,0 +1,430 @@ +import { + getRefinements, + checkRendering, + createDocumentationMessageGenerator, + noop, + warning, +} from '../../lib/utils'; + +import type { + Refinement, + FacetRefinement, + NumericRefinement, +} from '../../lib/utils'; +import type { + Connector, + TransformItems, + CreateURL, + WidgetRenderState, +} from '../../types'; +import type { + AlgoliaSearchHelper, + SearchParameters, + SearchResults, +} from 'algoliasearch-helper'; + +export type CurrentRefinementsConnectorParamsRefinement = { + /** + * The attribute on which the refinement is applied. + */ + attribute: string; + + /** + * The type of the refinement. + */ + type: + | 'facet' + | 'exclude' + | 'disjunctive' + | 'hierarchical' + | 'numeric' + | 'query' + | 'tag'; + + /** + * The raw value of the refinement. + */ + value: string | number; + + /** + * The label of the refinement to display. + */ + label: string; + + /** + * The value of the operator (only if applicable). + */ + operator?: string; + + /** + * The number of found items (only if applicable). + */ + count?: number; + + /** + * Whether the count is exhaustive (only if applicable). + */ + exhaustive?: boolean; +}; + +export type CurrentRefinementsConnectorParamsItem = { + /** + * The index name. + */ + indexName: string; + + /** + * The index id as provided to the index widget. + */ + indexId: string; + + /** + * The attribute on which the refinement is applied. + */ + attribute: string; + + /** + * The textual representation of this attribute. + */ + label: string; + + /** + * Currently applied refinements. + */ + refinements: CurrentRefinementsConnectorParamsRefinement[]; + + /** + * Removes the given refinement and triggers a new search. + */ + refine: (refinement: CurrentRefinementsConnectorParamsRefinement) => void; +}; + +export type CurrentRefinementsConnectorParams = { + /** + * The attributes to include in the widget (all by default). + * Cannot be used with `excludedAttributes`. + * + * @default `[]` + */ + includedAttributes?: string[]; + + /** + * The attributes to exclude from the widget. + * Cannot be used with `includedAttributes`. + * + * @default `['query']` + */ + excludedAttributes?: string[]; + + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; +}; + +export type CurrentRefinementsRenderState = { + /** + * All the currently refined items, grouped by attribute. + */ + items: CurrentRefinementsConnectorParamsItem[]; + + /** + * Indicates if search state can be refined. + */ + canRefine: boolean; + + /** + * Removes the given refinement and triggers a new search. + */ + refine: (refinement: CurrentRefinementsConnectorParamsRefinement) => void; + + /** + * Generates a URL for the next state. + */ + createURL: CreateURL; +}; + +const withUsage = createDocumentationMessageGenerator({ + name: 'current-refinements', + connector: true, +}); + +export type CurrentRefinementsWidgetDescription = { + $$type: 'ais.currentRefinements'; + renderState: CurrentRefinementsRenderState; + indexRenderState: { + currentRefinements: WidgetRenderState< + CurrentRefinementsRenderState, + CurrentRefinementsConnectorParams + >; + }; +}; + +export type CurrentRefinementsConnector = Connector< + CurrentRefinementsWidgetDescription, + CurrentRefinementsConnectorParams +>; + +const connectCurrentRefinements: CurrentRefinementsConnector = + function connectCurrentRefinements(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + if ( + (widgetParams || {}).includedAttributes && + (widgetParams || {}).excludedAttributes + ) { + throw new Error( + withUsage( + 'The options `includedAttributes` and `excludedAttributes` cannot be used together.' + ) + ); + } + + const { + includedAttributes, + excludedAttributes = ['query'], + transformItems = ((items) => items) as NonNullable< + CurrentRefinementsConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + return { + $$type: 'ais.currentRefinements', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + currentRefinements: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ results, scopedResults, createURL, helper }) { + function getItems() { + if (!results) { + return transformItems( + getRefinementsItems({ + results: null, + helper, + indexId: helper.state.index, + includedAttributes, + excludedAttributes, + }), + { results } + ); + } + + return scopedResults.reduce< + CurrentRefinementsConnectorParamsItem[] + >((accResults, scopedResult) => { + return accResults.concat( + transformItems( + getRefinementsItems({ + results: scopedResult.results, + helper: scopedResult.helper, + indexId: scopedResult.indexId, + includedAttributes, + excludedAttributes, + }), + { results } + ) + ); + }, []); + } + + const items = getItems(); + + return { + items, + canRefine: items.length > 0, + refine: (refinement) => clearRefinement(helper, refinement), + createURL: (refinement) => + createURL(clearRefinementFromState(helper.state, refinement)), + widgetParams, + }; + }, + }; + }; + }; + +function getRefinementsItems({ + results, + helper, + indexId, + includedAttributes, + excludedAttributes, +}: { + results: SearchResults | null; + helper: AlgoliaSearchHelper; + indexId: string; + includedAttributes: CurrentRefinementsConnectorParams['includedAttributes']; + excludedAttributes: CurrentRefinementsConnectorParams['excludedAttributes']; +}): CurrentRefinementsConnectorParamsItem[] { + const includesQuery = + (includedAttributes || []).indexOf('query') !== -1 || + (excludedAttributes || []).indexOf('query') === -1; + + const filterFunction = includedAttributes + ? (item: CurrentRefinementsConnectorParamsRefinement) => + includedAttributes.indexOf(item.attribute) !== -1 + : (item: CurrentRefinementsConnectorParamsRefinement) => + excludedAttributes!.indexOf(item.attribute) === -1; + + const items = getRefinements(results, helper.state, includesQuery) + .map(normalizeRefinement) + .filter(filterFunction); + + return items.reduce( + (allItems, currentItem) => [ + ...allItems.filter((item) => item.attribute !== currentItem.attribute), + { + indexName: helper.state.index, + indexId, + attribute: currentItem.attribute, + label: currentItem.attribute, + refinements: items + .filter((result) => result.attribute === currentItem.attribute) + // We want to keep the order of refinements except the numeric ones. + .sort((a, b) => + a.type === 'numeric' ? (a.value as number) - (b.value as number) : 0 + ), + refine: (refinement) => clearRefinement(helper, refinement), + }, + ], + [] + ); +} + +function clearRefinementFromState( + state: SearchParameters, + refinement: CurrentRefinementsConnectorParamsRefinement +): SearchParameters { + state = state.resetPage(); + switch (refinement.type) { + case 'facet': + return state.removeFacetRefinement( + refinement.attribute, + String(refinement.value) + ); + case 'disjunctive': + return state.removeDisjunctiveFacetRefinement( + refinement.attribute, + String(refinement.value) + ); + case 'hierarchical': + return state.removeHierarchicalFacetRefinement(refinement.attribute); + case 'exclude': + return state.removeExcludeRefinement( + refinement.attribute, + String(refinement.value) + ); + case 'numeric': + return state.removeNumericRefinement( + refinement.attribute, + refinement.operator, + String(refinement.value) + ); + case 'tag': + return state.removeTagRefinement(String(refinement.value)); + case 'query': + return state.setQueryParameter('query', ''); + default: + warning( + false, + `The refinement type "${refinement.type}" does not exist and cannot be cleared from the current refinements.` + ); + return state; + } +} + +function clearRefinement( + helper: AlgoliaSearchHelper, + refinement: CurrentRefinementsConnectorParamsRefinement +): void { + helper.setState(clearRefinementFromState(helper.state, refinement)).search(); +} + +function getOperatorSymbol(operator: SearchParameters.Operator): string { + switch (operator) { + case '>=': + return '≥'; + case '<=': + return '≤'; + default: + return operator; + } +} + +function normalizeRefinement( + refinement: Refinement +): CurrentRefinementsConnectorParamsRefinement { + const value = getValue(refinement); + const label = (refinement as NumericRefinement).operator + ? `${getOperatorSymbol( + (refinement as NumericRefinement).operator as SearchParameters.Operator + )} ${refinement.name}` + : refinement.name; + + const normalizedRefinement: CurrentRefinementsConnectorParamsRefinement = { + attribute: refinement.attribute, + type: refinement.type, + value, + label, + }; + + if ((refinement as NumericRefinement).operator !== undefined) { + normalizedRefinement.operator = (refinement as NumericRefinement).operator; + } + if ((refinement as FacetRefinement).count !== undefined) { + normalizedRefinement.count = (refinement as FacetRefinement).count; + } + if ((refinement as FacetRefinement).exhaustive !== undefined) { + normalizedRefinement.exhaustive = ( + refinement as FacetRefinement + ).exhaustive; + } + + return normalizedRefinement; +} + +function getValue(refinement: Refinement) { + if (refinement.type === 'numeric') { + return Number(refinement.name); + } + + if ('escapedValue' in refinement) { + return refinement.escapedValue; + } + + return refinement.name; +} + +export default connectCurrentRefinements; diff --git a/packages/instantsearch-core/src/connectors/dynamic-widgets/connectDynamicWidgets.ts b/packages/instantsearch-core/src/connectors/dynamic-widgets/connectDynamicWidgets.ts new file mode 100644 index 00000000000..81d239ddf5b --- /dev/null +++ b/packages/instantsearch-core/src/connectors/dynamic-widgets/connectDynamicWidgets.ts @@ -0,0 +1,262 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + getWidgetAttribute, + noop, + warning, +} from '../../lib/utils'; + +import type { + Connector, + TransformItems, + TransformItemsMetadata, + Widget, +} from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'dynamic-widgets', + connector: true, +}); + +export type DynamicWidgetsRenderState = { + attributesToRender: string[]; +}; + +export type DynamicWidgetsConnectorParams = { + /** + * An array of widgets, displayed in the order defined by `facetOrdering`. + */ + widgets: Widget[]; + + /** + * Function to return a fallback widget when an attribute isn't found in + * `widgets`. + */ + fallbackWidget?: (args: { + /** The attribute name to create a widget for. */ + attribute: string; + }) => Widget; + + /** + * Function to transform the items to render. + * The function also exposes the full search response. + */ + transformItems?: TransformItems< + string, + Omit & { + results: NonNullable; + } + >; + + /** + * To prevent unneeded extra network requests when widgets mount or unmount, + * we request all facet values by default. If you want to only request the + * facet values that are needed, you can set this option to the list of + * attributes you want to display. + * + * If `facets` is set to `['*']`, we request all facet values. + * + * Any facets that are requested due to the `facetOrdering` result are always + * requested by the widget that mounted itself. + * + * Setting `facets` to a value other than `['*']` will only prevent extra + * requests if all potential facets are listed. + * + * @default ['*'] + */ + facets?: ['*'] | string[]; + + /** + * If you have more than 20 facet values pinned, you need to increase the + * maxValuesPerFacet to at least that value. + * + * @default 20 + */ + maxValuesPerFacet?: number; +}; + +export type DynamicWidgetsWidgetDescription = { + $$type: 'ais.dynamicWidgets'; + renderState: DynamicWidgetsRenderState; + indexRenderState: { + dynamicWidgets: DynamicWidgetsRenderState; + }; +}; + +export type DynamicWidgetsConnector = Connector< + DynamicWidgetsWidgetDescription, + DynamicWidgetsConnectorParams +>; + +const MAX_WILDCARD_FACETS = 20; + +const connectDynamicWidgets: DynamicWidgetsConnector = + function connectDynamicWidgets(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + widgets, + maxValuesPerFacet = 20, + facets = ['*'], + transformItems = (items) => items, + fallbackWidget, + } = widgetParams; + + if ( + !( + widgets && + Array.isArray(widgets) && + widgets.every((widget) => typeof widget === 'object') + ) + ) { + throw new Error( + withUsage('The `widgets` option expects an array of widgets.') + ); + } + + if (!Array.isArray(facets)) { + throw new Error( + withUsage( + `The \`facets\` option only accepts an array of facets, you passed ${JSON.stringify( + facets + )}` + ) + ); + } + + const localWidgets: Map = + new Map(); + + return { + $$type: 'ais.dynamicWidgets', + init(initOptions) { + widgets.forEach((widget) => { + const attribute = getWidgetAttribute(widget, initOptions); + localWidgets.set(attribute, { widget, isMounted: false }); + }); + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + render(renderOptions) { + const { parent } = renderOptions; + const renderState = this.getWidgetRenderState(renderOptions); + + const widgetsToUnmount: Widget[] = []; + const widgetsToMount: Widget[] = []; + + if (fallbackWidget) { + renderState.attributesToRender.forEach((attribute) => { + if (!localWidgets.has(attribute)) { + const widget = fallbackWidget({ attribute }); + localWidgets.set(attribute, { widget, isMounted: false }); + } + }); + } + + localWidgets.forEach(({ widget, isMounted }, attribute) => { + const shouldMount = + renderState.attributesToRender.indexOf(attribute) > -1; + + if (!isMounted && shouldMount) { + widgetsToMount.push(widget); + localWidgets.set(attribute, { + widget, + isMounted: true, + }); + } else if (isMounted && !shouldMount) { + widgetsToUnmount.push(widget); + localWidgets.set(attribute, { + widget, + isMounted: false, + }); + } + }); + + parent.addWidgets(widgetsToMount); + // make sure this only happens after the regular render, otherwise it + // happens too quick, since render is "deferred" for the next microtask, + // so this needs to be a whole task later + setTimeout(() => parent.removeWidgets(widgetsToUnmount), 0); + + renderFn( + { + ...renderState, + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + dispose({ parent }) { + const toRemove: Widget[] = []; + localWidgets.forEach(({ widget, isMounted }) => { + if (isMounted) { + toRemove.push(widget); + } + }); + parent.removeWidgets(toRemove); + + unmountFn(); + }, + getWidgetSearchParameters(state) { + return facets.reduce( + (acc, curr) => acc.addFacet(curr), + state.setQueryParameters({ + maxValuesPerFacet: Math.max( + maxValuesPerFacet || 0, + state.maxValuesPerFacet || 0 + ), + }) + ); + }, + getRenderState(renderState, renderOptions) { + return { + ...renderState, + dynamicWidgets: this.getWidgetRenderState(renderOptions), + }; + }, + getWidgetRenderState({ results, state }) { + if (!results) { + return { attributesToRender: [], widgetParams }; + } + + const attributesToRender = transformItems( + results.renderingContent?.facetOrdering?.facets?.order ?? [], + { results } + ); + + if (!Array.isArray(attributesToRender)) { + throw new Error( + withUsage( + 'The `transformItems` option expects a function that returns an Array.' + ) + ); + } + + warning( + maxValuesPerFacet >= (state.maxValuesPerFacet || 0), + `The maxValuesPerFacet set by dynamic widgets (${maxValuesPerFacet}) is smaller than one of the limits set by a widget (${state.maxValuesPerFacet}). This causes a mismatch in query parameters and thus an extra network request when that widget is mounted.` + ); + + warning( + attributesToRender.length <= MAX_WILDCARD_FACETS || + widgetParams.facets !== undefined, + `More than ${MAX_WILDCARD_FACETS} facets are requested to be displayed without explicitly setting which facets to retrieve. This could have a performance impact. Set "facets" to [] to do two smaller network requests, or explicitly to ['*'] to avoid this warning.` + ); + + return { + attributesToRender, + widgetParams, + }; + }, + }; + }; + }; + +export default connectDynamicWidgets; diff --git a/packages/instantsearch-core/src/connectors/feeds/FeedContainer.ts b/packages/instantsearch-core/src/connectors/feeds/FeedContainer.ts new file mode 100644 index 00000000000..828ef23e69d --- /dev/null +++ b/packages/instantsearch-core/src/connectors/feeds/FeedContainer.ts @@ -0,0 +1,310 @@ +import algoliasearchHelper from 'algoliasearch-helper'; + +import { + createInitArgs, + createRenderArgs, + storeRenderState, +} from '../../lib/utils'; + +import type { + InstantSearch, + UiState, + IndexUiState, + Widget, + IndexWidget, + DisposeOptions, + RenderOptions, +} from '../../types'; +import type { SearchParameters } from 'algoliasearch-helper'; + +export function createFeedContainer( + feedID: string, + parentIndex: IndexWidget, + instantSearchInstance: InstantSearch +): IndexWidget { + let localWidgets: Array = []; + let initialized = false; + + const container: IndexWidget = { + $$type: 'ais.feedContainer', + $$widgetType: 'ais.feedContainer', + _isolated: true, + + getIndexName: () => parentIndex.getIndexName(), + getIndexId: () => feedID, + getHelper: () => parentIndex.getHelper(), + + getResults() { + const parentResults = parentIndex.getResults(); + if (!parentResults) return null; + if (!parentResults.feeds) { + // Single-feed backward compat: no feeds array means the parent result + // itself is the only feed. + if (feedID === '') { + parentResults._state = parentIndex.getHelper()!.state; + return parentResults; + } + return null; + } + const feed = parentResults.feeds.find((f) => f.feedID === feedID); + if (!feed) return null; + // Optimistic state patching — same as index widget (index.ts:365-370) + feed._state = parentIndex.getHelper()!.state; + return feed; + }, + + getResultsForWidget() { + return this.getResults(); + }, + + getParent: () => parentIndex, + getWidgets: () => localWidgets, + getScopedResults: () => parentIndex.getScopedResults(), + getPreviousState: () => null, + createURL: ( + nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) + ) => parentIndex.createURL(nextState), + scheduleLocalSearch: () => parentIndex.scheduleLocalSearch(), + + addWidgets(widgets) { + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); + flatWidgets.forEach((widget) => { + widget.parent = container; + }); + localWidgets = localWidgets.concat(flatWidgets); + + if (initialized) { + flatWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[container.getIndexId()] || {}, + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + storeRenderState({ + renderState, + instantSearchInstance, + parent: container, + }); + } + }); + + flatWidgets.forEach((widget) => { + if (widget.init) { + widget.init( + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + } + }); + + // Merge children's search params (e.g. disjunctiveFacets) into the + // parent's helper state so they're included in the composition request. + // uiState is {} because URL-derived refinements are already on the + // parent state; children only need to declare structural params. + const parentHelper = parentIndex.getHelper()!; + const withChildParams = container.getWidgetSearchParameters( + parentHelper.state, + { uiState: {} } + ); + if (withChildParams !== parentHelper.state) { + parentHelper.setState(withChildParams); + } + } + + return container; + }, + + removeWidgets(widgets) { + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); + const helper = parentIndex.getHelper(); + + if (!helper) { + localWidgets = localWidgets.filter((w) => !flatWidgets.includes(w)); + return container; + } + + // Chain through children's dispose so widgets clean up the + // SearchParameters they declared (e.g. RefinementList removes its + // disjunctiveFacet) instead of leaving them stale on the parent helper. + let cleanedState: SearchParameters = helper.state; + + flatWidgets.forEach((widget) => { + if (widget.dispose) { + const next = widget.dispose({ + helper, + state: cleanedState, + recommendState: helper.recommendState, + parent: container, + }); + + if (next instanceof algoliasearchHelper.RecommendParameters) { + // ignore — FeedContainer doesn't manage recommend state + } else if (next) { + cleanedState = next; + } + } + }); + + localWidgets = localWidgets.filter((w) => !flatWidgets.includes(w)); + + if (cleanedState !== helper.state) { + helper.setState(cleanedState); + } + + return container; + }, + + init() { + initialized = true; + + localWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[container.getIndexId()] || {}, + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + storeRenderState({ + renderState, + instantSearchInstance, + parent: container, + }); + } + }); + + localWidgets.forEach((widget) => { + if (widget.init) { + widget.init( + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + } + }); + }, + + render() { + localWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[container.getIndexId()] || {}, + createRenderArgs( + instantSearchInstance, + container, + widget + ) as RenderOptions + ); + storeRenderState({ + renderState, + instantSearchInstance, + parent: container, + }); + } + }); + + localWidgets.forEach((widget) => { + if (widget.render) { + widget.render( + createRenderArgs( + instantSearchInstance, + container, + widget + ) as RenderOptions + ); + } + }); + }, + + dispose(disposeOptions?: DisposeOptions) { + const helper = parentIndex.getHelper(); + + // Chain through children's dispose to return a cleaned state + // (e.g. RefinementList.dispose removes its disjunctiveFacet declaration). + // This mirrors how the index widget's removeWidgets chains dispose calls. + let cleanedState = disposeOptions?.state ?? helper?.state; + + localWidgets.forEach((widget) => { + if (widget.dispose && helper) { + const next = widget.dispose({ + helper, + state: cleanedState!, + recommendState: helper.recommendState, + parent: container, + }); + + if (next instanceof algoliasearchHelper.RecommendParameters) { + // ignore — FeedContainer doesn't manage recommend state + } else if (next) { + cleanedState = next; + } + } + }); + + localWidgets = []; + initialized = false; + return cleanedState; + }, + + getWidgetState(uiState: UiState) { + return this.getWidgetUiState(uiState); + }, + + getWidgetUiState( + uiState: TUiState + ): TUiState { + const helper = parentIndex.getHelper()!; + const widgetUiStateOptions = { + searchParameters: helper.state, + helper, + }; + return localWidgets.reduce( + (state, widget) => + widget.getWidgetUiState + ? (widget.getWidgetUiState(state, widgetUiStateOptions) as TUiState) + : state, + uiState + ); + }, + + getWidgetSearchParameters( + searchParameters: SearchParameters, + { uiState }: { uiState: IndexUiState } + ) { + return localWidgets.reduce( + (params, widget) => + widget.getWidgetSearchParameters + ? widget.getWidgetSearchParameters(params, { uiState }) + : params, + searchParameters + ); + }, + + refreshUiState() { + // no-op: FeedContainer doesn't own UI state + }, + + setIndexUiState() { + // no-op: FeedContainer delegates to parent + }, + }; + + return container; +} diff --git a/packages/instantsearch-core/src/connectors/feeds/connectFeeds.ts b/packages/instantsearch-core/src/connectors/feeds/connectFeeds.ts new file mode 100644 index 00000000000..ac849e989b2 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/feeds/connectFeeds.ts @@ -0,0 +1,210 @@ +import algoliasearchHelper from 'algoliasearch-helper'; + +import { + checkRendering, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; + +import type { + CompositionFeedResult, + Connector, + IndexWidget, + InstantSearch, +} from '../../types'; + +function toFeedSearchResults( + state: algoliasearchHelper.SearchResults['_state'], + raw: CompositionFeedResult +): algoliasearchHelper.SearchResults & { feedID: string } { + return Object.assign(new algoliasearchHelper.SearchResults(state, [raw]), { + feedID: raw.feedID, + }); +} + +/** + * Rebuild `lastResults.feeds` from `_initialResults.compositionFeedsResults` + * because the index-widget hydration only restores `lastResults` (the merged + * view), not the per-feed breakdown that the Feeds connector needs. + */ +function hydrateFeedsFromInitialResultsIfNeeded( + instantSearchInstance: InstantSearch, + parent: IndexWidget +) { + const initial = instantSearchInstance._initialResults?.[parent.getIndexId()]; + const compositionFeedsResults = initial?.compositionFeedsResults || []; + if (compositionFeedsResults.length === 0) { + return; + } + + const lastResults = parent.getHelper()?.lastResults; + if (!lastResults) { + return; + } + + if (lastResults.feeds && lastResults.feeds.length > 0) { + return; + } + + lastResults.feeds = compositionFeedsResults.map((raw) => + toFeedSearchResults(lastResults._state, raw) + ); +} + +const withUsage = createDocumentationMessageGenerator({ + name: 'feeds', + connector: true, +}); + +export type FeedsRenderState = { + feedIDs: string[]; +}; + +export type FeedsConnectorParams = { + /** + * Whether feeds are isolated from the global search scope. + * Currently only `false` is supported (future-proofing for per-feed search parameters). + */ + isolated: false; + + /** + * Optional: transform/reorder/filter feed IDs before rendering. + */ + transformFeeds?: (feeds: string[]) => string[]; +}; + +export type FeedsWidgetDescription = { + $$type: 'ais.feeds'; + renderState: FeedsRenderState; + indexRenderState: { + feeds: FeedsRenderState; + }; +}; + +export type FeedsConnector = Connector< + FeedsWidgetDescription, + FeedsConnectorParams +>; + +const connectFeeds: FeedsConnector = function connectFeeds( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { isolated, transformFeeds = (feeds) => feeds } = widgetParams; + + if (isolated !== false) { + throw new Error( + withUsage('The `isolated` option currently only supports `false`.') + ); + } + + return { + $$type: 'ais.feeds', + $$widgetType: 'ais.feeds', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + if (!instantSearchInstance.compositionID) { + throw new Error( + withUsage( + 'The `feeds` widget requires a composition-based InstantSearch instance (compositionID must be set).' + ) + ); + } + + hydrateFeedsFromInitialResultsIfNeeded( + instantSearchInstance, + initOptions.parent + ); + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + getWidgetSearchParameters(state) { + return state; + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + feeds: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ results }) { + if (!results) { + return { feedIDs: [], widgetParams }; + } + + if ( + Array.isArray(results.feeds) && + results.feeds.length > 0 && + !results.feeds.every( + (feed) => feed instanceof algoliasearchHelper.SearchResults + ) + ) { + results.feeds = results.feeds.map((feed) => + feed instanceof algoliasearchHelper.SearchResults + ? feed + : toFeedSearchResults( + results._state, + feed as CompositionFeedResult + ) + ); + } + + let feedIDs = results.feeds + ? results.feeds.map((f: { feedID: string }) => f.feedID) + : ['']; + + feedIDs = transformFeeds(feedIDs); + + if (!Array.isArray(feedIDs)) { + throw new Error( + withUsage( + 'The `transformFeeds` option expects a function that returns an Array.' + ) + ); + } + + if (!feedIDs.every((feedID: string) => typeof feedID === 'string')) { + throw new Error( + withUsage( + 'The `transformFeeds` option expects a function that returns an array of feed IDs (strings).' + ) + ); + } + + return { feedIDs, widgetParams }; + }, + }; + }; +}; + +export default connectFeeds; diff --git a/packages/instantsearch-core/src/connectors/filter-suggestions/connectFilterSuggestions.ts b/packages/instantsearch-core/src/connectors/filter-suggestions/connectFilterSuggestions.ts new file mode 100644 index 00000000000..e5820f9de67 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/filter-suggestions/connectFilterSuggestions.ts @@ -0,0 +1,449 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + getAlgoliaAgent, + getAppIdAndApiKey, + getRefinements, + noop, +} from '../../lib/utils'; + +import type { + Connector, + InitOptions, + RenderOptions, + TransformItems, + WidgetRenderState, +} from '../../types'; +import type { SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'filter-suggestions', + connector: true, +}); + +export type Suggestion = { + /** + * The facet attribute name. + */ + attribute: string; + /** + * The facet value to filter by. + */ + value: string; + /** + * Human-readable display label. + */ + label: string; + /** + * Number of records matching this filter. + */ + count: number; +}; + +export type FilterSuggestionsTransport = { + /** + * The custom API endpoint URL. + */ + api: string; + /** + * Custom headers to send with the request. + */ + headers?: Record; + /** + * Function to prepare the request body before sending. + * Receives the default body and returns the modified request options. + */ + prepareSendMessagesRequest?: (body: Record) => { + body: Record; + }; +}; + +export type FilterSuggestionsRenderState = { + /** + * The list of suggested filters. + */ + suggestions: Suggestion[]; + /** + * Whether suggestions are currently being fetched. + */ + isLoading: boolean; + /** + * Applies a filter for the given attribute and value. + */ + refine: (attribute: string, value: string) => void; +}; + +export type FilterSuggestionsConnectorParams = { + /** + * The ID of the agent configured in the Algolia dashboard. + * Required unless a custom `transport` is provided. + */ + agentId?: string; + /** + * Limit to specific facet attributes. + */ + attributes?: string[]; + /** + * Maximum number of suggestions to return. + * @default 3 + */ + maxSuggestions?: number; + /** + * Debounce delay in milliseconds before fetching suggestions. + * @default 300 + */ + debounceMs?: number; + /** + * Number of hits to send for context. + * @default 5 + */ + hitsToSample?: number; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; + /** + * Custom transport configuration for the API requests. + * When provided, allows using a custom endpoint, headers, and request body. + */ + transport?: FilterSuggestionsTransport; +}; + +export type FilterSuggestionsWidgetDescription = { + $$type: 'ais.filterSuggestions'; + renderState: FilterSuggestionsRenderState; + indexRenderState: { + filterSuggestions: WidgetRenderState< + FilterSuggestionsRenderState, + FilterSuggestionsConnectorParams + >; + }; +}; + +export type FilterSuggestionsConnector = Connector< + FilterSuggestionsWidgetDescription, + FilterSuggestionsConnectorParams +>; + +const connectFilterSuggestions: FilterSuggestionsConnector = + function connectFilterSuggestions(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + agentId, + attributes, + maxSuggestions = 3, + debounceMs = 300, + hitsToSample = 5, + transformItems = ((items) => items) as NonNullable< + FilterSuggestionsConnectorParams['transformItems'] + >, + transport, + } = widgetParams; + + if (!agentId && !transport) { + throw new Error( + withUsage( + 'The `agentId` option is required unless a custom `transport` is provided.' + ) + ); + } + + let endpoint: string; + let headers: Record; + let suggestions: Suggestion[] = []; + let isLoading = false; + let debounceTimer: ReturnType | undefined; + let lastStateSignature: string | null = null; // null means never fetched + let refine: FilterSuggestionsRenderState['refine']; + let searchHelper: InitOptions['helper'] | null = null; + let latestRenderOptions: RenderOptions | null = null; + + // Create a signature of the current search state (query + refinements) + const getStateSignature = (results: SearchResults): string => { + const query = results.query || ''; + const refinements = searchHelper + ? JSON.stringify(searchHelper.state.facetsRefinements) + + JSON.stringify(searchHelper.state.disjunctiveFacetsRefinements) + + JSON.stringify(searchHelper.state.hierarchicalFacetsRefinements) + : ''; + return `${query}|${refinements}`; + }; + + const getWidgetRenderState = ( + renderOptions: InitOptions | RenderOptions + ) => { + const results = + 'results' in renderOptions ? renderOptions.results : undefined; + const transformedSuggestions = transformItems(suggestions, { results }); + + return { + suggestions: transformedSuggestions, + isLoading, + refine, + widgetParams, + }; + }; + + // Minimum duration to show skeleton to avoid flash when results are cached + const MIN_SKELETON_DURATION_MS = 300; + + const fetchSuggestions = ( + results: SearchResults, + renderOptions: RenderOptions + ) => { + if (!results?.hits?.length) { + suggestions = []; + isLoading = false; + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + return; + } + + const loadingStartTime = Date.now(); + isLoading = true; + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + + // Get facets from raw results (results.facets is processed differently) + const rawResults = results._rawResults as Array<{ + facets?: Record>; + }>; + const rawFacets = rawResults?.[0]?.facets || {}; + + const facetsToSend = attributes + ? Object.fromEntries( + Object.entries(rawFacets).filter(([key]) => + attributes.includes(key) + ) + ) + : rawFacets; + + // Collect current refinements to exclude from suggestions + const currentRefinements = searchHelper + ? getRefinements(results, searchHelper.state).map((refinement) => ({ + attribute: refinement.attribute, + value: refinement.name, + })) + : []; + + const messageText = JSON.stringify({ + query: results.query, + facets: facetsToSend, + hitsSample: results.hits.slice(0, hitsToSample), + currentRefinements, + maxSuggestions, + }); + + const payload: Record = { + messages: [ + { + id: `sr-${Date.now()}`, + createdAt: new Date().toISOString(), + role: 'user', + parts: [ + { + type: 'text', + text: messageText, + }, + ], + }, + ], + }; + + // Apply custom body transformation if provided + const finalPayload = transport?.prepareSendMessagesRequest + ? transport.prepareSendMessagesRequest(payload).body + : payload; + + fetch(endpoint, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(finalPayload), + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`); + } + return response.json(); + }) + .then((data) => { + const parsedSuggestions = JSON.parse(data.parts[1].text); + + const validSuggestions = ( + Array.isArray(parsedSuggestions) ? parsedSuggestions : [] + ) + .filter((suggestion) => { + if ( + !suggestion?.attribute || + !suggestion?.value || + !suggestion?.label + ) { + return false; + } + // If attributes filter is specified, only allow suggestions for those attributes + if (attributes && !attributes.includes(suggestion.attribute)) { + return false; + } + return true; + }) + .slice(0, maxSuggestions); + + suggestions = validSuggestions; + }) + .catch(() => { + suggestions = []; + }) + .finally(() => { + const elapsed = Date.now() - loadingStartTime; + const remainingDelay = Math.max( + 0, + MIN_SKELETON_DURATION_MS - elapsed + ); + + const finishLoading = () => { + isLoading = false; + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }; + + if (remainingDelay > 0) { + setTimeout(finishLoading, remainingDelay); + } else { + finishLoading(); + } + }); + }; + + return { + $$type: 'ais.filterSuggestions', + + init(initOptions) { + const { instantSearchInstance, helper } = initOptions; + searchHelper = helper; + + if (transport) { + // Use custom transport configuration + endpoint = transport.api; + headers = transport.headers || {}; + } else { + // Use default Algolia agent endpoint + const [appId, apiKey] = getAppIdAndApiKey( + instantSearchInstance.client + ); + + if (!appId || !apiKey) { + throw new Error( + withUsage( + 'Could not extract Algolia credentials from the search client.' + ) + ); + } + + endpoint = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5&stream=false`; + headers = { + 'x-algolia-application-id': appId, + 'x-algolia-api-key': apiKey, + 'x-algolia-agent': getAlgoliaAgent(instantSearchInstance.client), + }; + } + + refine = (attribute: string, value: string) => { + // Check if the attribute belongs to a hierarchical facet + // by finding a hierarchical facet that includes this attribute + const attr = + helper.state.hierarchicalFacets.find((facet) => + facet.attributes.includes(attribute) + )?.name || attribute; + + helper.toggleFacetRefinement(attr, value); + helper.search(); + }; + + renderFn( + { + ...getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { results, instantSearchInstance } = renderOptions; + + // Always store the latest render options + latestRenderOptions = renderOptions; + + if (!results) { + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + return; + } + + // Debounce: only fetch if search state changed (query or refinements) and after delay + const stateSignature = getStateSignature(results); + if (stateSignature !== lastStateSignature) { + lastStateSignature = stateSignature; + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + // Use the latest render options when the timeout fires + if (latestRenderOptions?.results) { + fetchSuggestions( + latestRenderOptions.results, + latestRenderOptions + ); + } + }, debounceMs); + } + + renderFn( + { + ...getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose() { + clearTimeout(debounceTimer); + unmountFn(); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + filterSuggestions: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState(renderOptions) { + return getWidgetRenderState(renderOptions); + }, + }; + }; + }; + +export default connectFilterSuggestions; diff --git a/packages/instantsearch-core/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts b/packages/instantsearch-core/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts new file mode 100644 index 00000000000..1f13787ece0 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts @@ -0,0 +1,243 @@ +import { + createDocumentationMessageGenerator, + checkRendering, + noop, + escapeHits, + TAG_PLACEHOLDER, + createSendEventForHits, + addAbsolutePosition, + addQueryID, +} from '../../lib/utils'; + +import type { SendEventForHits } from '../../lib/utils'; +import type { + Connector, + TransformItems, + BaseHit, + Renderer, + Unmounter, + UnknownWidgetParams, + RecommendResponse, + Hit, + AlgoliaHit, +} from '../../types'; +import type { PlainSearchParameters } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'frequently-bought-together', + connector: true, +}); + +export type FrequentlyBoughtTogetherRenderState< + THit extends NonNullable = BaseHit +> = { + /** + * The matched recommendations from Algolia API. + */ + items: Array>; + + /** + * Sends an event to the Insights middleware. + */ + sendEvent: SendEventForHits; +}; + +export type FrequentlyBoughtTogetherConnectorParams< + THit extends NonNullable = BaseHit +> = { + /** + * The objectIDs of the items to get the frequently bought together items for. + */ + objectIDs: string[]; + + /** + * Threshold for the recommendations confidence score (between 0 and 100). Only recommendations with a greater score are returned. + */ + threshold?: number; + + /** + * List of search parameters to send. + */ + fallbackParameters?: Omit< + PlainSearchParameters, + 'page' | 'hitsPerPage' | 'offset' | 'length' + >; + + /** + * The maximum number of recommendations to return. + */ + limit?: number; + + /** + * Parameters to pass to the request. + */ + queryParameters?: Omit< + PlainSearchParameters, + 'page' | 'hitsPerPage' | 'offset' | 'length' + >; + + /** + * Whether to escape HTML tags from items string values. + * + * @default true + */ + escapeHTML?: boolean; + + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems< + Hit, + { results: RecommendResponse> } + >; +}; + +export type FrequentlyBoughtTogetherWidgetDescription< + THit extends NonNullable = BaseHit +> = { + $$type: 'ais.frequentlyBoughtTogether'; + renderState: FrequentlyBoughtTogetherRenderState; +}; + +export type FrequentlyBoughtTogetherConnector< + THit extends NonNullable = BaseHit +> = Connector< + FrequentlyBoughtTogetherWidgetDescription, + FrequentlyBoughtTogetherConnectorParams +>; + +export default (function connectFrequentlyBoughtTogether< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer< + FrequentlyBoughtTogetherRenderState, + TWidgetParams & FrequentlyBoughtTogetherConnectorParams + >, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return = BaseHit>( + widgetParams: TWidgetParams & FrequentlyBoughtTogetherConnectorParams + ) => { + const { + // @MAJOR: this can default to false + escapeHTML = true, + transformItems = ((items) => items) as NonNullable< + FrequentlyBoughtTogetherConnectorParams['transformItems'] + >, + objectIDs, + limit, + threshold, + fallbackParameters, + queryParameters, + } = widgetParams || {}; + + if (!objectIDs || objectIDs.length === 0) { + throw new Error(withUsage('The `objectIDs` option is required.')); + } + + let sendEvent: SendEventForHits; + + return { + dependsOn: 'recommend', + $$type: 'ais.frequentlyBoughtTogether', + + 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, helper, instantSearchInstance }) { + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + helper, + widgetType: this.$$type, + }); + } + if (results === null || results === undefined) { + return { items: [], widgetParams, sendEvent }; + } + + if (escapeHTML && results.hits.length > 0) { + results.hits = escapeHits(results.hits); + } + + const itemsWithAbsolutePosition = addAbsolutePosition( + results.hits, + 0, + 1 + ); + + const itemsWithAbsolutePositionAndQueryID = addQueryID( + itemsWithAbsolutePosition, + results.queryID + ); + + const transformedItems = transformItems( + itemsWithAbsolutePositionAndQueryID, + { + results: results as RecommendResponse>, + } + ); + + return { + items: transformedItems, + widgetParams, + sendEvent, + }; + }, + + dispose({ recommendState }) { + unmountFn(); + return recommendState.removeParams(this.$$id!); + }, + + getWidgetParameters(state) { + return objectIDs.reduce( + (acc, objectID) => + acc.addFrequentlyBoughtTogether({ + objectID, + maxRecommendations: limit, + threshold, + // @ts-expect-error until @algolia/recommend types are updated + fallbackParameters: fallbackParameters + ? { + ...fallbackParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + } + : undefined, + queryParameters: { + ...queryParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + }, + $$id: this.$$id!, + }), + state.removeParams(this.$$id!) + ); + }, + }; + }; +} satisfies FrequentlyBoughtTogetherConnector); diff --git a/packages/instantsearch-core/src/connectors/geo-search/connectGeoSearch.ts b/packages/instantsearch-core/src/connectors/geo-search/connectGeoSearch.ts new file mode 100644 index 00000000000..3cbf1506951 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/geo-search/connectGeoSearch.ts @@ -0,0 +1,427 @@ +import { + checkRendering, + aroundLatLngToPosition, + insideBoundingBoxToBoundingBox, + createDocumentationMessageGenerator, + createSendEventForHits, + noop, +} from '../../lib/utils'; + +import type { SendEventForHits } from '../../lib/utils'; +import type { + BaseHit, + Connector, + GeoHit, + GeoLoc, + IndexRenderState, + InitOptions, + Renderer, + RenderOptions, + TransformItems, + UnknownWidgetParams, + Unmounter, + WidgetRenderState, +} from '../../types'; +import type { + AlgoliaSearchHelper, + SearchParameters, +} from 'algoliasearch-helper'; + +export type { GeoHit } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'geo-search', + connector: true, +}); + +// in this connector, we assume insideBoundingBox is only a string, +// even though in the helper it's defined as number[][] alone. +// This can be done, since the connector assumes "control" of the parameter +function getBoundingBoxAsString(state: SearchParameters) { + return (state.insideBoundingBox as unknown as string) || ''; +} +function setBoundingBoxAsString(state: SearchParameters, value: string) { + return state.setQueryParameter( + 'insideBoundingBox', + value as unknown as number[][] + ); +} + +type Bounds = { + /** + * The top right corner of the map view. + */ + northEast: GeoLoc; + /** + * The bottom left corner of the map view. + */ + southWest: GeoLoc; +}; + +export type GeoSearchRenderState = BaseHit> = { + /** + * Reset the current bounding box refinement. + */ + clearMapRefinement: () => void; + /** + * The current bounding box of the search. + */ + currentRefinement?: Bounds; + /** + * Return true if the map has move since the last refinement. + */ + hasMapMoveSinceLastRefine: () => boolean; + /** + * Return true if the current refinement is set with the map bounds. + */ + isRefinedWithMap: () => boolean; + /** + * Return true if the user is able to refine on map move. + */ + isRefineOnMapMove: () => boolean; + /** + * The matched hits from Algolia API. + */ + items: Array>; + /** + * The current position of the search. + */ + position?: GeoLoc; + /** + * Sets a bounding box to filter the results from the given map bounds. + */ + refine: (bounds: Bounds) => void; + /** + * Send event to insights middleware + */ + sendEvent: SendEventForHits; + /** + * Set the fact that the map has moved since the last refinement, should be + * called on each map move. The call to the function triggers a new rendering + * only when the value change. + */ + setMapMoveSinceLastRefine: () => void; + /** + * Toggle the fact that the user is able to refine on map move. + */ + toggleRefineOnMapMove: () => void; +}; + +export type GeoSearchConnectorParams = { + /** + * If true, refine will be triggered as you move the map. + * @default true + */ + enableRefineOnMapMove?: boolean; + /** + * Function to transform the items passed to the templates. + * @default items => items + */ + transformItems?: TransformItems>; +}; + +const $$type = 'ais.geoSearch'; + +export type GeoSearchWidgetDescription = { + $$type: 'ais.geoSearch'; + renderState: GeoSearchRenderState; + indexRenderState: { + geoSearch: WidgetRenderState< + GeoSearchRenderState, + GeoSearchConnectorParams + >; + }; + indexUiState: { + geoSearch: { + /** + * The rectangular area in geo coordinates. + * The rectangle is defined by two diagonally opposite points, + * hence by 4 floats separated by commas. + * + * @example '47.3165,4.9665,47.3424,5.0201' + */ + boundingBox: string; + }; + }; +}; + +export type GeoSearchConnector = Connector< + GeoSearchWidgetDescription, + GeoSearchConnectorParams +>; + +/** + * The **GeoSearch** connector provides the logic to build a widget that will display the results on a map. It also provides a way to search for results based on their position. The connector provides functions to manage the search experience (search on map interaction or control the interaction for example). + * + * @requirements + * + * Note that the GeoSearch connector uses the [geosearch](https://www.algolia.com/doc/guides/searching/geo-search) capabilities of Algolia. Your hits **must** have a `_geoloc` attribute in order to be passed to the rendering function. + * + * Currently, the feature is not compatible with multiple values in the _geoloc attribute. + */ +export default (function connectGeoSearch< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer< + GeoSearchRenderState, + TWidgetParams & GeoSearchConnectorParams + >, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return ( + widgetParams: TWidgetParams & GeoSearchConnectorParams + ) => { + const { + enableRefineOnMapMove = true, + transformItems = ((items) => items) as NonNullable< + GeoSearchConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + const widgetState = { + isRefineOnMapMove: enableRefineOnMapMove, + // @MAJOR hasMapMoveSinceLastRefine -> hasMapMovedSinceLastRefine + hasMapMoveSinceLastRefine: false, + lastRefinePosition: '', + lastRefineBoundingBox: '', + internalToggleRefineOnMapMove: noop, + internalSetMapMoveSinceLastRefine: noop, + }; + + const getPositionFromState = (state: SearchParameters) => + state.aroundLatLng + ? aroundLatLngToPosition(state.aroundLatLng) + : undefined; + + const getCurrentRefinementFromState = (state: SearchParameters) => + state.insideBoundingBox && + insideBoundingBoxToBoundingBox(state.insideBoundingBox); + + const refine = + (helper: AlgoliaSearchHelper) => + ({ northEast: ne, southWest: sw }: Bounds) => { + const boundingBox = [ne.lat, ne.lng, sw.lat, sw.lng].join(); + + helper + .setState( + setBoundingBoxAsString(helper.state, boundingBox).resetPage() + ) + .search(); + + widgetState.hasMapMoveSinceLastRefine = false; + widgetState.lastRefineBoundingBox = boundingBox; + }; + + const clearMapRefinement = (helper: AlgoliaSearchHelper) => () => { + helper.setQueryParameter('insideBoundingBox', undefined).search(); + }; + + const isRefinedWithMap = (state: SearchParameters) => () => + Boolean(state.insideBoundingBox); + + const toggleRefineOnMapMove = () => + widgetState.internalToggleRefineOnMapMove(); + const createInternalToggleRefinementOnMapMove = + ( + renderOptions: TRenderOptions, + // false positive eslint because of generics + // eslint-disable-next-line no-shadow + render: (renderOptions: TRenderOptions) => void + ) => + () => { + widgetState.isRefineOnMapMove = !widgetState.isRefineOnMapMove; + + render(renderOptions); + }; + + const isRefineOnMapMove = () => widgetState.isRefineOnMapMove; + + const setMapMoveSinceLastRefine = () => + widgetState.internalSetMapMoveSinceLastRefine(); + const createInternalSetMapMoveSinceLastRefine = + ( + renderOptions: TRenderOptions, + // false positive eslint because of generics + // eslint-disable-next-line no-shadow + render: (renderOptions: TRenderOptions) => void + ) => + () => { + const shouldTriggerRender = + widgetState.hasMapMoveSinceLastRefine !== true; + + widgetState.hasMapMoveSinceLastRefine = true; + + if (shouldTriggerRender) { + render(renderOptions); + } + }; + + const hasMapMoveSinceLastRefine = () => + widgetState.hasMapMoveSinceLastRefine; + + let sendEvent: SendEventForHits; + + return { + $$type, + + init(initArgs) { + const { instantSearchInstance } = initArgs; + const isFirstRendering = true; + + widgetState.internalToggleRefineOnMapMove = + createInternalToggleRefinementOnMapMove(initArgs, noop); + + widgetState.internalSetMapMoveSinceLastRefine = + createInternalSetMapMoveSinceLastRefine(initArgs, noop); + + renderFn( + { + ...this.getWidgetRenderState(initArgs), + instantSearchInstance, + }, + isFirstRendering + ); + }, + + render(renderArgs) { + const { helper, instantSearchInstance } = renderArgs; + const isFirstRendering = false; + // We don't use the state provided by the render function because we need + // to be sure that the state is the latest one for the following condition + const state = helper.state; + + const positionChangedSinceLastRefine = + Boolean(state.aroundLatLng) && + Boolean(widgetState.lastRefinePosition) && + state.aroundLatLng !== widgetState.lastRefinePosition; + + const boundingBoxChangedSinceLastRefine = + !state.insideBoundingBox && + Boolean(widgetState.lastRefineBoundingBox) && + state.insideBoundingBox !== widgetState.lastRefineBoundingBox; + + if ( + positionChangedSinceLastRefine || + boundingBoxChangedSinceLastRefine + ) { + widgetState.hasMapMoveSinceLastRefine = false; + } + + widgetState.lastRefinePosition = state.aroundLatLng || ''; + + widgetState.lastRefineBoundingBox = getBoundingBoxAsString(state); + + widgetState.internalToggleRefineOnMapMove = + createInternalToggleRefinementOnMapMove( + renderArgs, + this.render!.bind(this) + ); + + widgetState.internalSetMapMoveSinceLastRefine = + createInternalSetMapMoveSinceLastRefine( + renderArgs, + this.render!.bind(this) + ); + + const widgetRenderState = this.getWidgetRenderState(renderArgs); + + sendEvent('view:internal', widgetRenderState.items); + + renderFn( + { + ...widgetRenderState, + instantSearchInstance, + }, + isFirstRendering + ); + }, + + getWidgetRenderState(renderOptions) { + const { helper, results, instantSearchInstance } = renderOptions; + const state = helper.state; + + const items = results + ? transformItems( + results.hits.filter((hit) => hit._geoloc), + { results } + ) + : []; + + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + helper, + widgetType: $$type, + }); + } + + return { + items, + position: getPositionFromState(state), + currentRefinement: getCurrentRefinementFromState(state), + refine: refine(helper), + sendEvent, + clearMapRefinement: clearMapRefinement(helper), + isRefinedWithMap: isRefinedWithMap(state), + toggleRefineOnMapMove, + isRefineOnMapMove, + setMapMoveSinceLastRefine, + hasMapMoveSinceLastRefine, + widgetParams, + }; + }, + + getRenderState( + renderState, + renderOptions + // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition + ): IndexRenderState & GeoSearchWidgetDescription['indexRenderState'] { + return { + ...renderState, + geoSearch: this.getWidgetRenderState(renderOptions), + }; + }, + + dispose({ state }) { + unmountFn(); + + return state.setQueryParameter('insideBoundingBox', undefined); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const boundingBox = getBoundingBoxAsString(searchParameters); + + if ( + !boundingBox || + (uiState && + uiState.geoSearch && + uiState.geoSearch.boundingBox === boundingBox) + ) { + return uiState; + } + + return { + ...uiState, + geoSearch: { + boundingBox, + }, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + if (!uiState || !uiState.geoSearch) { + return searchParameters.setQueryParameter( + 'insideBoundingBox', + undefined + ); + } + return setBoundingBoxAsString( + searchParameters, + uiState.geoSearch.boundingBox + ); + }, + }; + }; +} satisfies GeoSearchConnector); diff --git a/packages/instantsearch-core/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts b/packages/instantsearch-core/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts new file mode 100644 index 00000000000..11eb0b3bef1 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts @@ -0,0 +1,530 @@ +import { + checkRendering, + warning, + createDocumentationMessageGenerator, + createSendEventForFacet, + isEqual, + noop, +} from '../../lib/utils'; + +import type { SendEventForFacet } from '../../lib/utils'; +import type { + Connector, + CreateURL, + TransformItems, + RenderOptions, + Widget, + SortBy, + WidgetRenderState, + IndexUiState, +} from '../../types'; +import type { SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'hierarchical-menu', + connector: true, +}); + +const DEFAULT_SORT = ['name:asc']; + +export type HierarchicalMenuItem = { + /** + * Value of the menu item. + */ + value: string; + /** + * Human-readable value of the menu item. + */ + label: string; + /** + * Number of matched results after refinement is applied. + */ + count: number; + /** + * Indicates if the refinement is applied. + */ + isRefined: boolean; + /** + * n+1 level of items, same structure HierarchicalMenuItem + */ + data: HierarchicalMenuItem[] | null; +}; + +export type HierarchicalMenuConnectorParams = { + /** + * Attributes to use to generate the hierarchy of the menu. + */ + attributes: string[]; + /** + * Separator used in the attributes to separate level values. + */ + separator?: string; + /** + * Prefix path to use if the first level is not the root level. + */ + rootPath?: string | null; + /** + * Show the siblings of the selected parent levels of the current refined value. This + * does not impact the root level. + */ + showParentLevel?: boolean; + /** + * Max number of values to display. + */ + limit?: number; + /** + * Whether to display the "show more" button. + */ + showMore?: boolean; + /** + * Max number of values to display when showing more. + */ + showMoreLimit?: number; + /** + * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. + * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). + * + * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed + */ + sortBy?: SortBy; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; +}; + +export type HierarchicalMenuRenderState = { + /** + * Creates an url for the next state for a clicked item. + */ + createURL: CreateURL; + /** + * Values to be rendered. + */ + items: HierarchicalMenuItem[]; + /** + * Sets the path of the hierarchical filter and triggers a new search. + */ + refine: (value: string) => void; + /** + * Indicates if search state can be refined. + */ + canRefine: boolean; + /** + * True if the menu is displaying all the menu items. + */ + isShowingMore: boolean; + /** + * Toggles the number of values displayed between `limit` and `showMoreLimit`. + */ + toggleShowMore: () => void; + /** + * `true` if the toggleShowMore button can be activated (enough items to display more or + * already displaying more than `limit` items) + */ + canToggleShowMore: boolean; + /** + * Send event to insights middleware + */ + sendEvent: SendEventForFacet; +}; + +export type HierarchicalMenuWidgetDescription = { + $$type: 'ais.hierarchicalMenu'; + renderState: HierarchicalMenuRenderState; + indexRenderState: { + hierarchicalMenu: { + [rootAttribute: string]: WidgetRenderState< + HierarchicalMenuRenderState, + HierarchicalMenuConnectorParams + >; + }; + }; + indexUiState: { + hierarchicalMenu: { + [rootAttribute: string]: string[]; + }; + }; +}; + +export type HierarchicalMenuConnector = Connector< + HierarchicalMenuWidgetDescription, + HierarchicalMenuConnectorParams +>; + +/** + * **HierarchicalMenu** connector provides the logic to build a custom widget + * that will give the user the ability to explore facets in a tree-like structure. + * + * This is commonly used for multi-level categorization of products on e-commerce + * websites. From a UX point of view, we suggest not displaying more than two + * levels deep. + * + * @type {Connector} + * @param {function(HierarchicalMenuRenderingOptions, boolean)} renderFn Rendering function for the custom **HierarchicalMenu** widget. + * @param {function} unmountFn Unmount function called when the widget is disposed. + * @return {function(CustomHierarchicalMenuWidgetParams)} Re-usable widget factory for a custom **HierarchicalMenu** widget. + */ +const connectHierarchicalMenu: HierarchicalMenuConnector = + function connectHierarchicalMenu(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + attributes, + separator = ' > ', + rootPath = null, + showParentLevel = true, + limit = 10, + showMore = false, + showMoreLimit = 20, + sortBy = DEFAULT_SORT, + transformItems = ((items) => items) as NonNullable< + HierarchicalMenuConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if ( + !attributes || + !Array.isArray(attributes) || + attributes.length === 0 + ) { + throw new Error( + withUsage('The `attributes` option expects an array of strings.') + ); + } + + if (showMore === true && showMoreLimit <= limit) { + throw new Error( + withUsage('The `showMoreLimit` option must be greater than `limit`.') + ); + } + + type ThisWidget = Widget< + HierarchicalMenuWidgetDescription & { + widgetParams: typeof widgetParams; + } + >; + + // we need to provide a hierarchicalFacet name for the search state + // so that we can always map $hierarchicalFacetName => real attributes + // we use the first attribute name + const [hierarchicalFacetName] = attributes; + + let sendEvent: HierarchicalMenuRenderState['sendEvent']; + + // Provide the same function to the `renderFn` so that way the user + // has to only bind it once when `isFirstRendering` for instance + let toggleShowMore = () => {}; + function cachedToggleShowMore() { + toggleShowMore(); + } + + let _refine: HierarchicalMenuRenderState['refine'] | undefined; + + let isShowingMore = false; + + function createToggleShowMore( + renderOptions: RenderOptions, + widget: ThisWidget + ) { + return () => { + isShowingMore = !isShowingMore; + widget.render!(renderOptions); + }; + } + + function getLimit() { + return isShowingMore ? showMoreLimit : limit; + } + + function _prepareFacetValues( + facetValues: SearchResults.HierarchicalFacet[] + ): HierarchicalMenuItem[] { + return facetValues + .slice(0, getLimit()) + .map( + ({ name: label, escapedValue: value, data, path, ...subValue }) => { + const item: HierarchicalMenuItem = { + ...subValue, + value, + label, + data: null, + }; + if (Array.isArray(data)) { + item.data = _prepareFacetValues(data); + } + return item; + } + ); + } + + function _hasMoreItems( + facetValues: SearchResults.HierarchicalFacet[], + maxValuesPerFacet: number + ): boolean { + const currentLimit = getLimit(); + + return ( + // Check if we have exhaustive items at this level + // If the limit is the max number of facet retrieved it is impossible to know + // if the facets are exhaustive. The only moment we are sure it is exhaustive + // is when it is strictly under the number requested unless we know that another + // widget has requested more values (maxValuesPerFacet > getLimit()). + !(maxValuesPerFacet > currentLimit + ? facetValues.length <= currentLimit + : facetValues.length < currentLimit) || + // Check if any of the children are not exhaustive. + facetValues + .slice(0, limit) + .some( + (item) => + Array.isArray(item.data) && + item.data.length > 0 && + _hasMoreItems(item.data, maxValuesPerFacet) + ) + ); + } + + return { + $$type: 'ais.hierarchicalMenu', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + toggleShowMore = createToggleShowMore(renderOptions, this); + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return state + .removeHierarchicalFacet(hierarchicalFacetName) + .setQueryParameter('maxValuesPerFacet', undefined); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + hierarchicalMenu: { + ...renderState.hierarchicalMenu, + [hierarchicalFacetName]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState({ + results, + state, + createURL, + instantSearchInstance, + helper, + }) { + let items: HierarchicalMenuRenderState['items'] = []; + let canToggleShowMore = false; + + // Bind createURL to this specific attribute + const _createURL = (facetValue: string) => { + return createURL((uiState) => + this.getWidgetUiState(uiState, { + searchParameters: state + .resetPage() + .toggleFacetRefinement(hierarchicalFacetName, facetValue), + helper, + }) + ); + }; + + if (!sendEvent) { + sendEvent = createSendEventForFacet({ + instantSearchInstance, + helper, + attribute(facetValue) { + const index = facetValue.split(separator).length - 1; + + return attributes[index]; + }, + widgetType: this.$$type, + }); + } + + if (!_refine) { + _refine = function (facetValue) { + sendEvent('click:internal', facetValue); + helper + .toggleFacetRefinement(hierarchicalFacetName, facetValue) + .search(); + }; + } + + if (results) { + const facetValues = results.getFacetValues(hierarchicalFacetName, { + sortBy, + facetOrdering: sortBy === DEFAULT_SORT, + }); + const facetItems = + facetValues && !Array.isArray(facetValues) && facetValues.data + ? facetValues.data + : []; + + // Check if there are more items to show at any level + // This checks both the exhaustiveness of items retrieved from the API + // and whether there are hidden items at any visible child level + const hasMoreItems = _hasMoreItems( + facetItems, + state.maxValuesPerFacet || 0 + ); + + canToggleShowMore = showMore && (isShowingMore || hasMoreItems); + + items = transformItems(_prepareFacetValues(facetItems), { + results, + }); + } + + return { + items, + refine: _refine, + canRefine: items.length > 0, + createURL: _createURL, + sendEvent, + widgetParams, + isShowingMore, + toggleShowMore: cachedToggleShowMore, + canToggleShowMore, + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + const path = searchParameters.getHierarchicalFacetBreadcrumb( + hierarchicalFacetName + ); + + return removeEmptyRefinementsFromUiState( + { + ...uiState, + hierarchicalMenu: { + ...uiState.hierarchicalMenu, + [hierarchicalFacetName]: path, + }, + }, + hierarchicalFacetName + ); + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const values = + uiState.hierarchicalMenu && + uiState.hierarchicalMenu[hierarchicalFacetName]; + + if ( + searchParameters.isConjunctiveFacet(hierarchicalFacetName) || + searchParameters.isDisjunctiveFacet(hierarchicalFacetName) + ) { + warning( + false, + `HierarchicalMenu: Attribute "${hierarchicalFacetName}" is already used by another widget applying conjunctive or disjunctive faceting. +As this is not supported, please make sure to remove this other widget or this HierarchicalMenu widget will not work at all.` + ); + + return searchParameters; + } + + if (searchParameters.isHierarchicalFacet(hierarchicalFacetName)) { + const facet = searchParameters.getHierarchicalFacetByName( + hierarchicalFacetName + ); + + warning( + isEqual(facet.attributes, attributes) && + facet.separator === separator && + facet.rootPath === rootPath, + 'Using Breadcrumb and HierarchicalMenu on the same facet with different options overrides the configuration of the HierarchicalMenu.' + ); + } + + const withFacetConfiguration = searchParameters + .removeHierarchicalFacet(hierarchicalFacetName) + .addHierarchicalFacet({ + name: hierarchicalFacetName, + attributes, + separator, + rootPath, + showParentLevel, + }); + + const currentMaxValuesPerFacet = + withFacetConfiguration.maxValuesPerFacet || 0; + + const nextMaxValuesPerFacet = Math.max( + currentMaxValuesPerFacet, + showMore ? showMoreLimit : limit + ); + + const withMaxValuesPerFacet = + withFacetConfiguration.setQueryParameter( + 'maxValuesPerFacet', + nextMaxValuesPerFacet + ); + + if (!values) { + return withMaxValuesPerFacet.setQueryParameters({ + hierarchicalFacetsRefinements: { + ...withMaxValuesPerFacet.hierarchicalFacetsRefinements, + [hierarchicalFacetName]: [], + }, + }); + } + + return withMaxValuesPerFacet.addHierarchicalFacetRefinement( + hierarchicalFacetName, + values.join(separator) + ); + }, + }; + }; + }; + +function removeEmptyRefinementsFromUiState( + indexUiState: IndexUiState, + attribute: string +): IndexUiState { + if (!indexUiState.hierarchicalMenu) { + return indexUiState; + } + + if ( + !indexUiState.hierarchicalMenu[attribute] || + indexUiState.hierarchicalMenu[attribute].length === 0 + ) { + delete indexUiState.hierarchicalMenu[attribute]; + } + + if (Object.keys(indexUiState.hierarchicalMenu).length === 0) { + delete indexUiState.hierarchicalMenu; + } + + return indexUiState; +} + +export default connectHierarchicalMenu; diff --git a/packages/instantsearch-core/src/connectors/hits-per-page/connectHitsPerPage.ts b/packages/instantsearch-core/src/connectors/hits-per-page/connectHitsPerPage.ts new file mode 100644 index 00000000000..28d7f12f0b4 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/hits-per-page/connectHitsPerPage.ts @@ -0,0 +1,311 @@ +import { + checkRendering, + warning, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; + +import type { + Connector, + TransformItems, + CreateURL, + InitOptions, + RenderOptions, + WidgetRenderState, + Widget, +} from '../../types'; +import type { + AlgoliaSearchHelper, + SearchParameters, +} from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'hits-per-page', + connector: true, +}); + +export type HitsPerPageRenderStateItem = { + /** + * Label to display in the option. + */ + label: string; + + /** + * Number of hits to display per page. + */ + value: number; + + /** + * Indicates if it's the current refined value. + */ + isRefined: boolean; +}; + +export type HitsPerPageConnectorParamsItem = { + /** + * Label to display in the option. + */ + label: string; + + /** + * Number of hits to display per page. + */ + value: number; + + /** + * The default hits per page on first search. + * + * @default false + */ + default?: boolean; +}; + +export type HitsPerPageConnectorParams = { + /** + * Array of objects defining the different values and labels. + */ + items: HitsPerPageConnectorParamsItem[]; + + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; +}; + +export type HitsPerPageRenderState = { + /** + * Array of objects defining the different values and labels. + */ + items: HitsPerPageRenderStateItem[]; + + /** + * Creates the URL for a single item name in the list. + */ + createURL: CreateURL; + + /** + * Sets the number of hits per page and triggers a search. + */ + refine: (value: number) => void; + + /** + * Indicates whether or not the search has results. + * @deprecated Use `canRefine` instead. + */ + hasNoResults: boolean; + + /** + * Indicates if search state can be refined. + */ + canRefine: boolean; +}; + +export type HitsPerPageWidgetDescription = { + $$type: 'ais.hitsPerPage'; + renderState: HitsPerPageRenderState; + indexRenderState: { + hitsPerPage: WidgetRenderState< + HitsPerPageRenderState, + HitsPerPageConnectorParams + >; + }; + indexUiState: { + hitsPerPage: number; + }; +}; + +export type HitsPerPageConnector = Connector< + HitsPerPageWidgetDescription, + HitsPerPageConnectorParams +>; + +const connectHitsPerPage: HitsPerPageConnector = function connectHitsPerPage( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + items: userItems, + transformItems = ((items) => items) as NonNullable< + HitsPerPageConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if (!Array.isArray(userItems)) { + throw new Error( + withUsage('The `items` option expects an array of objects.') + ); + } + + let items = userItems; + + const defaultItems = items.filter((item) => item.default === true); + + if (defaultItems.length === 0) { + throw new Error( + withUsage(`A default value must be specified in \`items\`.`) + ); + } + + if (defaultItems.length > 1) { + throw new Error( + withUsage('More than one default value is specified in `items`.') + ); + } + + const defaultItem = defaultItems[0]; + + const normalizeItems = ({ hitsPerPage }: SearchParameters) => { + return items.map((item) => ({ + ...item, + isRefined: Number(item.value) === Number(hitsPerPage), + })); + }; + + type ConnectorState = { + getRefine: ( + helper: AlgoliaSearchHelper + ) => (value: HitsPerPageConnectorParamsItem['value']) => any; + createURLFactory: (props: { + state: SearchParameters; + createURL: (InitOptions | RenderOptions)['createURL']; + getWidgetUiState: NonNullable; + helper: AlgoliaSearchHelper; + }) => HitsPerPageRenderState['createURL']; + }; + + const connectorState: ConnectorState = { + getRefine: (helper) => (value) => { + return !value && value !== 0 + ? helper.setQueryParameter('hitsPerPage', undefined).search() + : helper.setQueryParameter('hitsPerPage', value).search(); + }, + createURLFactory: + ({ state, createURL, getWidgetUiState, helper }) => + (value) => + createURL((uiState) => + getWidgetUiState(uiState, { + searchParameters: state + .resetPage() + .setQueryParameter( + 'hitsPerPage', + !value && value !== 0 ? undefined : value + ), + helper, + }) + ), + }; + + return { + $$type: 'ais.hitsPerPage', + + init(initOptions) { + const { state, instantSearchInstance } = initOptions; + + const isCurrentInOptions = items.some( + (item) => Number(state.hitsPerPage) === Number(item.value) + ); + + if (!isCurrentInOptions) { + warning( + state.hitsPerPage !== undefined, + ` +\`hitsPerPage\` is not defined. +The option \`hitsPerPage\` needs to be set using the \`configure\` widget. + +Learn more: https://www.algolia.com/doc/api-reference/widgets/hits-per-page/js/ + ` + ); + + warning( + false, + ` +The \`items\` option of \`hitsPerPage\` does not contain the "hits per page" value coming from the state: ${state.hitsPerPage}. + +You may want to add another entry to the \`items\` option with this value.` + ); + + items = [ + // The helper will convert the empty string to `undefined`. + { value: '' as unknown as number, label: '' }, + ...items, + ]; + } + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return state.setQueryParameter('hitsPerPage', undefined); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + hitsPerPage: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ state, results, createURL, helper }) { + const canRefine = results ? results.nbHits > 0 : false; + + return { + items: transformItems(normalizeItems(state), { results }), + refine: connectorState.getRefine(helper), + createURL: connectorState.createURLFactory({ + state, + createURL, + getWidgetUiState: this.getWidgetUiState, + helper, + }), + hasNoResults: !canRefine, + canRefine, + widgetParams, + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + const hitsPerPage = searchParameters.hitsPerPage; + + if (hitsPerPage === undefined || hitsPerPage === defaultItem.value) { + return uiState; + } + + return { + ...uiState, + hitsPerPage, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + return searchParameters.setQueryParameters({ + hitsPerPage: uiState.hitsPerPage || defaultItem.value, + }); + }, + }; + }; +}; + +export default connectHitsPerPage; diff --git a/packages/instantsearch-core/src/connectors/hits/connectHits.ts b/packages/instantsearch-core/src/connectors/hits/connectHits.ts new file mode 100644 index 00000000000..e3f79074b3f --- /dev/null +++ b/packages/instantsearch-core/src/connectors/hits/connectHits.ts @@ -0,0 +1,236 @@ +import { + escapeHits, + TAG_PLACEHOLDER, + checkRendering, + createDocumentationMessageGenerator, + addAbsolutePosition, + addQueryID, + createSendEventForHits, + createBindEventForHits, + noop, +} from '../../lib/utils'; + +import type { SendEventForHits, BindEventForHits } from '../../lib/utils'; +import type { + TransformItems, + Connector, + Hit, + WidgetRenderState, + BaseHit, + Unmounter, + Renderer, + IndexRenderState, +} from '../../types'; +import type { Banner, SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'hits', + connector: true, +}); + +export type HitsRenderState = BaseHit> = { + /** + * The matched hits from Algolia API. + * @deprecated use `items` instead + */ + hits: Array>; + + /** + * The matched hits from Algolia API. + */ + items: Array>; + + /** + * The response from the Algolia API. + */ + results?: SearchResults>; + + /** + * The banner to display above the hits. + */ + banner?: Banner; + + /** + * Sends an event to the Insights middleware. + */ + sendEvent: SendEventForHits; + + /** + * Returns a string for the `data-insights-event` attribute for the Insights middleware + */ + bindEvent: BindEventForHits; +}; + +export type HitsConnectorParams = BaseHit> = { + /** + * Whether to escape HTML tags from hits string values. + * + * @default true + */ + escapeHTML?: boolean; + + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems>; +}; + +export type HitsWidgetDescription = BaseHit> = + { + $$type: 'ais.hits'; + renderState: HitsRenderState; + indexRenderState: { + hits: WidgetRenderState, HitsConnectorParams>; + }; + }; + +export type HitsConnector = BaseHit> = + Connector, HitsConnectorParams>; + +export default (function connectHits( + renderFn: Renderer, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return = BaseHit>( + widgetParams: TWidgetParams & HitsConnectorParams + ) => { + const { + // @MAJOR: this can default to false + escapeHTML = true, + transformItems = ((items) => items) as NonNullable< + HitsConnectorParams['transformItems'] + >, + } = widgetParams || {}; + let sendEvent: SendEventForHits; + let bindEvent: BindEventForHits; + + return { + $$type: 'ais.hits', + + init(initOptions) { + 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.items); + }, + + getRenderState( + renderState, + renderOptions + // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition + ): IndexRenderState & HitsWidgetDescription['indexRenderState'] { + return { + ...renderState, + hits: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ results, helper, instantSearchInstance }) { + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + helper, + widgetType: this.$$type, + }); + } + + if (!bindEvent) { + bindEvent = createBindEventForHits({ + helper, + widgetType: this.$$type, + instantSearchInstance, + }); + } + + if (!results) { + return { + hits: [], + items: [], + results: undefined, + banner: undefined, + sendEvent, + bindEvent, + widgetParams, + }; + } + + if (escapeHTML && results.hits.length > 0) { + results.hits = escapeHits(results.hits); + } + + const hitsWithAbsolutePosition = addAbsolutePosition( + results.hits, + results.page, + results.hitsPerPage + ); + + const hitsWithAbsolutePositionAndQueryID = addQueryID( + hitsWithAbsolutePosition, + results.queryID + ); + + const items = transformItems(hitsWithAbsolutePositionAndQueryID, { + results, + }); + + const banner = results.renderingContent?.widgets?.banners?.[0]; + + return { + hits: items, + items, + results, + banner, + sendEvent, + bindEvent, + widgetParams, + }; + }, + + dispose({ state }) { + unmountFn(); + + if (!escapeHTML) { + return state; + } + + return state.setQueryParameters( + Object.keys(TAG_PLACEHOLDER).reduce( + (acc, key) => ({ + ...acc, + [key]: undefined, + }), + {} + ) + ); + }, + + getWidgetSearchParameters(state, _uiState) { + if (!escapeHTML) { + return state; + } + + // @MAJOR: set this globally, not in the Hits widget to allow Hits to be conditionally used + return state.setQueryParameters(TAG_PLACEHOLDER); + }, + }; + }; +} satisfies HitsConnector); diff --git a/packages/instantsearch-core/src/connectors/hits/connectHitsWithInsights.ts b/packages/instantsearch-core/src/connectors/hits/connectHitsWithInsights.ts new file mode 100644 index 00000000000..12f24b9dc68 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/hits/connectHitsWithInsights.ts @@ -0,0 +1,21 @@ +import { withInsights } from '../../lib/insights'; + +import connectHits from './connectHits'; + +import type { Connector } from '../../types'; +import type { HitsConnectorParams, HitsWidgetDescription } from './connectHits'; + +/** + * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need + * Connector<...> imported in this file, even though it is only used implicitly. + * This _uses_ Connector<...> so it is not accidentally removed by someone. + */ +// eslint-disable-next-line no-unused-vars +declare type ImportWorkaround = Connector< + HitsWidgetDescription, + HitsConnectorParams +>; + +const connectHitsWithInsights = withInsights(connectHits); + +export default connectHitsWithInsights; diff --git a/packages/instantsearch-core/src/connectors/index.ts b/packages/instantsearch-core/src/connectors/index.ts new file mode 100644 index 00000000000..1c4394f1ef2 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/index.ts @@ -0,0 +1,71 @@ +export { default as connectAutocomplete } from './autocomplete/connectAutocomplete'; +export type * from './autocomplete/connectAutocomplete'; +export { default as connectBreadcrumb } from './breadcrumb/connectBreadcrumb'; +export type * from './breadcrumb/connectBreadcrumb'; +export { default as connectChat } from './chat/connectChat'; +export type * from './chat/connectChat'; +export { default as connectClearRefinements } from './clear-refinements/connectClearRefinements'; +export type * from './clear-refinements/connectClearRefinements'; +export { default as connectConfigure } from './configure/connectConfigure'; +export type * from './configure/connectConfigure'; +export { default as connectCurrentRefinements } from './current-refinements/connectCurrentRefinements'; +export type * from './current-refinements/connectCurrentRefinements'; +export { default as connectDynamicWidgets } from './dynamic-widgets/connectDynamicWidgets'; +export type * from './dynamic-widgets/connectDynamicWidgets'; +export { default as connectFeeds } from './feeds/connectFeeds'; +export type * from './feeds/connectFeeds'; +export { default as connectFilterSuggestions } from './filter-suggestions/connectFilterSuggestions'; +export type * from './filter-suggestions/connectFilterSuggestions'; +export { default as connectFrequentlyBoughtTogether } from './frequently-bought-together/connectFrequentlyBoughtTogether'; +export type * from './frequently-bought-together/connectFrequentlyBoughtTogether'; +export { default as connectGeoSearch } from './geo-search/connectGeoSearch'; +export type * from './geo-search/connectGeoSearch'; +export { default as connectHierarchicalMenu } from './hierarchical-menu/connectHierarchicalMenu'; +export type * from './hierarchical-menu/connectHierarchicalMenu'; +export { default as connectHits } from './hits/connectHits'; +export type * from './hits/connectHits'; +export { default as connectHitsWithInsights } from './hits/connectHitsWithInsights'; +export type * from './hits/connectHitsWithInsights'; +export { default as connectHitsPerPage } from './hits-per-page/connectHitsPerPage'; +export type * from './hits-per-page/connectHitsPerPage'; +export { default as connectInfiniteHits } from './infinite-hits/connectInfiniteHits'; +export type * from './infinite-hits/connectInfiniteHits'; +export { default as connectInfiniteHitsWithInsights } from './infinite-hits/connectInfiniteHitsWithInsights'; +export type * from './infinite-hits/connectInfiniteHitsWithInsights'; +export { default as connectLookingSimilar } from './looking-similar/connectLookingSimilar'; +export type * from './looking-similar/connectLookingSimilar'; +export { default as connectMenu } from './menu/connectMenu'; +export type * from './menu/connectMenu'; +export { default as connectNumericMenu } from './numeric-menu/connectNumericMenu'; +export type * from './numeric-menu/connectNumericMenu'; +export { default as connectPagination } from './pagination/connectPagination'; +export type * from './pagination/connectPagination'; +export { default as connectPoweredBy } from './powered-by/connectPoweredBy'; +export type * from './powered-by/connectPoweredBy'; +export { default as connectQueryRules } from './query-rules/connectQueryRules'; +export type * from './query-rules/connectQueryRules'; +export { default as connectRange } from './range/connectRange'; +export type * from './range/connectRange'; +export { default as connectRatingMenu } from './rating-menu/connectRatingMenu'; +export type * from './rating-menu/connectRatingMenu'; +export { default as connectRefinementList } from './refinement-list/connectRefinementList'; +export type * from './refinement-list/connectRefinementList'; +export { default as connectRelatedProducts } from './related-products/connectRelatedProducts'; +export type * from './related-products/connectRelatedProducts'; +export { default as connectRelevantSort } from './relevant-sort/connectRelevantSort'; +export type * from './relevant-sort/connectRelevantSort'; +export { default as connectSearchBox } from './search-box/connectSearchBox'; +export type * from './search-box/connectSearchBox'; +export { default as connectSortBy } from './sort-by/connectSortBy'; +export type * from './sort-by/connectSortBy'; +export { default as connectStats } from './stats/connectStats'; +export type * from './stats/connectStats'; +export { default as connectToggleRefinement } from './toggle-refinement/connectToggleRefinement'; +export type * from './toggle-refinement/connectToggleRefinement'; +export { default as connectTrendingFacets } from './trending-facets/connectTrendingFacets'; +export type * from './trending-facets/connectTrendingFacets'; +export { default as connectTrendingItems } from './trending-items/connectTrendingItems'; +export type * from './trending-items/connectTrendingItems'; +export { default as connectVoiceSearch } from './voice-search/connectVoiceSearch'; +export type * from './voice-search/connectVoiceSearch'; +export * from './feeds/FeedContainer'; diff --git a/packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHits.ts b/packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHits.ts new file mode 100644 index 00000000000..2deea5c64f2 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHits.ts @@ -0,0 +1,510 @@ +import { + escapeHits, + TAG_PLACEHOLDER, + checkRendering, + createDocumentationMessageGenerator, + isEqual, + addAbsolutePosition, + addQueryID, + noop, + createSendEventForHits, + createBindEventForHits, + walkIndex, + isTwoPassWidget, +} from '../../lib/utils'; + +import type { SendEventForHits, BindEventForHits } from '../../lib/utils'; +import type { + Connector, + TransformItems, + Hit, + WidgetRenderState, + BaseHit, + Renderer, + Unmounter, + UnknownWidgetParams, + IndexRenderState, +} from '../../types'; +import type { + Banner, + AlgoliaSearchHelper as Helper, + PlainSearchParameters, + SearchParameters, + SearchResults, +} from 'algoliasearch-helper'; + +export type InfiniteHitsCachedHits> = { + [page: number]: Array>; +}; + +type Read> = ({ + state, +}: { + state: PlainSearchParameters; +}) => InfiniteHitsCachedHits | null; + +type Write> = ({ + state, + hits, +}: { + state: PlainSearchParameters; + hits: InfiniteHitsCachedHits; +}) => void; + +export type InfiniteHitsCache = BaseHit> = { + read: Read; + write: Write; +}; + +export type InfiniteHitsConnectorParams< + THit extends NonNullable = BaseHit +> = { + /** + * Escapes HTML entities from hits string values. + * + * @default `true` + */ + escapeHTML?: boolean; + + /** + * Enable the button to load previous results. + * + * @default `false` + */ + showPrevious?: boolean; + + /** + * Receives the items, and is called before displaying them. + * Useful for mapping over the items to transform, and remove or reorder them. + */ + transformItems?: TransformItems>; + + /** + * Reads and writes hits from/to cache. + * When user comes back to the search page after leaving for product page, + * this helps restore InfiniteHits and its scroll position. + */ + cache?: InfiniteHitsCache; +}; + +export type InfiniteHitsRenderState< + THit extends NonNullable = BaseHit +> = { + /** + * Loads the previous results. + */ + showPrevious: () => void; + + /** + * Loads the next page of hits. + */ + showMore: () => void; + + /** + * Indicates whether the first page of hits has been reached. + */ + isFirstPage: boolean; + + /** + * Indicates whether the last page of hits has been reached. + */ + isLastPage: boolean; + + /** + * Send event to insights middleware + */ + sendEvent: SendEventForHits; + + /** + * Returns a string of data-insights-event attribute for insights middleware + */ + bindEvent: BindEventForHits; + + /** + * Hits for the current page + */ + currentPageHits: Array>; + + /** + * Hits for current and cached pages + * @deprecated use `items` instead + */ + hits: Array>; + + /** + * Hits for current and cached pages + */ + items: Array>; + + /** + * The response from the Algolia API. + */ + results?: SearchResults> | null; + + /** + * The banner to display above the hits. + */ + banner?: Banner; +}; + +const withUsage = createDocumentationMessageGenerator({ + name: 'infinite-hits', + connector: true, +}); + +export type InfiniteHitsWidgetDescription< + THit extends NonNullable = BaseHit +> = { + $$type: 'ais.infiniteHits'; + renderState: InfiniteHitsRenderState; + indexRenderState: { + infiniteHits: WidgetRenderState< + InfiniteHitsRenderState, + InfiniteHitsConnectorParams + >; + }; + indexUiState: { + page: number; + }; +}; + +export type InfiniteHitsConnector = BaseHit> = + Connector< + InfiniteHitsWidgetDescription, + InfiniteHitsConnectorParams + >; + +function getStateWithoutPage(state: PlainSearchParameters) { + const { page, ...rest } = state || {}; + return rest; +} + +function normalizeState(state: PlainSearchParameters) { + const { clickAnalytics, userToken, ...rest } = state || {}; + return rest; +} + +function getInMemoryCache< + THit extends NonNullable +>(): InfiniteHitsCache { + let cachedHits: InfiniteHitsCachedHits | null = null; + let cachedState: PlainSearchParameters | null = null; + return { + read({ state }) { + return isEqual(cachedState, getStateWithoutPage(state)) + ? cachedHits + : null; + }, + write({ state, hits }) { + cachedState = getStateWithoutPage(state); + cachedHits = hits; + }, + }; +} + +function extractHitsFromCachedHits>( + cachedHits: InfiniteHitsCachedHits +) { + return Object.keys(cachedHits) + .map(Number) + .sort((a, b) => a - b) + .reduce((acc: Array>, page) => { + return acc.concat(cachedHits[page]); + }, []); +} + +export default (function connectInfiniteHits< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return = BaseHit>( + widgetParams: TWidgetParams & InfiniteHitsConnectorParams + ) => { + const { + // @MAJOR: this can default to false + escapeHTML = true, + transformItems = ((items) => items) as NonNullable< + InfiniteHitsConnectorParams['transformItems'] + >, + cache = getInMemoryCache(), + } = widgetParams || {}; + let showPrevious: () => void; + let showMore: () => void; + let sendEvent: SendEventForHits; + let bindEvent: BindEventForHits; + const getFirstReceivedPage = ( + state: SearchParameters, + cachedHits: InfiniteHitsCachedHits + ) => { + const { page = 0 } = state; + const pages = Object.keys(cachedHits).map(Number); + if (pages.length === 0) { + return page; + } else { + return Math.min(page, ...pages); + } + }; + const getLastReceivedPage = ( + state: SearchParameters, + cachedHits: InfiniteHitsCachedHits + ) => { + const { page = 0 } = state; + const pages = Object.keys(cachedHits).map(Number); + if (pages.length === 0) { + return page; + } else { + return Math.max(page, ...pages); + } + }; + + const getShowPrevious = + ( + helper: Helper, + getCachedHits: () => InfiniteHitsCachedHits + ): (() => void) => + () => { + const cachedHits = getCachedHits(); + // Using the helper's `overrideStateWithoutTriggeringChangeEvent` method + // avoid updating the browser URL when the user displays the previous page. + helper + .overrideStateWithoutTriggeringChangeEvent({ + ...helper.state, + page: getFirstReceivedPage(helper.state, cachedHits) - 1, + }) + .searchWithoutTriggeringOnStateChange(); + }; + + const getShowMore = + ( + helper: Helper, + getCachedHits: () => InfiniteHitsCachedHits + ): (() => void) => + () => { + const cachedHits = getCachedHits(); + helper + .setPage(getLastReceivedPage(helper.state, cachedHits) + 1) + .search(); + }; + + return { + $$type: 'ais.infiniteHits', + + init(initOptions) { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + const widgetRenderState = this.getWidgetRenderState(renderOptions); + + renderFn( + { + ...widgetRenderState, + instantSearchInstance, + }, + false + ); + + sendEvent('view:internal', widgetRenderState.currentPageHits); + }, + + getRenderState( + renderState, + renderOptions + // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition + ): IndexRenderState & InfiniteHitsWidgetDescription['indexRenderState'] { + return { + ...renderState, + infiniteHits: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ + results, + helper, + parent, + state: existingState, + instantSearchInstance, + }) { + const getCacheHits = () => { + const state = parent.getPreviousState() || existingState; + return cache.read({ state: normalizeState(state) }) || {}; + }; + + let isFirstPage: boolean; + let currentPageHits: Array> = []; + /** + * We bail out of optimistic UI here, as the cache is based on search + * parameters, and we don't want to invalidate the cache when the search + * is loading. + */ + const state = parent.getPreviousState() || existingState; + + const cachedHits = getCacheHits(); + + const banner = results?.renderingContent?.widgets?.banners?.[0]; + + if (!showPrevious) { + showPrevious = () => getShowPrevious(helper, getCacheHits)(); + showMore = () => getShowMore(helper, getCacheHits)(); + } + + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + helper, + widgetType: this.$$type, + }); + bindEvent = createBindEventForHits({ + helper, + widgetType: this.$$type, + instantSearchInstance, + }); + } + + if (!results) { + isFirstPage = + state.page === undefined || + getFirstReceivedPage(state, cachedHits) === 0; + } else { + const { page = 0 } = state; + + if (escapeHTML && results.hits.length > 0) { + results.hits = escapeHits(results.hits); + } + + const hitsWithAbsolutePosition = addAbsolutePosition( + results.hits, + results.page, + results.hitsPerPage + ); + + const hitsWithAbsolutePositionAndQueryID = addQueryID( + hitsWithAbsolutePosition, + results.queryID + ); + + const transformedHits = transformItems( + hitsWithAbsolutePositionAndQueryID, + { results } + ); + + /* + With dynamic widgets, facets are not included in the state before their relevant widgets are mounted. Until then, we need to bail out of writing this incomplete state representation in cache. + */ + let hasTwoPassWidgets = false; + walkIndex(instantSearchInstance.mainIndex, (indexWidget) => { + if ( + !hasTwoPassWidgets && + indexWidget.getWidgets().some(isTwoPassWidget) + ) { + hasTwoPassWidgets = true; + } + }); + + const hasNoFacets = + !state.disjunctiveFacets?.length && + !(state.facets || []).filter((f) => f !== '*').length && + !state.hierarchicalFacets?.length; + + if ( + cachedHits[page] === undefined && + !results.__isArtificial && + instantSearchInstance.status === 'idle' && + !(hasTwoPassWidgets && hasNoFacets) + ) { + cachedHits[page] = transformedHits; + cache.write({ state: normalizeState(state), hits: cachedHits }); + } + currentPageHits = transformedHits; + + isFirstPage = getFirstReceivedPage(state, cachedHits) === 0; + } + + const items = extractHitsFromCachedHits(cachedHits); + const isLastPage = results + ? results.nbPages <= getLastReceivedPage(state, cachedHits) + 1 + : true; + + return { + hits: items, + items, + currentPageHits, + sendEvent, + bindEvent, + banner, + results: results || undefined, + showPrevious, + showMore, + isFirstPage, + isLastPage, + widgetParams, + }; + }, + + dispose({ state }) { + unmountFn(); + + const stateWithoutPage = state.setQueryParameter('page', undefined); + + if (!escapeHTML) { + return stateWithoutPage; + } + + return stateWithoutPage.setQueryParameters( + Object.keys(TAG_PLACEHOLDER).reduce( + (acc, key) => ({ + ...acc, + [key]: undefined, + }), + {} + ) + ); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const page = searchParameters.page || 0; + + if (!page) { + // return without adding `page` to uiState + // because we don't want `page=1` in the URL + return uiState; + } + + return { + ...uiState, + // The page in the UI state is incremented by one + // to expose the user value (not `0`). + page: page + 1, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + let widgetSearchParameters = searchParameters; + + if (escapeHTML) { + // @MAJOR: set this globally, not in the InfiniteHits widget to allow InfiniteHits to be conditionally used + widgetSearchParameters = + searchParameters.setQueryParameters(TAG_PLACEHOLDER); + } + + // The page in the search parameters is decremented by one + // to get to the actual parameter value from the UI state. + const page = uiState.page ? uiState.page - 1 : 0; + + return widgetSearchParameters.setQueryParameter('page', page); + }, + }; + }; +} satisfies InfiniteHitsConnector); diff --git a/packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts b/packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts new file mode 100644 index 00000000000..acf91b7a831 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts @@ -0,0 +1,24 @@ +import { withInsights } from '../../lib/insights'; + +import connectInfiniteHits from './connectInfiniteHits'; + +import type { Connector } from '../../types'; +import type { + InfiniteHitsWidgetDescription, + InfiniteHitsConnectorParams, +} from './connectInfiniteHits'; + +/** + * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need + * Connector<...> imported in this file, even though it is only used implicitly. + * This _uses_ Connector<...> so it is not accidentally removed by someone. + */ +// eslint-disable-next-line no-unused-vars +declare type ImportWorkaround = Connector< + InfiniteHitsWidgetDescription, + InfiniteHitsConnectorParams +>; + +const connectInfiniteHitsWithInsights = withInsights(connectInfiniteHits); + +export default connectInfiniteHitsWithInsights; diff --git a/packages/instantsearch-core/src/connectors/looking-similar/connectLookingSimilar.ts b/packages/instantsearch-core/src/connectors/looking-similar/connectLookingSimilar.ts new file mode 100644 index 00000000000..e5bc783bbb2 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/looking-similar/connectLookingSimilar.ts @@ -0,0 +1,235 @@ +import { + createDocumentationMessageGenerator, + checkRendering, + noop, + escapeHits, + TAG_PLACEHOLDER, + createSendEventForHits, + addAbsolutePosition, + addQueryID, +} from '../../lib/utils'; + +import type { SendEventForHits } from '../../lib/utils'; +import type { + Connector, + TransformItems, + BaseHit, + Renderer, + Unmounter, + UnknownWidgetParams, + RecommendResponse, + Hit, + AlgoliaHit, +} from '../../types'; +import type { PlainSearchParameters } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'looking-similar', + connector: true, +}); + +export type LookingSimilarRenderState< + THit extends NonNullable = BaseHit +> = { + /** + * The matched recommendations from the Algolia API. + */ + items: Array>; + /** + * Sends an event to the Insights middleware. + */ + sendEvent: SendEventForHits; +}; + +export type LookingSimilarConnectorParams< + THit extends NonNullable = BaseHit +> = { + /** + * The `objectIDs` of the items to get similar looking products from. + */ + objectIDs: string[]; + /** + * The number of recommendations to retrieve. + */ + limit?: 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' + >; + /** + * Whether to escape HTML tags from items string values. + * + * @default true + */ + escapeHTML?: boolean; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems< + Hit, + { results: RecommendResponse> } + >; +}; + +export type LookingSimilarWidgetDescription< + THit extends NonNullable = BaseHit +> = { + $$type: 'ais.lookingSimilar'; + renderState: LookingSimilarRenderState; +}; + +export type LookingSimilarConnector< + THit extends NonNullable = BaseHit +> = Connector< + LookingSimilarWidgetDescription, + LookingSimilarConnectorParams +>; + +export default (function connectLookingSimilar< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer< + LookingSimilarRenderState, + TWidgetParams & LookingSimilarConnectorParams + >, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return = BaseHit>( + widgetParams: TWidgetParams & LookingSimilarConnectorParams + ) => { + const { + // @MAJOR: this can default to false + escapeHTML = true, + objectIDs, + limit, + threshold, + fallbackParameters, + queryParameters, + transformItems = ((items) => items) as NonNullable< + LookingSimilarConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if (!objectIDs || objectIDs.length === 0) { + throw new Error(withUsage('The `objectIDs` option is required.')); + } + + let sendEvent: SendEventForHits; + + return { + dependsOn: 'recommend', + $$type: 'ais.lookingSimilar', + + 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, helper, instantSearchInstance }) { + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + helper, + widgetType: this.$$type, + }); + } + if (results === null || results === undefined) { + return { items: [], widgetParams, sendEvent }; + } + + if (escapeHTML && results.hits.length > 0) { + results.hits = escapeHits(results.hits); + } + + const itemsWithAbsolutePosition = addAbsolutePosition( + results.hits, + 0, + 1 + ); + + const itemsWithAbsolutePositionAndQueryID = addQueryID( + itemsWithAbsolutePosition, + results.queryID + ); + + const transformedItems = transformItems( + itemsWithAbsolutePositionAndQueryID, + { + results: results as RecommendResponse>, + } + ); + + return { + items: transformedItems, + widgetParams, + sendEvent, + }; + }, + + dispose({ recommendState }) { + unmountFn(); + return recommendState.removeParams(this.$$id!); + }, + + getWidgetParameters(state) { + return objectIDs.reduce( + (acc, objectID) => + acc.addLookingSimilar({ + objectID, + maxRecommendations: limit, + threshold, + fallbackParameters: fallbackParameters + ? { + ...fallbackParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + } + : undefined, + queryParameters: { + ...queryParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + }, + $$id: this.$$id!, + }), + state.removeParams(this.$$id!) + ); + }, + }; + }; +} satisfies LookingSimilarConnector); diff --git a/packages/instantsearch-core/src/connectors/menu/connectMenu.ts b/packages/instantsearch-core/src/connectors/menu/connectMenu.ts new file mode 100644 index 00000000000..93aee24dbb2 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/menu/connectMenu.ts @@ -0,0 +1,422 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + createSendEventForFacet, + noop, + warning, +} from '../../lib/utils'; + +import type { SendEventForFacet } from '../../lib/utils'; +import type { + Connector, + CreateURL, + IndexUiState, + RenderOptions, + SortBy, + TransformItems, + Widget, + WidgetRenderState, +} from '../../types'; +import type { SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'menu', + connector: true, +}); + +const DEFAULT_SORT = ['isRefined', 'name:asc']; + +export type MenuItem = { + /** + * The value of the menu item. + */ + value: string; + /** + * Human-readable value of the menu item. + */ + label: string; + /** + * Number of results matched after refinement is applied. + */ + count: number; + /** + * Indicates if the refinement is applied. + */ + isRefined: boolean; +}; + +export type MenuConnectorParams = { + /** + * Name of the attribute for faceting (eg. "free_shipping"). + */ + attribute: string; + /** + * How many facets values to retrieve. + */ + limit?: number; + /** + * Whether to display a button that expands the number of items. + */ + showMore?: boolean; + /** + * How many facets values to retrieve when `toggleShowMore` is called, this value is meant to be greater than `limit` option. + */ + showMoreLimit?: number; + /** + * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. + * + * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). + * + * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed + */ + sortBy?: SortBy; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; +}; + +export type MenuRenderState = { + /** + * The elements that can be refined for the current search results. + */ + items: MenuItem[]; + /** + * Creates the URL for a single item name in the list. + */ + createURL: CreateURL; + /** + * Filter the search to item value. + */ + refine: (value: string) => void; + /** + * True if refinement can be applied. + */ + canRefine: boolean; + /** + * True if the menu is displaying all the menu items. + */ + isShowingMore: boolean; + /** + * Toggles the number of values displayed between `limit` and `showMore.limit`. + */ + toggleShowMore: () => void; + /** + * `true` if the toggleShowMore button can be activated (enough items to display more or + * already displaying more than `limit` items) + */ + canToggleShowMore: boolean; + /** + * Send event to insights middleware + */ + sendEvent: SendEventForFacet; +}; + +export type MenuWidgetDescription = { + $$type: 'ais.menu'; + renderState: MenuRenderState; + indexRenderState: { + menu: { + [attribute: string]: WidgetRenderState< + MenuRenderState, + MenuConnectorParams + >; + }; + }; + indexUiState: { + menu: { + [attribute: string]: string; + }; + }; +}; + +export type MenuConnector = Connector< + MenuWidgetDescription, + MenuConnectorParams +>; + +/** + * **Menu** connector provides the logic to build a widget that will give the user the ability to choose a single value for a specific facet. The typical usage of menu is for navigation in categories. + * + * This connector provides a `toggleShowMore()` function to display more or less items and a `refine()` + * function to select an item. While selecting a new element, the `refine` will also unselect the + * one that is currently selected. + * + * **Requirement:** the attribute passed as `attribute` must be present in "attributes for faceting" on the Algolia dashboard or configured as attributesForFaceting via a set settings call to the Algolia API. + */ +const connectMenu: MenuConnector = function connectMenu( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + attribute, + limit = 10, + showMore = false, + showMoreLimit = 20, + sortBy = DEFAULT_SORT, + transformItems = ((items) => items) as NonNullable< + MenuConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if (!attribute) { + throw new Error(withUsage('The `attribute` option is required.')); + } + + if (showMore === true && showMoreLimit <= limit) { + throw new Error( + withUsage('The `showMoreLimit` option must be greater than `limit`.') + ); + } + + type ThisWidget = Widget< + MenuWidgetDescription & { widgetParams: typeof widgetParams } + >; + + let sendEvent: MenuRenderState['sendEvent'] | undefined; + let _createURL: MenuRenderState['createURL'] | undefined; + let _refine: MenuRenderState['refine'] | undefined; + + // Provide the same function to the `renderFn` so that way the user + // has to only bind it once when `isFirstRendering` for instance + let isShowingMore = false; + let toggleShowMore = () => {}; + function createToggleShowMore( + renderOptions: RenderOptions, + widget: ThisWidget + ) { + return () => { + isShowingMore = !isShowingMore; + widget.render!(renderOptions); + }; + } + function cachedToggleShowMore() { + toggleShowMore(); + } + + function getLimit() { + return isShowingMore ? showMoreLimit : limit; + } + + return { + $$type: 'ais.menu' as const, + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return state + .removeHierarchicalFacet(attribute) + .setQueryParameter('maxValuesPerFacet', undefined); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + menu: { + ...renderState.menu, + [attribute]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState(renderOptions) { + const { results, createURL, instantSearchInstance, helper } = + renderOptions; + + let items: MenuRenderState['items'] = []; + let canToggleShowMore = false; + + if (!sendEvent) { + sendEvent = createSendEventForFacet({ + instantSearchInstance, + helper, + attribute, + widgetType: this.$$type, + }); + } + + if (!_createURL) { + _createURL = (facetValue: string) => + createURL((uiState) => + this.getWidgetUiState(uiState, { + searchParameters: helper.state + .resetPage() + .toggleFacetRefinement(attribute, facetValue), + helper, + }) + ); + } + + if (!_refine) { + _refine = function (facetValue: string) { + const [refinedItem] = + helper.getHierarchicalFacetBreadcrumb(attribute); + sendEvent!('click:internal', facetValue ? facetValue : refinedItem); + helper + .toggleFacetRefinement( + attribute, + facetValue ? facetValue : refinedItem + ) + .search(); + }; + } + + if (renderOptions.results) { + toggleShowMore = createToggleShowMore(renderOptions, this); + } + + if (results) { + const facetValues = results.getFacetValues(attribute, { + sortBy, + facetOrdering: sortBy === DEFAULT_SORT, + }); + const facetItems = + facetValues && !Array.isArray(facetValues) && facetValues.data + ? facetValues.data + : []; + + canToggleShowMore = + showMore && (isShowingMore || facetItems.length > getLimit()); + + items = transformItems( + facetItems + .slice(0, getLimit()) + .map(({ name: label, escapedValue: value, path, ...item }) => ({ + ...item, + label, + value, + })), + { results } + ); + } + + return { + items, + createURL: _createURL, + refine: _refine, + sendEvent, + canRefine: items.length > 0, + widgetParams, + isShowingMore, + toggleShowMore: cachedToggleShowMore, + canToggleShowMore, + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + const [value] = + searchParameters.getHierarchicalFacetBreadcrumb(attribute); + + return removeEmptyRefinementsFromUiState( + { + ...uiState, + menu: { + ...uiState.menu, + [attribute]: value, + }, + }, + attribute + ); + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const value = uiState.menu && uiState.menu[attribute]; + + if ( + searchParameters.isConjunctiveFacet(attribute) || + searchParameters.isDisjunctiveFacet(attribute) + ) { + warning( + false, + `Menu: Attribute "${attribute}" is already used by another widget applying conjunctive or disjunctive faceting. +As this is not supported, please make sure to remove this other widget or this Menu widget will not work at all.` + ); + + return searchParameters; + } + + const withFacetConfiguration = searchParameters + .removeHierarchicalFacet(attribute) + .addHierarchicalFacet({ + name: attribute, + attributes: [attribute], + }); + + const currentMaxValuesPerFacet = + withFacetConfiguration.maxValuesPerFacet || 0; + + const nextMaxValuesPerFacet = Math.max( + currentMaxValuesPerFacet, + showMore ? showMoreLimit : limit + ); + + const withMaxValuesPerFacet = withFacetConfiguration.setQueryParameter( + 'maxValuesPerFacet', + nextMaxValuesPerFacet + ); + + if (!value) { + return withMaxValuesPerFacet.setQueryParameters({ + hierarchicalFacetsRefinements: { + ...withMaxValuesPerFacet.hierarchicalFacetsRefinements, + [attribute]: [], + }, + }); + } + + return withMaxValuesPerFacet.addHierarchicalFacetRefinement( + attribute, + value + ); + }, + }; + }; +}; + +function removeEmptyRefinementsFromUiState( + indexUiState: IndexUiState, + attribute: string +): IndexUiState { + if (!indexUiState.menu) { + return indexUiState; + } + + if (indexUiState.menu[attribute] === undefined) { + delete indexUiState.menu[attribute]; + } + + if (Object.keys(indexUiState.menu).length === 0) { + delete indexUiState.menu; + } + + return indexUiState; +} + +export default connectMenu; diff --git a/packages/instantsearch-core/src/connectors/numeric-menu/connectNumericMenu.ts b/packages/instantsearch-core/src/connectors/numeric-menu/connectNumericMenu.ts new file mode 100644 index 00000000000..f190de8bbcd --- /dev/null +++ b/packages/instantsearch-core/src/connectors/numeric-menu/connectNumericMenu.ts @@ -0,0 +1,507 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + isFiniteNumber, + noop, +} from '../../lib/utils'; + +import type { SendEventForFacet } from '../../lib/utils'; +import type { + Connector, + CreateURL, + IndexUiState, + InstantSearch, + TransformItems, + WidgetRenderState, +} from '../../types'; +import type { SearchParameters } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'numeric-menu', + connector: true, +}); + +export type NumericMenuConnectorParamsItem = { + /** + * Name of the option + */ + label: string; + + /** + * Higher bound of the option (<=) + */ + start?: number; + + /** + * Lower bound of the option (>=) + */ + end?: number; +}; + +export type NumericMenuRenderStateItem = { + /** + * Name of the option. + */ + label: string; + + /** + * URL encoded of the bounds object with the form `{start, end}`. + * This value can be used verbatim in the webpage and can be read by `refine` + * directly. If you want to inspect the value, you can do: + * `JSON.parse(decodeURI(value))` to get the object. + */ + value: string; + + /** + * True if the value is selected + */ + isRefined: boolean; +}; + +export type NumericMenuConnectorParams = { + /** + * Name of the attribute for filtering + */ + attribute: string; + + /** + * List of all the items + */ + items: NumericMenuConnectorParamsItem[]; + + /** + * Function to transform the items passed to the templates + */ + transformItems?: TransformItems; +}; + +export type NumericMenuRenderState = { + /** + * The list of available choices + */ + items: NumericMenuRenderStateItem[]; + + /** + * Creates URLs for the next state, the string is the name of the selected option + */ + createURL: CreateURL; + + /** + * `true` if the last search contains no result + * @deprecated Use `canRefine` instead. + */ + hasNoResults: boolean; + + /** + * Indicates if search state can be refined. + * + * This is `true` if the last search contains no result and + * "All" range is selected + */ + canRefine: boolean; + + /** + * Sets the selected value and trigger a new search + */ + refine: (facetValue: string) => void; + + /** + * Send event to insights middleware + */ + sendEvent: SendEventForFacet; +}; + +export type NumericMenuWidgetDescription = { + $$type: 'ais.numericMenu'; + renderState: NumericMenuRenderState; + indexRenderState: { + numericMenu: { + [attribute: string]: WidgetRenderState< + NumericMenuRenderState, + NumericMenuConnectorParams + >; + }; + }; + indexUiState: { + numericMenu: { + // @TODO: this could possibly become `${number}:${number}` later + [attribute: string]: string; + }; + }; +}; + +export type NumericMenuConnector = Connector< + NumericMenuWidgetDescription, + NumericMenuConnectorParams +>; + +const $$type = 'ais.numericMenu'; + +const createSendEvent = + ({ instantSearchInstance }: { instantSearchInstance: InstantSearch }) => + (...args: Parameters) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + }; + +const connectNumericMenu: NumericMenuConnector = function connectNumericMenu( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + attribute = '', + items = [], + transformItems = ((item) => item) as NonNullable< + NumericMenuConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if (attribute === '') { + throw new Error(withUsage('The `attribute` option is required.')); + } + + if (!items || items.length === 0) { + throw new Error( + withUsage('The `items` option expects an array of objects.') + ); + } + + type ConnectorState = { + refine?: (facetValue: string) => void; + createURL?: (state: SearchParameters) => (facetValue: string) => string; + sendEvent?: SendEventForFacet; + }; + + const prepareItems = (state: SearchParameters) => + items.map(({ start, end, label }) => ({ + label, + value: encodeURI(JSON.stringify({ start, end })), + isRefined: isRefined(state, attribute, { start, end, label }), + })); + + const connectorState: ConnectorState = {}; + + return { + $$type, + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + return state.removeNumericRefinement(attribute); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const values = searchParameters.getNumericRefinements(attribute); + + const equal = values['='] && values['='][0]; + + if (equal || equal === 0) { + return { + ...uiState, + numericMenu: { + ...uiState.numericMenu, + [attribute]: `${values['=']}`, + }, + }; + } + + const min = (values['>='] && values['>='][0]) || ''; + const max = (values['<='] && values['<='][0]) || ''; + + return removeEmptyRefinementsFromUiState( + { + ...uiState, + numericMenu: { + ...uiState.numericMenu, + [attribute]: `${min}:${max}`, + }, + }, + attribute + ); + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const value = uiState.numericMenu && uiState.numericMenu[attribute]; + + const withoutRefinements = searchParameters.setQueryParameters({ + numericRefinements: { + ...searchParameters.numericRefinements, + [attribute]: {}, + }, + }); + + if (!value) { + return withoutRefinements; + } + + const isExact = value.indexOf(':') === -1; + + if (isExact) { + return withoutRefinements.addNumericRefinement( + attribute, + '=', + Number(value) + ); + } + + const [min, max] = value.split(':').map(parseFloat); + + const withMinRefinement = isFiniteNumber(min) + ? withoutRefinements.addNumericRefinement(attribute, '>=', min) + : withoutRefinements; + + const withMaxRefinement = isFiniteNumber(max) + ? withMinRefinement.addNumericRefinement(attribute, '<=', max) + : withMinRefinement; + + return withMaxRefinement; + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + numericMenu: { + ...renderState.numericMenu, + [attribute]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState({ + results, + state, + instantSearchInstance, + helper, + createURL, + }) { + if (!connectorState.refine) { + connectorState.refine = (facetValue) => { + const refinedState = getRefinedState( + helper.state, + attribute, + facetValue + ); + connectorState.sendEvent!('click:internal', facetValue); + helper.setState(refinedState).search(); + }; + } + + if (!connectorState.createURL) { + connectorState.createURL = (newState) => (facetValue) => + createURL((uiState) => + this.getWidgetUiState(uiState, { + searchParameters: getRefinedState( + newState, + attribute, + facetValue + ), + helper, + }) + ); + } + + if (!connectorState.sendEvent) { + connectorState.sendEvent = createSendEvent({ + instantSearchInstance, + }); + } + + const hasNoResults = results ? results.nbHits === 0 : true; + const preparedItems = prepareItems(state); + let allIsSelected = true; + // @TODO avoid for..of for polyfill reasons + // eslint-disable-next-line instantsearch/no-for-of + for (const item of preparedItems) { + if (item.isRefined && decodeURI(item.value) !== '{}') { + allIsSelected = false; + break; + } + } + + return { + createURL: connectorState.createURL(state), + items: transformItems(preparedItems, { results }), + hasNoResults, + canRefine: !(hasNoResults && allIsSelected), + refine: connectorState.refine, + sendEvent: connectorState.sendEvent, + widgetParams, + }; + }, + }; + }; +}; + +function isRefined( + state: SearchParameters, + attribute: string, + option: NumericMenuConnectorParamsItem +) { + // @TODO: same as another spot, why is this mixing arrays & elements? + const currentRefinements = state.getNumericRefinements(attribute); + + if (option.start !== undefined && option.end !== undefined) { + if (option.start === option.end) { + return hasNumericRefinement(currentRefinements, '=', option.start); + } else { + return ( + hasNumericRefinement(currentRefinements, '>=', option.start) && + hasNumericRefinement(currentRefinements, '<=', option.end) + ); + } + } + + if (option.start !== undefined) { + return hasNumericRefinement(currentRefinements, '>=', option.start); + } + + if (option.end !== undefined) { + return hasNumericRefinement(currentRefinements, '<=', option.end); + } + + if (option.start === undefined && option.end === undefined) { + return ( + Object.keys(currentRefinements) as SearchParameters.Operator[] + ).every((operator) => (currentRefinements[operator] || []).length === 0); + } + + return false; +} + +function getRefinedState( + state: SearchParameters, + attribute: string, + facetValue: string +) { + let resolvedState = state; + + const refinedOption = JSON.parse(decodeURI(facetValue)); + + // @TODO: why is array / element mixed here & hasRefinements; seems wrong? + const currentRefinements = resolvedState.getNumericRefinements(attribute); + + if (refinedOption.start === undefined && refinedOption.end === undefined) { + return resolvedState.removeNumericRefinement(attribute); + } + + if (!isRefined(resolvedState, attribute, refinedOption)) { + resolvedState = resolvedState.removeNumericRefinement(attribute); + } + + if (refinedOption.start !== undefined && refinedOption.end !== undefined) { + if (refinedOption.start > refinedOption.end) { + throw new Error('option.start should be > to option.end'); + } + + if (refinedOption.start === refinedOption.end) { + if (hasNumericRefinement(currentRefinements, '=', refinedOption.start)) { + resolvedState = resolvedState.removeNumericRefinement( + attribute, + '=', + refinedOption.start + ); + } else { + resolvedState = resolvedState.addNumericRefinement( + attribute, + '=', + refinedOption.start + ); + } + return resolvedState; + } + } + + if (refinedOption.start !== undefined) { + if (hasNumericRefinement(currentRefinements, '>=', refinedOption.start)) { + resolvedState = resolvedState.removeNumericRefinement( + attribute, + '>=', + refinedOption.start + ); + } + resolvedState = resolvedState.addNumericRefinement( + attribute, + '>=', + refinedOption.start + ); + } + + if (refinedOption.end !== undefined) { + if (hasNumericRefinement(currentRefinements, '<=', refinedOption.end)) { + resolvedState = resolvedState.removeNumericRefinement( + attribute, + '<=', + refinedOption.end + ); + } + resolvedState = resolvedState.addNumericRefinement( + attribute, + '<=', + refinedOption.end + ); + } + + if (typeof resolvedState.page === 'number') { + resolvedState.page = 0; + } + + return resolvedState; +} + +function hasNumericRefinement( + currentRefinements: SearchParameters.OperatorList, + operator: SearchParameters.Operator, + value: number +) { + const refinements = currentRefinements[operator]; + + return refinements !== undefined && refinements.includes(value); +} + +function removeEmptyRefinementsFromUiState( + indexUiState: IndexUiState, + attribute: string +): IndexUiState { + if (!indexUiState.numericMenu) { + return indexUiState; + } + + if (indexUiState.numericMenu[attribute] === ':') { + delete indexUiState.numericMenu[attribute]; + } + + if (Object.keys(indexUiState.numericMenu).length === 0) { + delete indexUiState.numericMenu; + } + + return indexUiState; +} + +export default connectNumericMenu; diff --git a/packages/instantsearch-core/src/connectors/pagination/Paginator.ts b/packages/instantsearch-core/src/connectors/pagination/Paginator.ts new file mode 100644 index 00000000000..283cfcae0df --- /dev/null +++ b/packages/instantsearch-core/src/connectors/pagination/Paginator.ts @@ -0,0 +1,72 @@ +import { range } from '../../lib/utils'; + +class Paginator { + public currentPage: number; + public total: number; + public padding: number; + + public constructor(params: { + currentPage: number; + total: number; + padding: number; + }) { + this.currentPage = params.currentPage; + this.total = params.total; + this.padding = params.padding; + } + + public pages() { + const { total, currentPage, padding } = this; + + if (total === 0) return [0]; + + const totalDisplayedPages = this.nbPagesDisplayed(padding, total); + if (totalDisplayedPages === total) { + return range({ end: total }); + } + + const paddingLeft = this.calculatePaddingLeft( + currentPage, + padding, + total, + totalDisplayedPages + ); + const paddingRight = totalDisplayedPages - paddingLeft; + + const first = currentPage - paddingLeft; + const last = currentPage + paddingRight; + + return range({ start: first, end: last }); + } + + public nbPagesDisplayed(padding: number, total: number) { + return Math.min(2 * padding + 1, total); + } + + public calculatePaddingLeft( + current: number, + padding: number, + total: number, + totalDisplayedPages: number + ) { + if (current <= padding) { + return current; + } + + if (current >= total - padding) { + return totalDisplayedPages - (total - current); + } + + return padding; + } + + public isLastPage() { + return this.currentPage >= this.total - 1; + } + + public isFirstPage() { + return this.currentPage <= 0; + } +} + +export default Paginator; diff --git a/packages/instantsearch-core/src/connectors/pagination/connectPagination.ts b/packages/instantsearch-core/src/connectors/pagination/connectPagination.ts new file mode 100644 index 00000000000..93294a34dbf --- /dev/null +++ b/packages/instantsearch-core/src/connectors/pagination/connectPagination.ts @@ -0,0 +1,207 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; + +import Paginator from './Paginator'; + +import type { Connector, CreateURL, WidgetRenderState } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'pagination', + connector: true, +}); + +export type PaginationConnectorParams = { + /** + * The total number of pages to browse. + */ + totalPages?: number; + + /** + * The padding of pages to show around the current page + * @default 3 + */ + padding?: number; +}; + +export type PaginationRenderState = { + /** Creates URLs for the next state, the number is the page to generate the URL for. */ + createURL: CreateURL; + + /** Sets the current page and triggers a search. */ + refine: (page: number) => void; + + /** true if this search returned more than one page */ + canRefine: boolean; + + /** The number of the page currently displayed. */ + currentRefinement: number; + + /** The number of hits computed for the last query (can be approximated). */ + nbHits: number; + + /** The number of pages for the result set. */ + nbPages: number; + + /** The actual pages relevant to the current situation and padding. */ + pages: number[]; + + /** true if the current page is also the first page. */ + isFirstPage: boolean; + + /** true if the current page is also the last page. */ + isLastPage: boolean; +}; + +export type PaginationWidgetDescription = { + $$type: 'ais.pagination'; + renderState: PaginationRenderState; + indexRenderState: { + pagination: WidgetRenderState< + PaginationRenderState, + PaginationConnectorParams + >; + }; + indexUiState: { + page: number; + }; +}; + +export type PaginationConnector = Connector< + PaginationWidgetDescription, + PaginationConnectorParams +>; + +/** + * **Pagination** connector provides the logic to build a widget that will let the user + * choose the current page of the results. + * + * When using the pagination with Algolia, you should be aware that the engine won't provide you pages + * beyond the 1000th hits by default. You can find more information on the [Algolia documentation](https://www.algolia.com/doc/guides/searching/pagination/#pagination-limitations). + */ +const connectPagination: PaginationConnector = function connectPagination( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { totalPages, padding = 3 } = widgetParams || {}; + + const pager = new Paginator({ + currentPage: 0, + total: 0, + padding, + }); + + type ConnectorState = { + refine?: (page: number) => void; + createURL?: (page: number) => string; + }; + + const connectorState: ConnectorState = {}; + + function getMaxPage({ nbPages }: { nbPages: number }) { + return totalPages !== undefined ? Math.min(totalPages, nbPages) : nbPages; + } + + return { + $$type: 'ais.pagination', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return state.setQueryParameter('page', undefined); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const page = searchParameters.page || 0; + + if (!page) { + return uiState; + } + + return { + ...uiState, + page: page + 1, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const page = uiState.page ? uiState.page - 1 : 0; + + return searchParameters.setQueryParameter('page', page); + }, + + getWidgetRenderState({ results, helper, state, createURL }) { + if (!connectorState.refine) { + connectorState.refine = (page) => { + helper.setPage(page); + helper.search(); + }; + } + + if (!connectorState.createURL) { + connectorState.createURL = (page) => + createURL((uiState) => ({ + ...uiState, + page: page + 1, + })); + } + + const page = state.page || 0; + const nbPages = getMaxPage(results || { nbPages: 0 }); + pager.currentPage = page; + pager.total = nbPages; + + return { + createURL: connectorState.createURL, + refine: connectorState.refine, + canRefine: nbPages > 1, + currentRefinement: page, + nbHits: results?.nbHits || 0, + nbPages, + pages: results ? pager.pages() : [], + isFirstPage: pager.isFirstPage(), + isLastPage: pager.isLastPage(), + widgetParams, + }; + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + pagination: this.getWidgetRenderState(renderOptions), + }; + }, + }; + }; +}; + +export default connectPagination; diff --git a/packages/instantsearch-core/src/connectors/powered-by/connectPoweredBy.ts b/packages/instantsearch-core/src/connectors/powered-by/connectPoweredBy.ts new file mode 100644 index 00000000000..88ba26ba8c2 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/powered-by/connectPoweredBy.ts @@ -0,0 +1,111 @@ +import { + safelyRunOnBrowser, + checkRendering, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; + +import type { Connector, WidgetRenderState } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'powered-by', + connector: true, +}); + +export type PoweredByRenderState = { + /** the url to redirect to on click */ + url: string; +}; + +export type PoweredByConnectorParams = { + /** the url to redirect to on click */ + url?: string; +}; + +export type PoweredByWidgetDescription = { + $$type: 'ais.poweredBy'; + renderState: PoweredByRenderState; + indexRenderState: { + poweredBy: WidgetRenderState< + PoweredByRenderState, + PoweredByConnectorParams + >; + }; +}; + +export type PoweredByConnector = Connector< + PoweredByWidgetDescription, + PoweredByConnectorParams +>; + +/** + * **PoweredBy** connector provides the logic to build a custom widget that will displays + * the logo to redirect to Algolia. + */ +const connectPoweredBy: PoweredByConnector = function connectPoweredBy( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + const defaultUrl = + 'https://www.algolia.com/?' + + 'utm_source=instantsearch.js&' + + 'utm_medium=website&' + + `utm_content=${safelyRunOnBrowser( + ({ window }) => window.location?.hostname || '', + { fallback: () => '' } + )}&` + + 'utm_campaign=poweredby'; + + return (widgetParams) => { + const { url = defaultUrl } = widgetParams || {}; + + return { + $$type: 'ais.poweredBy', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + poweredBy: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState() { + return { + url, + widgetParams, + }; + }, + + dispose() { + unmountFn(); + }, + }; + }; +}; + +export default connectPoweredBy; diff --git a/packages/instantsearch-core/src/connectors/query-rules/connectQueryRules.ts b/packages/instantsearch-core/src/connectors/query-rules/connectQueryRules.ts new file mode 100644 index 00000000000..8a84f22dc1b --- /dev/null +++ b/packages/instantsearch-core/src/connectors/query-rules/connectQueryRules.ts @@ -0,0 +1,277 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + warning, + getRefinements, + isEqual, + noop, +} from '../../lib/utils'; + +import type { + Refinement as InternalRefinement, + NumericRefinement as InternalNumericRefinement, +} from '../../lib/utils'; +import type { Connector, TransformItems, WidgetRenderState } from '../../types'; +import type { + AlgoliaSearchHelper as Helper, + SearchParameters, +} from 'algoliasearch-helper'; + +type TrackedFilterRefinement = string | number | boolean; + +export type ParamTrackedFilters = { + [facetName: string]: ( + facetValues: TrackedFilterRefinement[] + ) => TrackedFilterRefinement[]; +}; +export type ParamTransformRuleContexts = (ruleContexts: string[]) => string[]; + +export type QueryRulesConnectorParams = { + trackedFilters?: ParamTrackedFilters; + transformRuleContexts?: ParamTransformRuleContexts; + transformItems?: TransformItems; +}; + +export type QueryRulesRenderState = { + items: any[]; +}; + +const withUsage = createDocumentationMessageGenerator({ + name: 'query-rules', + connector: true, +}); + +function hasStateRefinements(state: SearchParameters): boolean { + return [ + state.disjunctiveFacetsRefinements, + state.facetsRefinements, + state.hierarchicalFacetsRefinements, + state.numericRefinements, + ].some((refinement) => + Boolean(refinement && Object.keys(refinement).length > 0) + ); +} + +// A context rule must consist only of alphanumeric characters, hyphens, and underscores. +// See https://www.algolia.com/doc/guides/managing-results/refine-results/merchandising-and-promoting/in-depth/implementing-query-rules/#context +function escapeRuleContext(ruleName: string): string { + return ruleName.replace(/[^a-z0-9-_]+/gi, '_'); +} + +function getRuleContextsFromTrackedFilters({ + helper, + sharedHelperState, + trackedFilters, +}: { + helper: Helper; + sharedHelperState: SearchParameters; + trackedFilters: ParamTrackedFilters; +}): string[] { + const ruleContexts = Object.keys(trackedFilters).reduce( + (facets, facetName) => { + const facetRefinements: TrackedFilterRefinement[] = getRefinements( + helper.lastResults || {}, + sharedHelperState, + true + ) + .filter( + (refinement: InternalRefinement) => refinement.attribute === facetName + ) + .map( + (refinement: InternalRefinement) => + (refinement as InternalNumericRefinement).numericValue || + refinement.name + ); + + const getTrackedFacetValues = trackedFilters[facetName]; + const trackedFacetValues = getTrackedFacetValues(facetRefinements); + + return [ + ...facets, + ...facetRefinements + .filter((facetRefinement) => + trackedFacetValues.includes(facetRefinement) + ) + .map((facetValue) => + escapeRuleContext(`ais-${facetName}-${facetValue}`) + ), + ]; + }, + [] + ); + + return ruleContexts; +} + +function applyRuleContexts( + this: { + helper: Helper; + initialRuleContexts: string[]; + trackedFilters: ParamTrackedFilters; + transformRuleContexts: ParamTransformRuleContexts; + }, + event: { state: SearchParameters } +): void { + const { helper, initialRuleContexts, trackedFilters, transformRuleContexts } = + this; + + const sharedHelperState = event.state; + const previousRuleContexts: string[] = sharedHelperState.ruleContexts || []; + const newRuleContexts = getRuleContextsFromTrackedFilters({ + helper, + sharedHelperState, + trackedFilters, + }); + const nextRuleContexts = [...initialRuleContexts, ...newRuleContexts]; + + warning( + nextRuleContexts.length <= 10, + ` +The maximum number of \`ruleContexts\` is 10. They have been sliced to that limit. +Consider using \`transformRuleContexts\` to minimize the number of rules sent to Algolia. +` + ); + + const ruleContexts = transformRuleContexts(nextRuleContexts).slice(0, 10); + + if (!isEqual(previousRuleContexts, ruleContexts)) { + helper.overrideStateWithoutTriggeringChangeEvent({ + ...sharedHelperState, + ruleContexts, + }); + } +} + +export type QueryRulesWidgetDescription = { + $$type: 'ais.queryRules'; + renderState: QueryRulesRenderState; + indexRenderState: { + queryRules: WidgetRenderState< + QueryRulesRenderState, + QueryRulesConnectorParams + >; + }; +}; + +export type QueryRulesConnector = Connector< + QueryRulesWidgetDescription, + QueryRulesConnectorParams +>; + +const connectQueryRules: QueryRulesConnector = function connectQueryRules( + render, + unmount = noop +) { + checkRendering(render, withUsage()); + + return (widgetParams) => { + const { + trackedFilters = {} as ParamTrackedFilters, + transformRuleContexts = ((rules) => rules) as ParamTransformRuleContexts, + transformItems = ((items) => items) as NonNullable< + QueryRulesConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + Object.keys(trackedFilters).forEach((facetName) => { + if (typeof trackedFilters[facetName] !== 'function') { + throw new Error( + withUsage( + `'The "${facetName}" filter value in the \`trackedFilters\` option expects a function.` + ) + ); + } + }); + + const hasTrackedFilters = Object.keys(trackedFilters).length > 0; + + // We store the initial rule contexts applied before creating the widget + // so that we do not override them with the rules created from `trackedFilters`. + let initialRuleContexts: string[] = []; + let onHelperChange: (event: { state: SearchParameters }) => void; + + return { + $$type: 'ais.queryRules', + + init(initOptions) { + const { helper, state, instantSearchInstance } = initOptions; + + initialRuleContexts = state.ruleContexts || []; + onHelperChange = applyRuleContexts.bind({ + helper, + initialRuleContexts, + trackedFilters, + transformRuleContexts, + }); + + if (hasTrackedFilters) { + // We need to apply the `ruleContexts` based on the `trackedFilters` + // before the helper changes state in some cases: + // - Some filters are applied on the first load (e.g. using `configure`) + // - The `transformRuleContexts` option sets initial `ruleContexts`. + if ( + hasStateRefinements(state) || + Boolean(widgetParams.transformRuleContexts) + ) { + onHelperChange({ state }); + } + + // We track every change in the helper to override its state and add + // any `ruleContexts` needed based on the `trackedFilters`. + helper.on('change', onHelperChange); + } + + render( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + render( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + getWidgetRenderState({ results }) { + const { userData = [] } = results || {}; + const items = transformItems(userData, { results }); + + return { + items, + widgetParams, + }; + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + queryRules: this.getWidgetRenderState(renderOptions), + }; + }, + + dispose({ helper, state }) { + unmount(); + + if (hasTrackedFilters) { + helper.removeListener('change', onHelperChange); + + return state.setQueryParameter('ruleContexts', initialRuleContexts); + } + + return state; + }, + }; + }; +}; + +export default connectQueryRules; diff --git a/packages/instantsearch-core/src/connectors/range/connectRange.ts b/packages/instantsearch-core/src/connectors/range/connectRange.ts new file mode 100644 index 00000000000..b951b05e33c --- /dev/null +++ b/packages/instantsearch-core/src/connectors/range/connectRange.ts @@ -0,0 +1,484 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + isFiniteNumber, + find, + noop, +} from '../../lib/utils'; + +import type { SendEventForFacet } from '../../lib/utils'; +import type { Connector, InstantSearch, WidgetRenderState } from '../../types'; +import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator( + { name: 'range-input', connector: true }, + { name: 'range-slider', connector: true } +); + +const $$type = 'ais.range'; + +export type RangeMin = number | undefined; +export type RangeMax = number | undefined; + +// @MAJOR: potentially we should consolidate these types +export type RangeBoundaries = [RangeMin, RangeMax]; +export type Range = { + min: RangeMin; + max: RangeMax; +}; + +export type RangeRenderState = { + /** + * Sets a range to filter the results on. Both values + * are optional, and will default to the higher and lower bounds. You can use `undefined` to remove a + * previously set bound or to set an infinite bound. + * @param rangeValue tuple of [min, max] bounds + */ + refine: (rangeValue: RangeBoundaries) => void; + + /** + * Indicates whether this widget can be refined + */ + canRefine: boolean; + + /** + * Send an event to the insights middleware + */ + sendEvent: SendEventForFacet; + + /** + * Maximum range possible for this search + */ + range: Range; + + /** + * Current refinement of the search + */ + start: RangeBoundaries; + + /** + * Transform for the rendering `from` and/or `to` values. + * Both functions take a `number` as input and should output a `string`. + */ + format: { + from: (fromValue: number) => string; + to: (toValue: number) => string; + }; +}; + +export type RangeConnectorParams = { + /** + * Name of the attribute for faceting. + */ + attribute: string; + + /** + * Minimal range value, default to automatically computed from the result set. + */ + min?: number; + + /** + * Maximal range value, default to automatically computed from the result set. + */ + max?: number; + + /** + * Number of digits after decimal point to use. + */ + precision?: number; +}; + +export type RangeWidgetDescription = { + $$type: 'ais.range'; + renderState: RangeRenderState; + indexRenderState: { + range: { + [attribute: string]: WidgetRenderState< + RangeRenderState, + RangeConnectorParams + >; + }; + }; + indexUiState: { + range: { + // @TODO: this could possibly become `${number}:${number}` later + [attribute: string]: string; + }; + }; +}; + +export type RangeConnector = Connector< + RangeWidgetDescription, + RangeConnectorParams +>; + +function toPrecision({ + min, + max, + precision, +}: { + min?: number; + max?: number; + precision: number; +}) { + const pow = Math.pow(10, precision); + + return { + min: min ? Math.floor(min * pow) / pow : min, + max: max ? Math.ceil(max * pow) / pow : max, + }; +} + +/** + * **Range** connector provides the logic to create custom widget that will let + * the user refine results using a numeric range. + * + * This connectors provides a `refine()` function that accepts bounds. It will also provide + * information about the min and max bounds for the current result set. + */ +const connectRange: RangeConnector = function connectRange( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + attribute = '', + min: minBound, + max: maxBound, + precision = 0, + } = widgetParams || {}; + + if (!attribute) { + throw new Error(withUsage('The `attribute` option is required.')); + } + + if ( + isFiniteNumber(minBound) && + isFiniteNumber(maxBound) && + minBound > maxBound + ) { + throw new Error(withUsage("The `max` option can't be lower than `min`.")); + } + + const formatToNumber = (v: string | number) => + Number(Number(v).toFixed(precision)); + + const rangeFormatter = { + from: (v: number) => v.toLocaleString(), + to: (v: number) => formatToNumber(v).toLocaleString(), + }; + + // eslint-disable-next-line complexity + const getRefinedState = ( + helper: AlgoliaSearchHelper, + currentRange: Range, + nextMin: RangeMin | string, + nextMax: RangeMax | string + ) => { + let resolvedState = helper.state; + const { min: currentRangeMin, max: currentRangeMax } = currentRange; + + const [min] = resolvedState.getNumericRefinement(attribute, '>=') || []; + const [max] = resolvedState.getNumericRefinement(attribute, '<=') || []; + + const isResetMin = nextMin === undefined || nextMin === ''; + const isResetMax = nextMax === undefined || nextMax === ''; + + const { min: nextMinAsNumber, max: nextMaxAsNumber } = toPrecision({ + min: !isResetMin ? parseFloat(nextMin as string) : undefined, + max: !isResetMax ? parseFloat(nextMax as string) : undefined, + precision, + }); + + let newNextMin: RangeMin; + if (!isFiniteNumber(minBound) && currentRangeMin === nextMinAsNumber) { + newNextMin = undefined; + } else if (isFiniteNumber(minBound) && isResetMin) { + newNextMin = minBound; + } else { + newNextMin = nextMinAsNumber; + } + + let newNextMax: RangeMax; + if (!isFiniteNumber(maxBound) && currentRangeMax === nextMaxAsNumber) { + newNextMax = undefined; + } else if (isFiniteNumber(maxBound) && isResetMax) { + newNextMax = maxBound; + } else { + newNextMax = nextMaxAsNumber; + } + + const isResetNewNextMin = newNextMin === undefined; + + const isGreaterThanCurrentRange = + isFiniteNumber(currentRangeMin) && currentRangeMin <= newNextMin!; + const isMinValid = + isResetNewNextMin || + (isFiniteNumber(newNextMin) && + (!isFiniteNumber(currentRangeMin) || isGreaterThanCurrentRange)); + + const isResetNewNextMax = newNextMax === undefined; + const isLowerThanRange = + isFiniteNumber(newNextMax) && currentRangeMax! >= newNextMax; + const isMaxValid = + isResetNewNextMax || + (isFiniteNumber(newNextMax) && + (!isFiniteNumber(currentRangeMax) || isLowerThanRange)); + + const hasMinChange = min !== newNextMin; + const hasMaxChange = max !== newNextMax; + + if ((hasMinChange || hasMaxChange) && isMinValid && isMaxValid) { + resolvedState = resolvedState.removeNumericRefinement(attribute); + + if (isFiniteNumber(newNextMin)) { + resolvedState = resolvedState.addNumericRefinement( + attribute, + '>=', + newNextMin + ); + } + + if (isFiniteNumber(newNextMax)) { + resolvedState = resolvedState.addNumericRefinement( + attribute, + '<=', + newNextMax + ); + } + + return resolvedState.resetPage(); + } + + return null; + }; + + const createSendEvent = + (instantSearchInstance: InstantSearch) => + (...args: Parameters) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + }; + + function _getCurrentRange( + stats: Partial> + ) { + let min: number; + if (isFiniteNumber(minBound)) { + min = minBound; + } else if (isFiniteNumber(stats.min)) { + min = stats.min; + } else { + min = 0; + } + + let max: number; + if (isFiniteNumber(maxBound)) { + max = maxBound; + } else if (isFiniteNumber(stats.max)) { + max = stats.max; + } else { + max = 0; + } + + return toPrecision({ min, max, precision }); + } + + function _getCurrentRefinement( + helper: AlgoliaSearchHelper + ): RangeBoundaries { + const [minValue] = helper.getNumericRefinement(attribute, '>=') || []; + + const [maxValue] = helper.getNumericRefinement(attribute, '<=') || []; + + const min = isFiniteNumber(minValue) ? minValue : -Infinity; + const max = isFiniteNumber(maxValue) ? maxValue : Infinity; + + return [min, max]; + } + + function _refine(helper: AlgoliaSearchHelper, currentRange: Range) { + return ([nextMin, nextMax]: RangeBoundaries = [undefined, undefined]) => { + const refinedState = getRefinedState( + helper, + currentRange, + nextMin, + nextMax + ); + if (refinedState) { + helper.setState(refinedState).search(); + } + }; + } + + return { + $$type, + + init(initOptions) { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + range: { + ...renderState.range, + [attribute]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState({ results, helper, instantSearchInstance }) { + const facetsFromResults = (results && results.disjunctiveFacets) || []; + const facet = find( + facetsFromResults, + (facetResult) => facetResult.name === attribute + ); + const stats = (facet && facet.stats) || { + min: undefined, + max: undefined, + }; + + const currentRange = _getCurrentRange(stats); + const start = _getCurrentRefinement(helper); + + let refine: ReturnType; + + if (!results) { + // On first render pass an empty range + // to be able to bypass the validation + // related to it + refine = _refine(helper, { + min: undefined, + max: undefined, + }); + } else { + refine = _refine(helper, currentRange); + } + + return { + refine, + canRefine: currentRange.min !== currentRange.max, + format: rangeFormatter, + range: currentRange, + sendEvent: createSendEvent(instantSearchInstance), + widgetParams: { + ...widgetParams, + precision, + }, + start, + }; + }, + + dispose({ state }) { + unmountFn(); + + return state + .removeDisjunctiveFacet(attribute) + .removeNumericRefinement(attribute); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const { '>=': min = [], '<=': max = [] } = + searchParameters.getNumericRefinements(attribute); + + if (min.length === 0 && max.length === 0) { + return uiState; + } + + return { + ...uiState, + range: { + ...uiState.range, + [attribute]: `${min}:${max}`, + }, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + let widgetSearchParameters = searchParameters + .addDisjunctiveFacet(attribute) + .setQueryParameters({ + numericRefinements: { + ...searchParameters.numericRefinements, + [attribute]: {}, + }, + }); + + if (isFiniteNumber(minBound)) { + widgetSearchParameters = widgetSearchParameters.addNumericRefinement( + attribute, + '>=', + minBound + ); + } + + if (isFiniteNumber(maxBound)) { + widgetSearchParameters = widgetSearchParameters.addNumericRefinement( + attribute, + '<=', + maxBound + ); + } + + const value = uiState.range && uiState.range[attribute]; + + if (!value || value.indexOf(':') === -1) { + return widgetSearchParameters; + } + + const [lowerBound, upperBound] = value.split(':').map(parseFloat); + + if ( + isFiniteNumber(lowerBound) && + (!isFiniteNumber(minBound) || minBound < lowerBound) + ) { + widgetSearchParameters = + widgetSearchParameters.removeNumericRefinement(attribute, '>='); + widgetSearchParameters = widgetSearchParameters.addNumericRefinement( + attribute, + '>=', + lowerBound + ); + } + + if ( + isFiniteNumber(upperBound) && + (!isFiniteNumber(maxBound) || upperBound < maxBound) + ) { + widgetSearchParameters = + widgetSearchParameters.removeNumericRefinement(attribute, '<='); + widgetSearchParameters = widgetSearchParameters.addNumericRefinement( + attribute, + '<=', + upperBound + ); + } + + return widgetSearchParameters; + }, + }; + }; +}; + +export default connectRange; diff --git a/packages/instantsearch-core/src/connectors/rating-menu/connectRatingMenu.ts b/packages/instantsearch-core/src/connectors/rating-menu/connectRatingMenu.ts new file mode 100644 index 00000000000..85c4b1407d4 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/rating-menu/connectRatingMenu.ts @@ -0,0 +1,498 @@ +import { + checkRendering, + createDocumentationLink, + createDocumentationMessageGenerator, + noop, + warning, +} from '../../lib/utils'; + +import type { InsightsEvent } from '../../middlewares'; +import type { + Connector, + InstantSearch, + CreateURL, + WidgetRenderState, + Widget, + InitOptions, + RenderOptions, + IndexUiState, +} from '../../types'; +import type { + AlgoliaSearchHelper, + SearchParameters, + SearchResults, +} from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'rating-menu', + connector: true, +}); + +const $$type = 'ais.ratingMenu'; + +const MAX_VALUES_PER_FACET_API_LIMIT = 1000; +const STEP = 1; + +type SendEvent = (...args: [InsightsEvent] | [string, string, string?]) => void; + +type CreateSendEvent = (createSendEventArgs: { + instantSearchInstance: InstantSearch; + helper: AlgoliaSearchHelper; + getRefinedStar: () => number | number[] | undefined; + attribute: string; +}) => SendEvent; + +const createSendEvent: CreateSendEvent = + ({ instantSearchInstance, helper, getRefinedStar, attribute }) => + (...args) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + const [, facetValue, eventName = 'Filter Applied'] = args; + const [eventType, eventModifier] = args[0].split(':'); + if (eventType !== 'click') { + return; + } + const isRefined = getRefinedStar() === Number(facetValue); + if (!isRefined) { + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'clickedFilters', + widgetType: $$type, + eventType, + eventModifier, + payload: { + eventName, + index: helper.lastResults?.index || helper.state.index, + filters: [`${attribute}>=${facetValue}`], + }, + attribute, + }); + } + }; + +type StarRatingItems = { + /** + * Name corresponding to the number of stars. + */ + name: string; + /** + * Human-readable name corresponding to the number of stars. + */ + label: string; + /** + * Number of stars as string. + */ + value: string; + /** + * Count of matched results corresponding to the number of stars. + */ + count: number; + /** + * Array of length of maximum rating value with stars to display or not. + */ + stars: boolean[]; + /** + * Indicates if star rating refinement is applied. + */ + isRefined: boolean; +}; + +export type RatingMenuConnectorParams = { + /** + * Name of the attribute for faceting (eg. "free_shipping"). + */ + attribute: string; + + /** + * The maximum rating value. + */ + max?: number; +}; + +export type RatingMenuRenderState = { + /** + * Possible star ratings the user can apply. + */ + items: StarRatingItems[]; + + /** + * Creates an URL for the next state (takes the item value as parameter). Takes the value of an item as parameter. + */ + createURL: CreateURL; + + /** + * Indicates if search state can be refined. + */ + canRefine: boolean; + + /** + * Selects a rating to filter the results (takes the filter value as parameter). Takes the value of an item as parameter. + */ + refine: (value: string) => void; + + /** + * `true` if the last search contains no result. + * + * @deprecated Use `canRefine` instead. + */ + hasNoResults: boolean; + + /** + * Send event to insights middleware + */ + sendEvent: SendEvent; +}; + +export type RatingMenuWidgetDescription = { + $$type: 'ais.ratingMenu'; + renderState: RatingMenuRenderState; + indexRenderState: { + ratingMenu: { + [attribute: string]: WidgetRenderState< + RatingMenuRenderState, + RatingMenuConnectorParams + >; + }; + }; + indexUiState: { + ratingMenu: { + [attribute: string]: number | undefined; + }; + }; +}; + +export type RatingMenuConnector = Connector< + RatingMenuWidgetDescription, + RatingMenuConnectorParams +>; + +/** + * **StarRating** connector provides the logic to build a custom widget that will let + * the user refine search results based on ratings. + * + * The connector provides to the rendering: `refine()` to select a value and + * `items` that are the values that can be selected. `refine` should be used + * with `items.value`. + */ +const connectRatingMenu: RatingMenuConnector = function connectRatingMenu( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { attribute, max = 5 } = widgetParams || {}; + let sendEvent: SendEvent; + + if (!attribute) { + throw new Error(withUsage('The `attribute` option is required.')); + } + + const getRefinedStar = (state: SearchParameters) => { + const values = state.getNumericRefinements(attribute); + + if (!values['>=']?.length) { + return undefined; + } + + return values['>='][0]; + }; + + const getFacetsMaxDecimalPlaces = ( + facetResults: SearchResults.FacetValue[] + ) => { + let maxDecimalPlaces = 0; + facetResults.forEach((facetResult) => { + const [, decimal = ''] = facetResult.name.split('.'); + maxDecimalPlaces = Math.max(maxDecimalPlaces, decimal.length); + }); + return maxDecimalPlaces; + }; + + const getFacetValuesWarningMessage = ({ + maxDecimalPlaces, + maxFacets, + maxValuesPerFacet, + }: { + maxDecimalPlaces: number; + maxFacets: number; + maxValuesPerFacet: number; + }) => { + const maxDecimalPlacesInRange = Math.max( + 0, + Math.floor(Math.log10(MAX_VALUES_PER_FACET_API_LIMIT / max)) + ); + const maxFacetsInRange = Math.min( + MAX_VALUES_PER_FACET_API_LIMIT, + Math.pow(10, maxDecimalPlacesInRange) * max + ); + + const solutions: string[] = []; + + if (maxFacets > MAX_VALUES_PER_FACET_API_LIMIT) { + solutions.push( + `- Update your records to lower the precision of the values in the "${attribute}" attribute (for example: ${(5.123456789).toPrecision( + maxDecimalPlaces + 1 + )} to ${(5.123456789).toPrecision(maxDecimalPlacesInRange + 1)})` + ); + } + if (maxValuesPerFacet < maxFacetsInRange) { + solutions.push( + `- Increase the maximum number of facet values to ${maxFacetsInRange} using the "configure" widget ${createDocumentationLink( + { name: 'configure' } + )} and the "maxValuesPerFacet" parameter https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/` + ); + } + + return `The ${attribute} attribute can have ${maxFacets} different values (0 to ${max} with a maximum of ${maxDecimalPlaces} decimals = ${maxFacets}) but you retrieved only ${maxValuesPerFacet} facet values. Therefore the number of results that match the refinements can be incorrect. + ${ + solutions.length + ? `To resolve this problem you can:\n${solutions.join('\n')}` + : `` + }`; + }; + + function getRefinedState(state: SearchParameters, facetValue: string) { + const isRefined = getRefinedStar(state) === Number(facetValue); + + const emptyState = state.resetPage().removeNumericRefinement(attribute); + + if (!isRefined) { + return emptyState + .addNumericRefinement(attribute, '<=', max) + .addNumericRefinement(attribute, '>=', Number(facetValue)); + } + return emptyState; + } + + const toggleRefinement = ( + helper: AlgoliaSearchHelper, + facetValue: string + ) => { + sendEvent('click:internal', facetValue); + helper.setState(getRefinedState(helper.state, facetValue)).search(); + }; + + type ConnectorState = { + toggleRefinementFactory: ( + helper: AlgoliaSearchHelper + ) => (facetValue: string) => void; + createURLFactory: ({ + state, + createURL, + }: { + state: SearchParameters; + createURL: (InitOptions | RenderOptions)['createURL']; + getWidgetUiState: NonNullable; + helper: AlgoliaSearchHelper; + }) => (value: string) => string; + }; + + const connectorState: ConnectorState = { + toggleRefinementFactory: (helper) => toggleRefinement.bind(null, helper), + createURLFactory: + ({ state, createURL, getWidgetUiState, helper }) => + (value) => + createURL((uiState) => + getWidgetUiState(uiState, { + searchParameters: getRefinedState(state, value), + helper, + }) + ), + }; + + return { + $$type, + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + ratingMenu: { + ...renderState.ratingMenu, + [attribute]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState({ + helper, + results, + state, + instantSearchInstance, + createURL, + }) { + let facetValues: StarRatingItems[] = []; + + if (!sendEvent) { + sendEvent = createSendEvent({ + instantSearchInstance, + helper, + getRefinedStar: () => getRefinedStar(helper.state), + attribute, + }); + } + + let refinementIsApplied = false; + let totalCount = 0; + + const facetResults = results?.getFacetValues(attribute, {}) as + | SearchResults.FacetValue[] + | undefined; + + if (results && facetResults) { + const maxValuesPerFacet = facetResults.length; + const maxDecimalPlaces = getFacetsMaxDecimalPlaces(facetResults); + const maxFacets = Math.pow(10, maxDecimalPlaces) * max; + + warning( + maxFacets <= maxValuesPerFacet || Boolean(results.__isArtificial), + getFacetValuesWarningMessage({ + maxDecimalPlaces, + maxFacets, + maxValuesPerFacet, + }) + ); + + const refinedStar = getRefinedStar(state); + + for (let star = STEP; star < max; star += STEP) { + const isRefined = refinedStar === star; + refinementIsApplied = refinementIsApplied || isRefined; + + const count = facetResults + .filter((f) => Number(f.name) >= star && Number(f.name) <= max) + .map((f) => f.count) + .reduce((sum, current) => sum + current, 0); + totalCount += count; + + if (refinedStar && !isRefined && count === 0) { + // skip count==0 when at least 1 refinement is enabled + // eslint-disable-next-line no-continue + continue; + } + + const stars = [...new Array(Math.floor(max / STEP))].map( + (_v, i) => i * STEP < star + ); + + facetValues.push({ + stars, + name: String(star), + label: String(star), + value: String(star), + count, + isRefined, + }); + } + } + facetValues = facetValues.reverse(); + + const hasNoResults = results ? results.nbHits === 0 : true; + + return { + items: facetValues, + hasNoResults, + canRefine: (!hasNoResults || refinementIsApplied) && totalCount > 0, + refine: connectorState.toggleRefinementFactory(helper), + sendEvent, + createURL: connectorState.createURLFactory({ + state, + createURL, + helper, + getWidgetUiState: this.getWidgetUiState, + }), + widgetParams, + }; + }, + + dispose({ state }) { + unmountFn(); + + return state.removeNumericRefinement(attribute); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const value = getRefinedStar(searchParameters); + + return removeEmptyRefinementsFromUiState( + { + ...uiState, + ratingMenu: { + ...uiState.ratingMenu, + [attribute]: typeof value === 'number' ? value : undefined, + }, + }, + attribute + ); + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const value = uiState.ratingMenu && uiState.ratingMenu[attribute]; + + const withDisjunctiveFacet = searchParameters + .addDisjunctiveFacet(attribute) + .removeNumericRefinement(attribute) + .removeDisjunctiveFacetRefinement(attribute); + + if (!value) { + return withDisjunctiveFacet.setQueryParameters({ + numericRefinements: { + ...withDisjunctiveFacet.numericRefinements, + [attribute]: {}, + }, + }); + } + + return withDisjunctiveFacet + .addNumericRefinement(attribute, '<=', max) + .addNumericRefinement(attribute, '>=', value); + }, + }; + }; +}; + +function removeEmptyRefinementsFromUiState( + indexUiState: IndexUiState, + attribute: string +): IndexUiState { + if (!indexUiState.ratingMenu) { + return indexUiState; + } + + if (typeof indexUiState.ratingMenu[attribute] !== 'number') { + delete indexUiState.ratingMenu[attribute]; + } + + if (Object.keys(indexUiState.ratingMenu).length === 0) { + delete indexUiState.ratingMenu; + } + + return indexUiState; +} + +export default connectRatingMenu; diff --git a/packages/instantsearch-core/src/connectors/refinement-list/connectRefinementList.ts b/packages/instantsearch-core/src/connectors/refinement-list/connectRefinementList.ts new file mode 100644 index 00000000000..1aa2342dc01 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/refinement-list/connectRefinementList.ts @@ -0,0 +1,591 @@ +import { + escapeFacets, + TAG_PLACEHOLDER, + TAG_REPLACEMENT, + checkRendering, + createDocumentationMessageGenerator, + createSendEventForFacet, + noop, + warning, +} from '../../lib/utils'; + +import type { SendEventForFacet } from '../../lib/utils'; +import type { + Connector, + TransformItems, + SortBy, + RenderOptions, + Widget, + InitOptions, + FacetHit, + CreateURL, + WidgetRenderState, + IndexUiState, +} from '../../types'; +import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'refinement-list', + connector: true, +}); + +const DEFAULT_SORT = ['isRefined', 'count:desc', 'name:asc']; + +export type RefinementListItem = { + /** + * The value of the refinement list item. + */ + value: string; + /** + * Human-readable value of the refinement list item. + */ + label: string; + /** + * Human-readable value of the searched refinement list item. + */ + highlighted?: string; + /** + * Number of matched results after refinement is applied. + */ + count: number; + /** + * Indicates if the list item is refined. + */ + isRefined: boolean; +}; + +export type RefinementListConnectorParams = { + /** + * The name of the attribute in the records. + */ + attribute: string; + /** + * How the filters are combined together. + */ + operator?: 'and' | 'or'; + /** + * The max number of items to display when + * `showMoreLimit` is not set or if the widget is showing less value. + */ + limit?: number; + /** + * Whether to display a button that expands the number of items. + */ + showMore?: boolean; + /** + * The max number of items to display if the widget + * is showing more items. + */ + showMoreLimit?: number; + /** + * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. + * + * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). + * + * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed + */ + sortBy?: SortBy; + /** + * Escapes the content of the facet values. + */ + escapeFacetValues?: boolean; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; +}; + +export type RefinementListRenderState = { + /** + * The list of filtering values returned from Algolia API. + */ + items: RefinementListItem[]; + /** + * indicates whether the results are exhaustive (complete) + */ + hasExhaustiveItems: boolean; + /** + * Creates the next state url for a selected refinement. + */ + createURL: CreateURL; + /** + * Action to apply selected refinements. + */ + refine: (value: string) => void; + /** + * Send event to insights middleware + */ + sendEvent: SendEventForFacet; + /** + * Searches for values inside the list. + */ + searchForItems: (query: string) => void; + /** + * `true` if the values are from an index search. + */ + isFromSearch: boolean; + /** + * `true` if a refinement can be applied. + * @MAJOR: reconsider how `canRefine` is computed so it both accounts for the + * items returned in the main search and in SFFV. + */ + canRefine: boolean; + /** + * `true` if the toggleShowMore button can be activated (enough items to display more or + * already displaying more than `limit` items) + */ + canToggleShowMore: boolean; + /** + * True if the menu is displaying all the menu items. + */ + isShowingMore: boolean; + /** + * Toggles the number of values displayed between `limit` and `showMoreLimit`. + */ + toggleShowMore: () => void; +}; + +export type RefinementListWidgetDescription = { + $$type: 'ais.refinementList'; + renderState: RefinementListRenderState; + indexRenderState: { + refinementList: { + [attribute: string]: WidgetRenderState< + RefinementListRenderState, + RefinementListConnectorParams + >; + }; + }; + indexUiState: { + refinementList: { + [attribute: string]: string[]; + }; + }; +}; + +export type RefinementListConnector = Connector< + RefinementListWidgetDescription, + RefinementListConnectorParams +>; + +/** + * **RefinementList** connector provides the logic to build a custom widget that + * will let the user filter the results based on the values of a specific facet. + * + * **Requirement:** the attribute passed as `attribute` must be present in + * attributesForFaceting of the searched index. + * + * This connector provides: + * - a `refine()` function to select an item. + * - a `toggleShowMore()` function to display more or less items + * - a `searchForItems()` function to search within the items. + */ +const connectRefinementList: RefinementListConnector = + function connectRefinementList(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + attribute, + operator = 'or', + limit = 10, + showMore = false, + showMoreLimit = 20, + sortBy = DEFAULT_SORT, + escapeFacetValues = true, + transformItems = ((items) => items) as NonNullable< + RefinementListConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + type ThisWidget = Widget< + RefinementListWidgetDescription & { widgetParams: typeof widgetParams } + >; + + if (!attribute) { + throw new Error(withUsage('The `attribute` option is required.')); + } + + if (!/^(and|or)$/.test(operator)) { + throw new Error( + withUsage( + `The \`operator\` must one of: \`"and"\`, \`"or"\` (got "${operator}").` + ) + ); + } + + if (showMore === true && showMoreLimit <= limit) { + throw new Error( + withUsage('`showMoreLimit` should be greater than `limit`.') + ); + } + + const formatItems = ({ + name: label, + escapedValue: value, + ...item + }: SearchResults.FacetValue): RefinementListItem => ({ + ...item, + value, + label, + highlighted: label, + }); + + let lastResultsFromMainSearch: SearchResults; + let lastItemsFromMainSearch: RefinementListItem[] = []; + let hasExhaustiveItems = true; + let triggerRefine: RefinementListRenderState['refine'] | undefined; + let sendEvent: RefinementListRenderState['sendEvent'] | undefined; + + let isShowingMore = false; + // Provide the same function to the `renderFn` so that way the user + // has to only bind it once when `isFirstRendering` for instance + let toggleShowMore = () => {}; + function cachedToggleShowMore() { + toggleShowMore(); + } + + function createToggleShowMore( + renderOptions: RenderOptions, + widget: ThisWidget + ) { + return () => { + isShowingMore = !isShowingMore; + widget.render!(renderOptions); + }; + } + + function getLimit() { + return isShowingMore ? showMoreLimit : limit; + } + + let searchForFacetValues: ( + renderOptions: RenderOptions | InitOptions + ) => RefinementListRenderState['searchForItems'] = () => () => {}; + + const createSearchForFacetValues = function ( + helper: AlgoliaSearchHelper, + widget: ThisWidget + ) { + return (renderOptions: RenderOptions | InitOptions) => + (query: string) => { + const { instantSearchInstance, results: searchResults } = + renderOptions; + if (query === '' && lastItemsFromMainSearch) { + // render with previous data from the helper. + renderFn( + { + ...widget.getWidgetRenderState({ + ...renderOptions, + results: lastResultsFromMainSearch, + }), + instantSearchInstance, + }, + false + ); + } else { + const tags = { + highlightPreTag: escapeFacetValues + ? TAG_PLACEHOLDER.highlightPreTag + : TAG_REPLACEMENT.highlightPreTag, + highlightPostTag: escapeFacetValues + ? TAG_PLACEHOLDER.highlightPostTag + : TAG_REPLACEMENT.highlightPostTag, + }; + + helper + .searchForFacetValues( + attribute, + query, + // We cap the `maxFacetHits` value to 100 because the Algolia API + // doesn't support a greater number. + // See https://www.algolia.com/doc/api-reference/api-parameters/maxFacetHits/ + Math.min(getLimit(), 100), + tags + ) + .then((results) => { + const facetValues = escapeFacetValues + ? escapeFacets(results.facetHits) + : results.facetHits; + + const normalizedFacetValues = transformItems( + facetValues.map(({ escapedValue, value, ...item }) => ({ + ...item, + value: escapedValue, + label: value, + })), + { results: searchResults } + ); + + renderFn( + { + ...widget.getWidgetRenderState({ + ...renderOptions, + results: lastResultsFromMainSearch, + }), + items: normalizedFacetValues, + canToggleShowMore: false, + canRefine: true, + isFromSearch: true, + instantSearchInstance, + }, + false + ); + }); + } + }; + }; + + return { + $$type: 'ais.refinementList' as const, + + init(initOptions) { + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance: initOptions.instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance: renderOptions.instantSearchInstance, + }, + false + ); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + refinementList: { + ...renderState.refinementList, + [attribute]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState(renderOptions) { + const { results, state, createURL, instantSearchInstance, helper } = + renderOptions; + let items: RefinementListItem[] = []; + let facetValues: SearchResults.FacetValue[] | FacetHit[] = []; + + if (!sendEvent || !triggerRefine || !searchForFacetValues) { + sendEvent = createSendEventForFacet({ + instantSearchInstance, + helper, + attribute, + widgetType: this.$$type, + }); + + triggerRefine = (facetValue) => { + sendEvent!('click:internal', facetValue); + helper.toggleFacetRefinement(attribute, facetValue).search(); + }; + + searchForFacetValues = createSearchForFacetValues(helper, this); + } + + if (results) { + const values = results.getFacetValues(attribute, { + sortBy, + facetOrdering: sortBy === DEFAULT_SORT, + }); + facetValues = values && Array.isArray(values) ? values : []; + items = transformItems( + facetValues.slice(0, getLimit()).map(formatItems), + { results } + ); + + const maxValuesPerFacetConfig = state.maxValuesPerFacet; + const currentLimit = getLimit(); + // If the limit is the max number of facet retrieved it is impossible to know + // if the facets are exhaustive. The only moment we are sure it is exhaustive + // is when it is strictly under the number requested unless we know that another + // widget has requested more values (maxValuesPerFacet > getLimit()). + // Because this is used for making the search of facets unable or not, it is important + // to be conservative here. + hasExhaustiveItems = + maxValuesPerFacetConfig! > currentLimit + ? facetValues.length <= currentLimit + : facetValues.length < currentLimit; + + lastResultsFromMainSearch = results; + lastItemsFromMainSearch = items; + + if (renderOptions.results) { + toggleShowMore = createToggleShowMore(renderOptions, this); + } + } + + // Do not mistake searchForFacetValues and searchFacetValues which is the actual search + // function + const searchFacetValues = + searchForFacetValues && searchForFacetValues(renderOptions); + + const canShowLess = + isShowingMore && lastItemsFromMainSearch.length > limit; + const canShowMore = showMore && !hasExhaustiveItems; + + const canToggleShowMore = canShowLess || canShowMore; + + return { + createURL: (facetValue: string) => { + return createURL((uiState) => + this.getWidgetUiState(uiState, { + searchParameters: state + .resetPage() + .toggleFacetRefinement(attribute, facetValue), + helper, + }) + ); + }, + items, + refine: triggerRefine, + searchForItems: searchFacetValues, + isFromSearch: false, + canRefine: items.length > 0, + widgetParams, + isShowingMore, + canToggleShowMore, + toggleShowMore: cachedToggleShowMore, + sendEvent, + hasExhaustiveItems, + }; + }, + + dispose({ state }) { + unmountFn(); + + const withoutMaxValuesPerFacet = state.setQueryParameter( + 'maxValuesPerFacet', + undefined + ); + if (operator === 'and') { + return withoutMaxValuesPerFacet.removeFacet(attribute); + } + return withoutMaxValuesPerFacet.removeDisjunctiveFacet(attribute); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const values = + operator === 'or' + ? searchParameters.getDisjunctiveRefinements(attribute) + : searchParameters.getConjunctiveRefinements(attribute); + + return removeEmptyRefinementsFromUiState( + { + ...uiState, + refinementList: { + ...uiState.refinementList, + [attribute]: values, + }, + }, + attribute + ); + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const isDisjunctive = operator === 'or'; + + if (searchParameters.isHierarchicalFacet(attribute)) { + warning( + false, + `RefinementList: Attribute "${attribute}" is already used by another widget applying hierarchical faceting. +As this is not supported, please make sure to remove this other widget or this RefinementList widget will not work at all.` + ); + + return searchParameters; + } + + if ( + (isDisjunctive && searchParameters.isConjunctiveFacet(attribute)) || + (!isDisjunctive && searchParameters.isDisjunctiveFacet(attribute)) + ) { + warning( + false, + `RefinementList: Attribute "${attribute}" is used by another refinement list with a different operator. +As this is not supported, please make sure to only use this attribute with one of the two operators.` + ); + + return searchParameters; + } + + const values = + uiState.refinementList && uiState.refinementList[attribute]; + + const withFacetConfiguration = isDisjunctive + ? searchParameters + .addDisjunctiveFacet(attribute) + .removeDisjunctiveFacetRefinement(attribute) + : searchParameters + .addFacet(attribute) + .removeFacetRefinement(attribute); + + const currentMaxValuesPerFacet = + withFacetConfiguration.maxValuesPerFacet || 0; + + const nextMaxValuesPerFacet = Math.max( + currentMaxValuesPerFacet, + showMore ? showMoreLimit : limit + ); + + const withMaxValuesPerFacet = + withFacetConfiguration.setQueryParameter( + 'maxValuesPerFacet', + nextMaxValuesPerFacet + ); + + if (!values) { + const key = isDisjunctive + ? 'disjunctiveFacetsRefinements' + : 'facetsRefinements'; + + return withMaxValuesPerFacet.setQueryParameters({ + [key]: { + ...withMaxValuesPerFacet[key], + [attribute]: [], + }, + }); + } + + return values.reduce( + (parameters, value) => + isDisjunctive + ? parameters.addDisjunctiveFacetRefinement(attribute, value) + : parameters.addFacetRefinement(attribute, value), + withMaxValuesPerFacet + ); + }, + }; + }; + }; + +function removeEmptyRefinementsFromUiState( + indexUiState: IndexUiState, + attribute: string +): IndexUiState { + if (!indexUiState.refinementList) { + return indexUiState; + } + + if ( + !indexUiState.refinementList[attribute] || + indexUiState.refinementList[attribute].length === 0 + ) { + delete indexUiState.refinementList[attribute]; + } + + if (Object.keys(indexUiState.refinementList).length === 0) { + delete indexUiState.refinementList; + } + + return indexUiState; +} + +export default connectRefinementList; diff --git a/packages/instantsearch-core/src/connectors/related-products/connectRelatedProducts.ts b/packages/instantsearch-core/src/connectors/related-products/connectRelatedProducts.ts new file mode 100644 index 00000000000..ac1610fda27 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/related-products/connectRelatedProducts.ts @@ -0,0 +1,236 @@ +import { + createDocumentationMessageGenerator, + checkRendering, + noop, + escapeHits, + TAG_PLACEHOLDER, + createSendEventForHits, + addAbsolutePosition, + addQueryID, +} from '../../lib/utils'; + +import type { SendEventForHits } from '../../lib/utils'; +import type { + Connector, + TransformItems, + BaseHit, + Renderer, + Unmounter, + UnknownWidgetParams, + RecommendResponse, + Hit, + AlgoliaHit, +} from '../../types'; +import type { PlainSearchParameters } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'related-products', + connector: true, +}); + +export type RelatedProductsRenderState< + THit extends NonNullable = BaseHit +> = { + /** + * The matched recommendations from the Algolia API. + */ + items: Array>; + + /** + * Sends an event to the Insights middleware. + */ + sendEvent: SendEventForHits; +}; + +export type RelatedProductsConnectorParams< + THit extends NonNullable = BaseHit +> = { + /** + * The `objectIDs` of the items to get related products from. + */ + objectIDs: string[]; + /** + * The number of recommendations to retrieve. + */ + limit?: 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' + >; + /** + * Whether to escape HTML tags from items string values. + * + * @default true + */ + escapeHTML?: boolean; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems< + Hit, + { results: RecommendResponse> } + >; +}; + +export type RelatedProductsWidgetDescription< + THit extends NonNullable = BaseHit +> = { + $$type: 'ais.relatedProducts'; + renderState: RelatedProductsRenderState; +}; + +export type RelatedProductsConnector< + THit extends NonNullable = BaseHit +> = Connector< + RelatedProductsWidgetDescription, + RelatedProductsConnectorParams +>; + +export default (function connectRelatedProducts< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer< + RelatedProductsRenderState, + RelatedProductsConnectorParams & TWidgetParams + >, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return = BaseHit>( + widgetParams: TWidgetParams & RelatedProductsConnectorParams + ) => { + const { + // @MAJOR: this can default to false + escapeHTML = true, + objectIDs, + limit, + 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.')); + } + + let sendEvent: SendEventForHits; + + 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, helper, instantSearchInstance }) { + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + helper, + widgetType: this.$$type, + }); + } + if (results === null || results === undefined) { + return { items: [], widgetParams, sendEvent }; + } + + if (escapeHTML && results.hits.length > 0) { + results.hits = escapeHits(results.hits); + } + + const itemsWithAbsolutePosition = addAbsolutePosition( + results.hits, + 0, + 1 + ); + + const itemsWithAbsolutePositionAndQueryID = addQueryID( + itemsWithAbsolutePosition, + results.queryID + ); + + const transformedItems = transformItems( + itemsWithAbsolutePositionAndQueryID, + { + results: results as RecommendResponse>, + } + ); + + return { + items: transformedItems, + widgetParams, + sendEvent, + }; + }, + + dispose({ recommendState }) { + unmountFn(); + return recommendState.removeParams(this.$$id!); + }, + + getWidgetParameters(state) { + return objectIDs.reduce( + (acc, objectID) => + acc.addRelatedProducts({ + objectID, + maxRecommendations: limit, + threshold, + fallbackParameters: fallbackParameters + ? { + ...fallbackParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + } + : undefined, + queryParameters: { + ...queryParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + }, + $$id: this.$$id!, + }), + state.removeParams(this.$$id!) + ); + }, + }; + }; +} satisfies RelatedProductsConnector); diff --git a/packages/instantsearch-core/src/connectors/relevant-sort/connectRelevantSort.ts b/packages/instantsearch-core/src/connectors/relevant-sort/connectRelevantSort.ts new file mode 100644 index 00000000000..e118b105551 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/relevant-sort/connectRelevantSort.ts @@ -0,0 +1,142 @@ +import { noop } from '../../lib/utils'; + +import type { Connector, WidgetRenderState } from '../../types'; + +export type RelevantSortConnectorParams = Record; + +type Refine = (relevancyStrictness: number | undefined) => void; + +export type RelevantSortRenderState = { + /** + * Indicates if it has appliedRelevancyStrictness greater than zero + */ + isRelevantSorted: boolean; + + /** + * Indicates if the results come from a virtual replica + */ + isVirtualReplica: boolean; + + /** + * Indicates if search state can be refined + */ + canRefine: boolean; + + /** + * Sets the value as relevancyStrictness and trigger a new search + */ + refine: Refine; +}; + +export type RelevantSortWidgetDescription = { + $$type: 'ais.relevantSort'; + renderState: RelevantSortRenderState; + indexRenderState: { + relevantSort: WidgetRenderState< + RelevantSortRenderState, + RelevantSortConnectorParams + >; + }; + indexUiState: { + relevantSort: number; + }; +}; + +export type RelevantSortConnector = Connector< + RelevantSortWidgetDescription, + RelevantSortConnectorParams +>; + +const connectRelevantSort: RelevantSortConnector = function connectRelevantSort( + renderFn = noop, + unmountFn = noop +) { + return (widgetParams) => { + type ConnectorState = { + refine?: Refine; + }; + + const connectorState: ConnectorState = {}; + + return { + $$type: 'ais.relevantSort', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return state.setQueryParameter('relevancyStrictness', undefined); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + relevantSort: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ results, helper }) { + if (!connectorState.refine) { + connectorState.refine = (relevancyStrictness) => { + helper + .setQueryParameter('relevancyStrictness', relevancyStrictness) + .search(); + }; + } + + const { appliedRelevancyStrictness } = results || {}; + + const isVirtualReplica = appliedRelevancyStrictness !== undefined; + + return { + isRelevantSorted: + typeof appliedRelevancyStrictness !== 'undefined' && + appliedRelevancyStrictness > 0, + isVirtualReplica, + canRefine: isVirtualReplica, + refine: connectorState.refine, + widgetParams, + }; + }, + + getWidgetSearchParameters(state, { uiState }) { + return state.setQueryParameter( + 'relevancyStrictness', + uiState.relevantSort ?? state.relevancyStrictness + ); + }, + + getWidgetUiState(uiState, { searchParameters }) { + return { + ...uiState, + relevantSort: + searchParameters.relevancyStrictness || uiState.relevantSort, + }; + }, + }; + }; +}; + +export default connectRelevantSort; diff --git a/packages/instantsearch-core/src/connectors/search-box/connectSearchBox.ts b/packages/instantsearch-core/src/connectors/search-box/connectSearchBox.ts new file mode 100644 index 00000000000..d9efe514262 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/search-box/connectSearchBox.ts @@ -0,0 +1,176 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; + +import type { Connector, WidgetRenderState } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'search-box', + connector: true, +}); + +export type SearchBoxConnectorParams = { + /** + * A function that will be called every time + * a new value for the query is set. The first parameter is the query and the second is a + * function to actually trigger the search. The function takes the query as the parameter. + * + * This queryHook can be used to debounce the number of searches done from the searchBox. + */ + queryHook?: (query: string, hook: (value: string) => void) => void; +}; + +/** + * @typedef {Object} CustomSearchBoxWidgetParams + * @property {function(string, function(string))} [queryHook = undefined] A function that will be called every time + * a new value for the query is set. The first parameter is the query and the second is a + * function to actually trigger the search. The function takes the query as the parameter. + * + * This queryHook can be used to debounce the number of searches done from the searchBox. + */ + +export type SearchBoxRenderState = { + /** + * The query from the last search. + */ + query: string; + /** + * Sets a new query and searches. + */ + refine: (value: string) => void; + /** + * Remove the query and perform search. + */ + clear: () => void; + /** + * `true` if the search results takes more than a certain time to come back + * from Algolia servers. This can be configured on the InstantSearch constructor with the attribute + * `stalledSearchDelay` which is 200ms, by default. + * @deprecated use `instantSearchInstance.status` instead + */ + isSearchStalled: boolean; +}; + +export type SearchBoxWidgetDescription = { + $$type: 'ais.searchBox'; + renderState: SearchBoxRenderState; + indexRenderState: { + searchBox: WidgetRenderState< + SearchBoxRenderState, + SearchBoxConnectorParams + >; + }; + indexUiState: { + query: string; + }; +}; + +export type SearchBoxConnector = Connector< + SearchBoxWidgetDescription, + SearchBoxConnectorParams +>; + +const defaultQueryHook: SearchBoxConnectorParams['queryHook'] = (query, hook) => + hook(query); + +/** + * **SearchBox** connector provides the logic to build a widget that will let the user search for a query. + * + * The connector provides to the rendering: `refine()` to set the query. The behaviour of this function + * may be impacted by the `queryHook` widget parameter. + */ +const connectSearchBox: SearchBoxConnector = function connectSearchBox( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { queryHook = defaultQueryHook } = widgetParams || {}; + + let _refine: SearchBoxRenderState['refine']; + let _clear: SearchBoxRenderState['clear']; + + return { + $$type: 'ais.searchBox', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return state.setQueryParameter('query', undefined); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + searchBox: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ helper, instantSearchInstance, state }) { + if (!_refine) { + _refine = (query) => { + queryHook(query, (q) => helper.setQuery(q).search()); + }; + + _clear = () => { + helper.setQuery('').search(); + }; + } + + return { + query: state.query || '', + refine: _refine, + clear: _clear, + widgetParams, + isSearchStalled: instantSearchInstance.status === 'stalled', + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + const query = searchParameters.query || ''; + + if (query === '' || (uiState && uiState.query === query)) { + return uiState; + } + + return { + ...uiState, + query, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + return searchParameters.setQueryParameter('query', uiState.query || ''); + }, + }; + }; +}; + +export default connectSearchBox; diff --git a/packages/instantsearch-core/src/connectors/sort-by/connectSortBy.ts b/packages/instantsearch-core/src/connectors/sort-by/connectSortBy.ts new file mode 100644 index 00000000000..3af89aaa2c1 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/sort-by/connectSortBy.ts @@ -0,0 +1,396 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + find, + warning, + noop, +} from '../../lib/utils'; + +import type { Connector, TransformItems, WidgetRenderState } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'sort-by', + connector: true, +}); + +/** + * The **SortBy** connector provides the logic to build a custom widget that will display a + * list of indices or sorting strategies. With Algolia, this is most commonly used for changing + * ranking strategy. This allows a user to change how the hits are being sorted. + * + * This connector supports two sorting modes: + * 1. **Index-based (traditional)**: Uses the `value` property to switch between different indices. + * This is the standard behavior for non-composition setups. + * + * 2. **Strategy-based (composition mode)**: Uses the `strategy` property to apply sorting strategies + * via the `sortBy` search parameter. This is only available when using Algolia Compositions. + * + * Items can mix both types in the same widget, allowing for flexible sorting options. + */ + +export type SortByIndexItem = { + /** + * The name of the index to target. + */ + value: string; + /** + * The label of the index to display. + */ + label: string; + /** + * Ensures mutual exclusivity with strategy. + */ + strategy?: never; +}; + +export type SortByStrategyItem = { + /** + * The name of the sorting strategy to use. + * Only available in composition mode. + */ + strategy: string; + /** + * The label of the strategy to display. + */ + label: string; + /** + * Ensures mutual exclusivity with value. + */ + value?: never; +}; + +export type SortByItem = SortByIndexItem | SortByStrategyItem; + +export type SortByConnectorParams = { + /** + * Array of objects defining the different indices or strategies to choose from. + */ + items: SortByItem[]; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems; +}; + +export type SortByRenderState = { + /** + * The initially selected index or strategy. + */ + initialIndex?: string; + /** + * The currently selected index or strategy. + */ + currentRefinement: string; + /** + * All the available indices and strategies + */ + options: Array<{ value: string; label: string }>; + /** + * Switches indices or strategies and triggers a new search. + */ + refine: (value: string) => void; + /** + * `true` if the last search contains no result. + * @deprecated Use `canRefine` instead. + */ + hasNoResults: boolean; + /** + * `true` if we can refine. + */ + canRefine: boolean; +}; + +export type SortByWidgetDescription = { + $$type: 'ais.sortBy'; + renderState: SortByRenderState; + indexRenderState: { + sortBy: WidgetRenderState; + }; + indexUiState: { + sortBy: string; + }; +}; + +export type SortByConnector = Connector< + SortByWidgetDescription, + SortByConnectorParams +>; + +function isStrategyItem(item: SortByItem): item is SortByStrategyItem { + return 'strategy' in item && item.strategy !== undefined; +} + +function getItemValue(item: SortByItem): string { + if (isStrategyItem(item)) { + return item.strategy; + } + return item.value; +} + +function isValidStrategy( + itemsLookup: Record, + value: string | undefined +): boolean { + if (!value) return false; + const item = itemsLookup[value]; + return item !== undefined && isStrategyItem(item); +} + +const connectSortBy: SortByConnector = function connectSortBy( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + const connectorState: ConnectorState = {}; + + type ConnectorState = { + refine?: (value: string) => void; + initialValue?: string; + // Cached flag: whether we're in composition mode (checked once, never changes) + // This is cached because instantSearchInstance is not available in all lifecycle methods + isUsingComposition?: boolean; + // Object for O(1) lookup: value/strategy -> item + itemsLookup?: Record; + }; + + return (widgetParams) => { + const { + items, + transformItems = ((x) => x) as NonNullable< + SortByConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if (!Array.isArray(items)) { + throw new Error( + withUsage('The `items` option expects an array of objects.') + ); + } + + const itemsLookup: Record = {}; + + items.forEach((item, index) => { + const hasValue = 'value' in item && item.value !== undefined; + const hasStrategy = 'strategy' in item && item.strategy !== undefined; + + // Validate mutual exclusivity + if (hasValue && hasStrategy) { + throw new Error( + withUsage( + `Item at index ${index} cannot have both "value" and "strategy" properties.` + ) + ); + } + + if (!hasValue && !hasStrategy) { + throw new Error( + withUsage( + `Item at index ${index} must have either a "value" or "strategy" property.` + ) + ); + } + + const itemValue = getItemValue(item); + + itemsLookup[itemValue] = item; + }); + + connectorState.itemsLookup = itemsLookup; + + return { + $$type: 'ais.sortBy', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + // Check if strategies are used outside composition mode + const hasStrategyItems = items.some( + (item) => 'strategy' in item && item.strategy + ); + + if (hasStrategyItems && !instantSearchInstance.compositionID) { + throw new Error( + withUsage( + 'Sorting strategies can only be used in composition mode. Please provide a "compositionID" to your InstantSearch instance.' + ) + ); + } + + const widgetRenderState = this.getWidgetRenderState(initOptions); + const currentIndex = widgetRenderState.currentRefinement; + const isCurrentIndexInItems = find( + items, + (item) => getItemValue(item) === currentIndex + ); + + warning( + isCurrentIndexInItems !== undefined, + `The index named "${currentIndex}" is not listed in the \`items\` of \`sortBy\`.` + ); + + renderFn( + { + ...widgetRenderState, + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + // Clear sortBy parameter if it was set + if (connectorState.isUsingComposition && state.sortBy) { + state = state.setQueryParameter('sortBy' as any, undefined); + } + + // Restore initial index if changed + if ( + connectorState.initialValue && + state.index !== connectorState.initialValue + ) { + return state.setIndex(connectorState.initialValue); + } + + return state; + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + sortBy: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ + results, + helper, + state, + parent, + instantSearchInstance, + }) { + // Capture initial value (composition ID or main index) + if (!connectorState.initialValue && parent) { + connectorState.initialValue = parent.getIndexName(); + } + + // Create refine function if not exists + if (!connectorState.refine) { + // Cache composition mode status for lifecycle methods that don't have access to instantSearchInstance + connectorState.isUsingComposition = Boolean( + instantSearchInstance?.compositionID + ); + + connectorState.refine = (value: string) => { + // O(1) lookup using the items lookup table + const item = connectorState.itemsLookup![value]; + + if (item && isStrategyItem(item)) { + // Strategy-based: set sortBy parameter for composition API + // The composition backend will interpret this and apply the sorting strategy + helper.setQueryParameter('sortBy', item.strategy).search(); + } else { + // Index-based: clear any existing sortBy parameter and switch to the new index + // Clearing sortBy is critical when transitioning from strategy to index-based sorting + helper + .setQueryParameter('sortBy', undefined) + .setIndex(value) + .search(); + } + }; + } + + // Transform items first (on original structure) + const transformedItems = transformItems(items, { results }); + + // Normalize items: all get a 'value' property for the render state + const normalizedItems = transformedItems.map((item) => ({ + label: item.label, + value: getItemValue(item), + })); + + // Determine current refinement + // In composition mode, prefer sortBy parameter if it corresponds to a valid strategy item + // Otherwise use the index (for index-based items or when no valid strategy is active) + const currentRefinement = + connectorState.isUsingComposition && + isValidStrategy(connectorState.itemsLookup!, state.sortBy) + ? state.sortBy! + : state.index; + + const hasNoResults = results ? results.nbHits === 0 : true; + + return { + currentRefinement, + options: normalizedItems, + refine: connectorState.refine, + hasNoResults, + canRefine: !hasNoResults && items.length > 0, + widgetParams, + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + // In composition mode with an active strategy, use sortBy parameter + // Otherwise use index-based behavior (traditional mode) + const currentValue = + connectorState.isUsingComposition && + isValidStrategy(connectorState.itemsLookup!, searchParameters.sortBy) + ? searchParameters.sortBy! + : searchParameters.index; + + return { + ...uiState, + sortBy: + currentValue !== connectorState.initialValue + ? currentValue + : undefined, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + const isUiStateSortByInItems = + !uiState.sortBy || + Object.prototype.hasOwnProperty.call( + connectorState.itemsLookup, + uiState.sortBy + ); + + warning( + Boolean(isUiStateSortByInItems), + `The index named "${uiState.sortBy}" is not listed in the \`items\` of \`sortBy\`.` + ); + + const sortByValue = + (isUiStateSortByInItems ? uiState.sortBy : undefined) || + connectorState.initialValue || + searchParameters.index; + + if (isValidStrategy(connectorState.itemsLookup!, sortByValue)) { + const item = connectorState.itemsLookup![sortByValue]; + // Strategy-based: set the sortBy parameter for composition API + // The index remains as the compositionID + return searchParameters.setQueryParameter('sortBy', item.strategy); + } + + // Index-based: set the index parameter (traditional behavior) + return searchParameters.setQueryParameter('index', sortByValue); + }, + }; + }; +}; + +export default connectSortBy; diff --git a/packages/instantsearch-core/src/connectors/stats/connectStats.ts b/packages/instantsearch-core/src/connectors/stats/connectStats.ts new file mode 100644 index 00000000000..f31cf708e9e --- /dev/null +++ b/packages/instantsearch-core/src/connectors/stats/connectStats.ts @@ -0,0 +1,146 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; + +import type { Connector, WidgetRenderState } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'stats', + connector: true, +}); + +/** + * **Stats** connector provides the logic to build a custom widget that will displays + * search statistics (hits number and processing time). + */ + +export type StatsRenderState = { + /** + * The maximum number of hits per page returned by Algolia. + */ + hitsPerPage?: number; + /** + * The number of hits in the result set. + */ + nbHits: number; + /** + * The number of sorted hits in the result set (when using Relevant sort). + */ + nbSortedHits?: number; + /** + * Indicates whether the index is currently using Relevant sort and is displaying only sorted hits. + */ + areHitsSorted: boolean; + /** + * The number of pages computed for the result set. + */ + nbPages: number; + /** + * The current page. + */ + page: number; + /** + * The time taken to compute the results inside the Algolia engine. + */ + processingTimeMS: number; + /** + * The query used for the current search. + */ + query: string; +}; + +export type StatsConnectorParams = Record; + +export type StatsWidgetDescription = { + $$type: 'ais.stats'; + renderState: StatsRenderState; + indexRenderState: { + stats: WidgetRenderState; + }; +}; + +export type StatsConnector = Connector< + StatsWidgetDescription, + StatsConnectorParams +>; + +const connectStats: StatsConnector = function connectStats( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => ({ + $$type: 'ais.stats', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + stats: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ results, state }) { + if (!results) { + return { + hitsPerPage: state.hitsPerPage, + nbHits: 0, + nbSortedHits: undefined, + areHitsSorted: false, + nbPages: 0, + page: state.page || 0, + processingTimeMS: -1, + query: state.query || '', + widgetParams, + }; + } + + return { + hitsPerPage: results.hitsPerPage, + nbHits: results.nbHits, + nbSortedHits: results.nbSortedHits, + areHitsSorted: + typeof results.appliedRelevancyStrictness !== 'undefined' && + results.appliedRelevancyStrictness > 0 && + results.nbSortedHits !== results.nbHits, + nbPages: results.nbPages, + page: results.page, + processingTimeMS: results.processingTimeMS, + query: results.query, + widgetParams, + }; + }, + }); +}; + +export default connectStats; diff --git a/packages/instantsearch-core/src/connectors/toggle-refinement/connectToggleRefinement.ts b/packages/instantsearch-core/src/connectors/toggle-refinement/connectToggleRefinement.ts new file mode 100644 index 00000000000..fccc3373ff6 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/toggle-refinement/connectToggleRefinement.ts @@ -0,0 +1,505 @@ +import { + checkRendering, + escapeFacetValue, + createDocumentationMessageGenerator, + find, + noop, + toArray, + warning, +} from '../../lib/utils'; + +import type { + Connector, + CreateURL, + InitOptions, + InstantSearch, + RenderOptions, + Widget, + WidgetRenderState, +} from '../../types'; +import type { + AlgoliaSearchHelper, + SearchParameters, + SearchResults, +} from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'toggle-refinement', + connector: true, +}); + +const $$type = 'ais.toggleRefinement'; + +type BuiltInSendEventForToggle = ( + eventType: string, + isRefined: boolean, + eventName?: string +) => void; +type CustomSendEventForToggle = (customPayload: any) => void; + +export type SendEventForToggle = BuiltInSendEventForToggle & + CustomSendEventForToggle; + +const createSendEvent = ({ + instantSearchInstance, + helper, + attribute, + on, +}: { + instantSearchInstance: InstantSearch; + helper: AlgoliaSearchHelper; + attribute: string; + on: string[] | undefined; +}) => { + const sendEventForToggle: SendEventForToggle = (...args: any[]) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + const [, isRefined, eventName = 'Filter Applied'] = args; + const [eventType, eventModifier] = args[0].split(':'); + if (eventType !== 'click' || on === undefined) { + return; + } + + // only send an event when the refinement gets applied, + // not when it gets removed + if (!isRefined) { + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'clickedFilters', + widgetType: $$type, + eventType, + eventModifier, + payload: { + eventName, + index: helper.lastResults?.index || helper.state.index, + filters: on.map((value) => `${attribute}:${value}`), + }, + attribute, + }); + } + }; + return sendEventForToggle; +}; + +export type ToggleRefinementValue = { + /** + * Whether this option is enabled. + */ + isRefined: boolean; + /** + * Number of result if this option is toggled. + */ + count: number | null; +}; + +export type ToggleRefinementConnectorParams = { + /** + * Name of the attribute for faceting (e.g., "free_shipping"). + */ + attribute: string; + /** + * Value to filter on when toggled. + * @default "true" + */ + on?: FacetValue | FacetValue[]; + /** + * Value to filter on when not toggled. + */ + off?: FacetValue | FacetValue[]; +}; + +type FacetValue = string | boolean | number; + +export type ToggleRefinementRenderState = { + /** The current toggle value */ + value: { + /** + * The attribute name of this toggle. + */ + name: string; + /** + * Whether the current option is "on" (true) or "off" (false) + */ + isRefined: boolean; + /** + * Number of results if this option is toggled. + */ + count: number | null; + /** + * Information about the "on" toggle. + */ + onFacetValue: ToggleRefinementValue; + /** + * Information about the "off" toggle. + */ + offFacetValue: ToggleRefinementValue; + }; + /** + * Creates an URL for the next state. + */ + createURL: CreateURL; + /** + * Send a "Facet Clicked" Insights event. + */ + sendEvent: SendEventForToggle; + /** + * Indicates if search state can be refined. + */ + canRefine: boolean; + /** + * Updates to the next state by applying the toggle refinement. + */ + refine: (value?: { isRefined: boolean }) => void; +}; + +export type ToggleRefinementWidgetDescription = { + $$type: 'ais.toggleRefinement'; + renderState: ToggleRefinementRenderState; + indexRenderState: { + toggleRefinement: { + [attribute: string]: WidgetRenderState< + ToggleRefinementRenderState, + ToggleRefinementConnectorParams + >; + }; + }; + indexUiState: { + toggle: { + [attribute: string]: boolean; + }; + }; +}; + +export type ToggleRefinementConnector = Connector< + ToggleRefinementWidgetDescription, + ToggleRefinementConnectorParams +>; + +/** + * **Toggle** connector provides the logic to build a custom widget that will provide + * an on/off filtering feature based on an attribute value or values. + * + * Two modes are implemented in the custom widget: + * - with or without the value filtered + * - switch between two values. + */ +const connectToggleRefinement: ToggleRefinementConnector = + function connectToggleRefinement(renderFn, unmountFn = noop) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { attribute, on: userOn = true, off: userOff } = widgetParams || {}; + + if (!attribute) { + throw new Error(withUsage('The `attribute` option is required.')); + } + + const hasAnOffValue = userOff !== undefined; + // even though facet values can be numbers and boolean, + // the helper methods only accept string in the type + const on = toArray(userOn).map(escapeFacetValue) as string[]; + const off = hasAnOffValue + ? (toArray(userOff).map(escapeFacetValue) as string[]) + : undefined; + + let sendEvent: SendEventForToggle; + + const toggleRefinementFactory = + (helper: AlgoliaSearchHelper) => + ( + { + isRefined, + }: { + isRefined: boolean; + } = { isRefined: false } + ) => { + if (!isRefined) { + sendEvent('click:internal', isRefined); + if (hasAnOffValue) { + off!.forEach((v) => + helper.removeDisjunctiveFacetRefinement(attribute, v) + ); + } + + on.forEach((v) => + helper.addDisjunctiveFacetRefinement(attribute, v) + ); + } else { + on.forEach((v) => + helper.removeDisjunctiveFacetRefinement(attribute, v) + ); + + if (hasAnOffValue) { + off!.forEach((v) => + helper.addDisjunctiveFacetRefinement(attribute, v) + ); + } + } + + helper.search(); + }; + + const connectorState = { + createURLFactory: + ( + isRefined: boolean, + { + state, + createURL, + getWidgetUiState, + helper, + }: { + state: SearchParameters; + createURL: (InitOptions | RenderOptions)['createURL']; + getWidgetUiState: NonNullable; + helper: AlgoliaSearchHelper; + } + ) => + () => { + state = state.resetPage(); + + const valuesToRemove = isRefined ? on : off; + if (valuesToRemove) { + valuesToRemove.forEach((v) => { + state = state.removeDisjunctiveFacetRefinement(attribute, v); + }); + } + + const valuesToAdd = isRefined ? off : on; + if (valuesToAdd) { + valuesToAdd.forEach((v) => { + state = state.addDisjunctiveFacetRefinement(attribute, v); + }); + } + + return createURL((uiState) => + getWidgetUiState(uiState, { searchParameters: state, helper }) + ); + }, + }; + + return { + $$type, + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose({ state }) { + unmountFn(); + + return state.removeDisjunctiveFacet(attribute); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + toggleRefinement: { + ...renderState.toggleRefinement, + [attribute]: this.getWidgetRenderState(renderOptions), + }, + }; + }, + + getWidgetRenderState({ + state, + helper, + results, + createURL, + instantSearchInstance, + }) { + const isRefined = results + ? on.every((v) => state.isDisjunctiveFacetRefined(attribute, v)) + : on.every((v) => state.isDisjunctiveFacetRefined(attribute, v)); + + let onFacetValue: ToggleRefinementValue = { + isRefined, + count: 0, + }; + + let offFacetValue: ToggleRefinementValue = { + isRefined: hasAnOffValue && !isRefined, + count: 0, + }; + + if (results) { + const offValue = toArray(off || false); + const allFacetValues = (results.getFacetValues(attribute, {}) || + []) as SearchResults.FacetValue[]; + + const onData = on + .map((v) => + find( + allFacetValues, + ({ escapedValue }) => + escapedValue === escapeFacetValue(String(v)) + ) + ) + .filter((v): v is SearchResults.FacetValue => v !== undefined); + + const offData = hasAnOffValue + ? offValue + .map((v) => + find( + allFacetValues, + ({ escapedValue }) => + escapedValue === escapeFacetValue(String(v)) + ) + ) + .filter((v): v is SearchResults.FacetValue => v !== undefined) + : []; + + onFacetValue = { + isRefined: onData.length + ? onData.every((v) => v.isRefined) + : false, + count: onData.reduce((acc, v) => acc + v.count, 0) || null, + }; + + offFacetValue = { + isRefined: offData.length + ? offData.every((v) => v.isRefined) + : false, + count: + offData.reduce((acc, v) => acc + v.count, 0) || + allFacetValues.reduce((total, { count }) => total + count, 0), + }; + } + + if (!sendEvent) { + sendEvent = createSendEvent({ + instantSearchInstance, + attribute, + on, + helper, + }); + } + const nextRefinement = isRefined ? offFacetValue : onFacetValue; + + return { + value: { + name: attribute, + isRefined, + count: results ? nextRefinement.count : null, + onFacetValue, + offFacetValue, + }, + createURL: connectorState.createURLFactory(isRefined, { + state, + createURL, + helper, + getWidgetUiState: this.getWidgetUiState, + }), + sendEvent, + canRefine: Boolean(results ? nextRefinement.count : null), + refine: toggleRefinementFactory(helper), + widgetParams, + }; + }, + + getWidgetUiState(uiState, { searchParameters }) { + const isRefined = + on && + on.every((v) => + searchParameters.isDisjunctiveFacetRefined(attribute, v) + ); + + if (!isRefined) { + // This needs to be done in the case `uiState` comes from `createURL` + delete uiState.toggle?.[attribute]; + return uiState; + } + + return { + ...uiState, + toggle: { + ...uiState.toggle, + [attribute]: isRefined, + }, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + if ( + searchParameters.isHierarchicalFacet(attribute) || + searchParameters.isConjunctiveFacet(attribute) + ) { + warning( + false, + `ToggleRefinement: Attribute "${attribute}" is already used by another widget of a different type. +As this is not supported, please make sure to remove this other widget or this ToggleRefinement widget will not work at all.` + ); + + return searchParameters; + } + + let withFacetConfiguration = searchParameters + .addDisjunctiveFacet(attribute) + .removeDisjunctiveFacetRefinement(attribute); + + const isRefined = Boolean( + uiState.toggle && uiState.toggle[attribute] + ); + + if (isRefined) { + if (on) { + on.forEach((v) => { + withFacetConfiguration = + withFacetConfiguration.addDisjunctiveFacetRefinement( + attribute, + v + ); + }); + } + + return withFacetConfiguration; + } + + // It's not refined with an `off` value + if (hasAnOffValue) { + if (off) { + off.forEach((v) => { + withFacetConfiguration = + withFacetConfiguration.addDisjunctiveFacetRefinement( + attribute, + v + ); + }); + } + return withFacetConfiguration; + } + + // It's not refined without an `off` value + return withFacetConfiguration.setQueryParameters({ + disjunctiveFacetsRefinements: { + ...searchParameters.disjunctiveFacetsRefinements, + [attribute]: [], + }, + }); + }, + }; + }; + }; + +export default connectToggleRefinement; diff --git a/packages/instantsearch-core/src/connectors/toggle-refinement/types.ts b/packages/instantsearch-core/src/connectors/toggle-refinement/types.ts new file mode 100644 index 00000000000..5ebbd8461bd --- /dev/null +++ b/packages/instantsearch-core/src/connectors/toggle-refinement/types.ts @@ -0,0 +1,6 @@ +export type { + /** @deprecated import from connectToggleRefinement directly */ + ToggleRefinementConnector, + /** @deprecated import from connectToggleRefinement directly */ + ToggleRefinementWidgetDescription, +} from './connectToggleRefinement'; diff --git a/packages/instantsearch-core/src/connectors/trending-facets/connectTrendingFacets.ts b/packages/instantsearch-core/src/connectors/trending-facets/connectTrendingFacets.ts new file mode 100644 index 00000000000..c3086784a2f --- /dev/null +++ b/packages/instantsearch-core/src/connectors/trending-facets/connectTrendingFacets.ts @@ -0,0 +1,205 @@ +import { + createDocumentationMessageGenerator, + checkRendering, + noop, + TAG_PLACEHOLDER, +} from '../../lib/utils'; +import { escape } from '../../lib/utils/escape-html'; + +import type { + Connector, + TransformItems, + Renderer, + Unmounter, + UnknownWidgetParams, + RecommendResponse, +} from '../../types'; +import type { TrendingFacetItem } from '../../types/recommend'; +import type { PlainSearchParameters } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'trending-facets', + connector: true, +}); + +export type TrendingFacetsRenderState = { + /** + * The trending facet values from the Algolia Recommend API. + */ + items: TrendingFacetItem[]; +}; + +export type TrendingFacetsConnectorParams = { + /** + * The facet attribute to get trending values for. + */ + facetName: string; + /** + * The number of recommendations to retrieve. + */ + limit?: 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' + >; + /** + * Whether to escape HTML tags from items string values. + * + * @default true + */ + escapeHTML?: boolean; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems< + TrendingFacetItem, + { results: RecommendResponse } + >; +}; + +export type TrendingFacetsWidgetDescription = { + $$type: 'ais.trendingFacets'; + renderState: TrendingFacetsRenderState; +}; + +export type TrendingFacetsConnector = Connector< + TrendingFacetsWidgetDescription, + TrendingFacetsConnectorParams +>; + +export default (function connectTrendingFacets< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer< + TrendingFacetsRenderState, + TWidgetParams & TrendingFacetsConnectorParams + >, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams: TWidgetParams & TrendingFacetsConnectorParams) => { + const { + facetName, + limit, + threshold, + fallbackParameters, + queryParameters, + // @MAJOR: this can default to false + escapeHTML = true, + transformItems = ((items) => items) as NonNullable< + TrendingFacetsConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if (!facetName) { + throw new Error( + withUsage('The `facetName` option is required.') + ); + } + + return { + dependsOn: 'recommend', + $$type: 'ais.trendingFacets', + + 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, helper, instantSearchInstance }) { + void helper; + void instantSearchInstance; + + if (results === null || results === undefined) { + return { items: [], widgetParams }; + } + + let items: TrendingFacetItem[] = ( + (results as RecommendResponse).hits || [] + ).map((hit: any) => ({ + facetName: hit.facetName as string, + facetValue: hit.facetValue as string, + _score: hit._score as number, + })); + + if (escapeHTML) { + items = items.map((item) => ({ + ...item, + facetValue: escape(item.facetValue), + })); + } + + items = transformItems(items, { + results: results as RecommendResponse, + }); + + return { + items, + widgetParams, + }; + }, + + dispose({ recommendState }) { + unmountFn(); + return recommendState.removeParams(this.$$id!); + }, + + getWidgetParameters(state) { + // v4 TrendingFacetsQuery doesn't include queryParameters or + // fallbackParameters, but the v5 API and the helper support them. + return state.removeParams(this.$$id!).addTrendingFacets({ + facetName, + maxRecommendations: limit, + threshold, + fallbackParameters: fallbackParameters + ? { + ...fallbackParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + } + : undefined, + queryParameters: { + ...queryParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + }, + $$id: this.$$id!, + } as any); + }, + }; + }; +} satisfies TrendingFacetsConnector); diff --git a/packages/instantsearch-core/src/connectors/trending-items/connectTrendingItems.ts b/packages/instantsearch-core/src/connectors/trending-items/connectTrendingItems.ts new file mode 100644 index 00000000000..a2548fb767e --- /dev/null +++ b/packages/instantsearch-core/src/connectors/trending-items/connectTrendingItems.ts @@ -0,0 +1,253 @@ +import { + createDocumentationMessageGenerator, + checkRendering, + noop, + escapeHits, + TAG_PLACEHOLDER, + getObjectType, + createSendEventForHits, + addAbsolutePosition, + addQueryID, +} from '../../lib/utils'; + +import type { SendEventForHits } from '../../lib/utils'; +import type { + Connector, + TransformItems, + BaseHit, + Renderer, + Unmounter, + UnknownWidgetParams, + RecommendResponse, + Hit, + AlgoliaHit, +} from '../../types'; +import type { PlainSearchParameters } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'trending-items', + connector: true, +}); + +export type TrendingItemsRenderState< + THit extends NonNullable = BaseHit +> = { + /** + * The matched recommendations from the Algolia API. + */ + items: Array>; + + /** + * Sends an event to the Insights middleware. + */ + sendEvent: SendEventForHits; +}; + +export type TrendingItemsConnectorParams< + THit extends NonNullable = BaseHit +> = ( + | { + /** + * The facet attribute to get recommendations for. + */ + facetName: string; + /** + * The facet value to get recommendations for. + */ + facetValue: string; + } + | { + facetName?: string; + facetValue?: string; + } +) & { + /** + * The number of recommendations to retrieve. + */ + limit?: 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' + >; + /** + * Whether to escape HTML tags from items string values. + * + * @default true + */ + escapeHTML?: boolean; + /** + * Function to transform the items passed to the templates. + */ + transformItems?: TransformItems< + Hit, + { results: RecommendResponse> } + >; +}; + +export type TrendingItemsWidgetDescription< + THit extends NonNullable = BaseHit +> = { + $$type: 'ais.trendingItems'; + renderState: TrendingItemsRenderState; +}; + +export type TrendingItemsConnector = BaseHit> = + Connector< + TrendingItemsWidgetDescription, + TrendingItemsConnectorParams + >; + +export default (function connectTrendingItems< + TWidgetParams extends UnknownWidgetParams +>( + renderFn: Renderer< + TrendingItemsRenderState, + TWidgetParams & TrendingItemsConnectorParams + >, + unmountFn: Unmounter = noop +) { + checkRendering(renderFn, withUsage()); + + return = BaseHit>( + widgetParams: TWidgetParams & TrendingItemsConnectorParams + ) => { + const { + facetName, + facetValue, + limit, + threshold, + fallbackParameters, + queryParameters, + // @MAJOR: this can default to false + escapeHTML = true, + transformItems = ((items) => items) as NonNullable< + TrendingItemsConnectorParams['transformItems'] + >, + } = widgetParams || {}; + + if ((facetName && !facetValue) || (!facetName && facetValue)) { + throw new Error( + withUsage( + `When you provide facetName (received type ${getObjectType( + facetName + )}), you must also provide facetValue (received type ${getObjectType( + facetValue + )}).` + ) + ); + } + + let sendEvent: SendEventForHits; + + 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, helper, instantSearchInstance }) { + if (!sendEvent) { + sendEvent = createSendEventForHits({ + instantSearchInstance, + helper, + widgetType: this.$$type, + }); + } + if (results === null || results === undefined) { + return { items: [], widgetParams, sendEvent }; + } + + if (escapeHTML && results.hits.length > 0) { + results.hits = escapeHits(results.hits); + } + + const itemsWithAbsolutePosition = addAbsolutePosition( + results.hits, + 0, + 1 + ); + + const itemsWithAbsolutePositionAndQueryID = addQueryID( + itemsWithAbsolutePosition, + results.queryID + ); + + const transformedItems = transformItems( + itemsWithAbsolutePositionAndQueryID, + { + results: results as RecommendResponse>, + } + ); + + return { + items: transformedItems, + widgetParams, + sendEvent, + }; + }, + + dispose({ recommendState }) { + unmountFn(); + return recommendState.removeParams(this.$$id!); + }, + + getWidgetParameters(state) { + return state.removeParams(this.$$id!).addTrendingItems({ + facetName: facetName as string, + facetValue: facetValue as string, + maxRecommendations: limit, + threshold, + fallbackParameters: fallbackParameters + ? { + ...fallbackParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + } + : undefined, + queryParameters: { + ...queryParameters, + ...(escapeHTML ? TAG_PLACEHOLDER : {}), + }, + $$id: this.$$id!, + }); + }, + }; + }; +} satisfies TrendingItemsConnector); diff --git a/packages/instantsearch-core/src/connectors/voice-search/connectVoiceSearch.ts b/packages/instantsearch-core/src/connectors/voice-search/connectVoiceSearch.ts new file mode 100644 index 00000000000..ad252d07f79 --- /dev/null +++ b/packages/instantsearch-core/src/connectors/voice-search/connectVoiceSearch.ts @@ -0,0 +1,222 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; +import builtInCreateVoiceSearchHelper from '../../lib/voiceSearchHelper'; + +import type { + CreateVoiceSearchHelper, + VoiceListeningState, +} from '../../lib/voiceSearchHelper/types'; +import type { Connector, WidgetRenderState } from '../../types'; +import type { PlainSearchParameters } from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'voice-search', + connector: true, +}); + +export type VoiceSearchConnectorParams = { + searchAsYouSpeak?: boolean; + language?: string; + additionalQueryParameters?: (params: { + query: string; + }) => PlainSearchParameters | void; + createVoiceSearchHelper?: CreateVoiceSearchHelper; +}; + +export type VoiceSearchRenderState = { + isBrowserSupported: boolean; + isListening: boolean; + toggleListening: () => void; + voiceListeningState: VoiceListeningState; +}; + +export type VoiceSearchWidgetDescription = { + $$type: 'ais.voiceSearch'; + renderState: VoiceSearchRenderState; + indexRenderState: { + voiceSearch: WidgetRenderState< + VoiceSearchRenderState, + VoiceSearchConnectorParams + >; + }; + indexUiState: { + query: string; + }; +}; + +export type VoiceSearchConnector = Connector< + VoiceSearchWidgetDescription, + VoiceSearchConnectorParams +>; + +const connectVoiceSearch: VoiceSearchConnector = function connectVoiceSearch( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { + searchAsYouSpeak = false, + language, + additionalQueryParameters, + createVoiceSearchHelper = builtInCreateVoiceSearchHelper, + } = widgetParams; + + return { + $$type: 'ais.voiceSearch', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + voiceSearch: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState(renderOptions) { + const { helper, instantSearchInstance } = renderOptions; + if (!(this as any)._refine) { + (this as any)._refine = (query: string): void => { + if (query !== helper.state.query) { + const queryLanguages = language + ? [language.split('-')[0]] + : undefined; + // @ts-ignore queryLanguages is allowed to be a string, not just an array + helper.setQueryParameter('queryLanguages', queryLanguages); + + if (typeof additionalQueryParameters === 'function') { + helper.setState( + helper.state.setQueryParameters({ + ignorePlurals: true, + removeStopWords: true, + // @ts-ignore optionalWords is allowed to be a string too + optionalWords: query, + ...additionalQueryParameters({ query }), + }) + ); + } + + helper.setQuery(query).search(); + } + }; + } + + if (!(this as any)._voiceSearchHelper) { + (this as any)._voiceSearchHelper = createVoiceSearchHelper({ + searchAsYouSpeak, + language, + onQueryChange: (query) => (this as any)._refine(query), + onStateChange: () => { + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + }); + } + + const { + isBrowserSupported, + isListening, + startListening, + stopListening, + getState, + } = (this as any)._voiceSearchHelper; + + return { + isBrowserSupported: isBrowserSupported(), + isListening: isListening(), + toggleListening() { + if (!isBrowserSupported()) { + return; + } + if (isListening()) { + stopListening(); + } else { + startListening(); + } + }, + voiceListeningState: getState(), + widgetParams, + }; + }, + + dispose({ state }) { + (this as any)._voiceSearchHelper.dispose(); + + unmountFn(); + + let newState = state; + if (typeof additionalQueryParameters === 'function') { + const additional = additionalQueryParameters({ query: '' }); + const toReset = additional + ? ( + Object.keys(additional) as Array + ).reduce((acc, current) => { + // @ts-ignore search parameters is typed as readonly in v4 + acc[current] = undefined; + return acc; + }, {}) + : {}; + newState = state.setQueryParameters({ + // @ts-ignore (queryLanguages is not added to algoliasearch v3) + queryLanguages: undefined, + ignorePlurals: undefined, + removeStopWords: undefined, + optionalWords: undefined, + ...toReset, + }); + } + + return newState.setQueryParameter('query', undefined); + }, + + getWidgetUiState(uiState, { searchParameters }) { + const query = searchParameters.query || ''; + + if (!query) { + return uiState; + } + + return { + ...uiState, + query, + }; + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + return searchParameters.setQueryParameter('query', uiState.query || ''); + }, + }; + }; +}; + +export default connectVoiceSearch; diff --git a/packages/instantsearch-core/src/helpers/get-insights-anonymous-user-token.ts b/packages/instantsearch-core/src/helpers/get-insights-anonymous-user-token.ts new file mode 100644 index 00000000000..9e698a0e519 --- /dev/null +++ b/packages/instantsearch-core/src/helpers/get-insights-anonymous-user-token.ts @@ -0,0 +1,39 @@ +import { warning } from '../lib/utils'; + +export const ANONYMOUS_TOKEN_COOKIE_KEY = '_ALGOLIA'; + +function getCookie(name: string): string | undefined { + if (typeof document !== 'object' || typeof document.cookie !== 'string') { + return undefined; + } + + const prefix = `${name}=`; + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + let cookie = cookies[i]; + while (cookie.charAt(0) === ' ') { + cookie = cookie.substring(1); + } + if (cookie.indexOf(prefix) === 0) { + return cookie.substring(prefix.length, cookie.length); + } + } + return undefined; +} + +export function getInsightsAnonymousUserTokenInternal(): string | undefined { + return getCookie(ANONYMOUS_TOKEN_COOKIE_KEY); +} + +/** + * @deprecated This function will be still supported in 4.x releases, but not further. It is replaced by the `insights` middleware. For more information, visit https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/how-to/send-click-and-conversion-events-with-instantsearch/js/ + */ +export default function getInsightsAnonymousUserToken(): string | undefined { + warning( + false, + `\`getInsightsAnonymousUserToken\` function has been deprecated. It is still supported in 4.x releases, but not further. It is replaced by the \`insights\` middleware. + +For more information, visit https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/how-to/send-click-and-conversion-events-with-instantsearch/js/` + ); + return getInsightsAnonymousUserTokenInternal(); +} diff --git a/packages/instantsearch-core/src/helpers/index.ts b/packages/instantsearch-core/src/helpers/index.ts new file mode 100644 index 00000000000..f5905c4010b --- /dev/null +++ b/packages/instantsearch-core/src/helpers/index.ts @@ -0,0 +1 @@ +export { getInsightsAnonymousUserTokenInternal } from './get-insights-anonymous-user-token'; diff --git a/packages/instantsearch-core/src/index.ts b/packages/instantsearch-core/src/index.ts new file mode 100644 index 00000000000..a2e3a3a4b2d --- /dev/null +++ b/packages/instantsearch-core/src/index.ts @@ -0,0 +1,44 @@ +export { default as version } from './version'; +export * from './connectors'; +export { AbstractChat } from './lib/ai-lite'; +export { parseJsonEventStream, processStream } from './lib/ai-lite'; +export type { + UIMessage, + UIMessagePart, + UIMessageChunk, + ChatTransport, + ChatStatus, + ChatRequestOptions, +} from './lib/ai-lite'; +export { + Chat, + ChatState, + CACHE_KEY, + SearchIndexToolType, + RecommendToolType, + MemorizeToolType, + MemorySearchToolType, + PonderToolType, + DisplayResultsToolType, +} from './lib/chat'; +export { default as createVoiceSearchHelper } from './lib/voiceSearchHelper'; +export type { + CreateVoiceSearchHelper, + VoiceSearchHelper, + VoiceSearchHelperParams, + VoiceListeningState, +} from './lib/voiceSearchHelper/types'; +export * from './lib/infiniteHitsCache'; +export * from './lib/public'; +export * from './lib/routers'; +export * from './lib/server'; +export * from './lib/stateMappings'; +export * from './middlewares'; +export * from './types'; +export * from './widgets'; +export type { + FacetRefinement, + NumericRefinement, + Refinement, +} from './lib/public'; +export type { InsightsEvent } from './middlewares'; diff --git a/packages/instantsearch-core/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch-core/src/lib/ai-lite/abstract-chat.ts new file mode 100644 index 00000000000..ce2d34beba4 --- /dev/null +++ b/packages/instantsearch-core/src/lib/ai-lite/abstract-chat.ts @@ -0,0 +1,1196 @@ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { processStream } from './stream-parser'; +import { generateId as defaultGenerateId, SerialJobExecutor } from './utils'; + +import type { + ChatInit, + ChatRequestOptions, + ChatState, + ChatStatus, + ChatTransport, + CreateUIMessage, + FileUIPart, + IdGenerator, + InferUIMessageChunk, + InferUIMessageMetadata, + InferUIMessageToolCall, + InferUIMessageTools, + UIMessage, + UIMessageChunk, + ChatOnErrorCallback, + ChatOnToolCallCallback, + ChatOnFinishCallback, + ChatOnDataCallback, +} from './types'; + +type ActiveResponse = { + abortController: AbortController; + stream?: ReadableStream; +}; + +const tryParseJson = (value: string): unknown | undefined => { + try { + return JSON.parse(value); + } catch { + return undefined; + } +}; + +const repairPartialJson = (value: string): string => { + let repaired = value.trim(); + + if (!repaired) { + return repaired; + } + + let inString = false; + let isEscaped = false; + const stack: Array<'{' | '['> = []; + + for (let index = 0; index < repaired.length; index++) { + const char = repaired[index]; + if (inString) { + if (isEscaped) { + isEscaped = false; + } else if (char === '\\') { + isEscaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === '{' || char === '[') { + stack.push(char); + continue; + } + + if (char === '}' && stack[stack.length - 1] === '{') { + stack.pop(); + continue; + } + + if (char === ']' && stack[stack.length - 1] === '[') { + stack.pop(); + } + } + + if (inString && !isEscaped) { + repaired += '"'; + } + + repaired = repaired.replace(/,\s*$/u, ''); + + if (stack.length > 0) { + repaired += stack + .reverse() + .map((opening) => (opening === '{' ? '}' : ']')) + .join(''); + } + + return repaired.replace(/,\s*([}\]])/gu, '$1'); +}; + +const parseToolInputDelta = ( + accumulatedRawInput: string, + fallbackInput: unknown +): unknown => { + const normalized = accumulatedRawInput.trim(); + if (!normalized) { + return fallbackInput; + } + + const directParsed = tryParseJson(normalized); + if (directParsed !== undefined) { + return directParsed; + } + + const repairedParsed = tryParseJson(repairPartialJson(normalized)); + if (repairedParsed !== undefined) { + return repairedParsed; + } + + return fallbackInput; +}; + +/** + * Abstract base class for chat implementations. + */ +export abstract class AbstractChat { + readonly id: string; + readonly generateId: IdGenerator; + protected state: ChatState; + + private readonly transport?: ChatTransport; + private onError?: ChatOnErrorCallback; + private onToolCall?: ChatOnToolCallCallback; + private onFinish?: ChatOnFinishCallback; + private onData?: ChatOnDataCallback; + private sendAutomaticallyWhen?: (options: { + messages: TUIMessage[]; + }) => boolean | PromiseLike; + private shouldRepairToolInput?: (toolName: string) => boolean; + + private activeResponse: ActiveResponse< + InferUIMessageChunk + > | null = null; + private jobExecutor = new SerialJobExecutor(); + + constructor({ + generateId = defaultGenerateId, + id = generateId(), + transport, + state, + onError, + onToolCall, + onFinish, + onData, + sendAutomaticallyWhen, + shouldRepairToolInput, + }: Omit, 'messages'> & { + state: ChatState; + }) { + this.id = id; + this.generateId = generateId; + this.state = state; + this.transport = transport; + this.onError = onError; + this.onToolCall = onToolCall; + this.onFinish = onFinish; + this.onData = onData; + this.sendAutomaticallyWhen = sendAutomaticallyWhen; + this.shouldRepairToolInput = shouldRepairToolInput; + } + + /** + * Hook status: + * + * - `submitted`: The message has been sent to the API and we're awaiting the start of the response stream. + * - `streaming`: The response is actively streaming in from the API, receiving chunks of data. + * - `ready`: The full response has been received and processed; a new user message can be submitted. + * - `error`: An error occurred during the API request, preventing successful completion. + */ + get status(): ChatStatus { + return this.state.status; + } + + protected setStatus({ + status, + error, + }: { + status: ChatStatus; + error?: Error; + }): void { + this.state.status = status; + if (error !== undefined) { + this.state.error = error; + } + } + + get error(): Error | undefined { + return this.state.error; + } + + get messages(): TUIMessage[] { + return this.state.messages; + } + + set messages(messages: TUIMessage[]) { + this.state.messages = messages; + } + + get lastMessage(): TUIMessage | undefined { + return this.state.messages[this.state.messages.length - 1]; + } + + /** + * Appends or replaces a user message to the chat list. This triggers the API call to fetch + * the assistant's response. + */ + sendMessage = ( + message?: + | (CreateUIMessage & { + text?: never; + files?: never; + messageId?: string; + }) + | { + text: string; + files?: FileList | FileUIPart[]; + metadata?: InferUIMessageMetadata; + parts?: never; + messageId?: string; + } + | { + files: FileList | FileUIPart[]; + metadata?: InferUIMessageMetadata; + parts?: never; + messageId?: string; + }, + options?: ChatRequestOptions + ): Promise => { + return this.jobExecutor.run(() => { + // Build the user message + let userMessagePromise: Promise; + + if (message) { + const messageId = message.messageId || this.generateId(); + + if ('parts' in message && message.parts) { + // Full message with parts provided + userMessagePromise = Promise.resolve({ + id: messageId, + role: 'user', + ...message, + } as TUIMessage); + } else if ('text' in message && message.text) { + // Build from text + const parts: TUIMessage['parts'] = [ + { type: 'text', text: message.text }, + ]; + + // Add file parts if provided + if (message.files) { + userMessagePromise = this.convertFilesToParts(message.files).then( + (fileParts) => { + parts.push(...fileParts); + return { + id: messageId, + role: 'user', + parts, + metadata: message.metadata, + } as TUIMessage; + } + ); + } else { + userMessagePromise = Promise.resolve({ + id: messageId, + role: 'user', + parts, + metadata: message.metadata, + } as TUIMessage); + } + } else if ('files' in message && message.files) { + // Files only + userMessagePromise = this.convertFilesToParts(message.files).then( + (fileParts) => + ({ + id: messageId, + role: 'user', + parts: fileParts, + metadata: message.metadata, + } as TUIMessage) + ); + } else { + userMessagePromise = Promise.resolve(undefined); + } + } else { + userMessagePromise = Promise.resolve(undefined); + } + + return userMessagePromise.then((userMessage) => { + if (userMessage) { + this.state.pushMessage(userMessage); + } + + return this.makeRequest({ + trigger: 'submit-message', + messageId: userMessage?.id, + ...options, + }); + }); + }); + }; + + /** + * Regenerate the assistant message with the provided message id. + * If no message id is provided, the last assistant message will be regenerated. + */ + regenerate = ({ + messageId, + ...options + }: { messageId?: string } & ChatRequestOptions = {}): Promise => { + return this.jobExecutor.run(() => { + // Find the message to regenerate from + let targetIndex = -1; + + if (messageId) { + targetIndex = this.state.messages.findIndex((m) => m.id === messageId); + } else { + // Find the last assistant message + for (let i = this.state.messages.length - 1; i >= 0; i--) { + if (this.state.messages[i].role === 'assistant') { + targetIndex = i; + break; + } + } + } + + if (targetIndex >= 0) { + // Remove the assistant message and all messages after it + this.state.messages = this.state.messages.slice(0, targetIndex); + } + + return this.makeRequest({ + trigger: 'regenerate-message', + messageId, + ...options, + }); + }); + }; + + /** + * Attempt to resume an ongoing streaming response. + */ + resumeStream = (options?: ChatRequestOptions): Promise => { + return this.jobExecutor.run(() => { + if (!this.transport) { + return Promise.reject( + new Error( + 'Transport is required for resuming stream. Please provide a transport when initializing the chat.' + ) + ); + } + + this.setStatus({ status: 'submitted' }); + + return this.transport + .reconnectToStream({ + chatId: this.id, + ...options, + }) + .then( + (stream) => { + if (stream) { + return this.processStreamWithCallbacks(stream); + } else { + this.setStatus({ status: 'ready' }); + return Promise.resolve(); + } + }, + (error) => { + this.handleError(error as Error); + return Promise.resolve(); + } + ); + }); + }; + + /** + * Clear the error state and set the status to ready if the chat is in an error state. + */ + clearError = (): void => { + if (this.state.status === 'error') { + this.setStatus({ status: 'ready', error: undefined }); + } + }; + + /** + * Add a tool result for a tool call. + */ + addToolResult = >({ + tool, + toolCallId, + output, + }: { + tool: TTool; + toolCallId: string; + output: InferUIMessageTools[TTool]['output']; + }): Promise => { + return this.jobExecutor.run(() => { + // Find the message with this tool call + const messageIndex = this.state.messages.findIndex( + (m) => + m.parts?.some( + (p) => + ('toolCallId' in p && p.toolCallId === toolCallId) || + ('type' in p && p.type === `tool-${String(tool)}`) + ) ?? false + ); + + if (messageIndex === -1) return Promise.resolve(); + + const message = this.state.messages[messageIndex]; + const updatedParts = message.parts.map((part) => { + if ( + 'toolCallId' in part && + part.toolCallId === toolCallId && + 'state' in part + ) { + return { + ...part, + state: 'output-available' as const, + output, + }; + } + return part; + }); + + this.state.replaceMessage(messageIndex, { + ...message, + parts: updatedParts, + } as TUIMessage); + + // Check if we should auto-send based on sendAutomaticallyWhen + if (this.sendAutomaticallyWhen) { + return Promise.resolve( + this.sendAutomaticallyWhen({ + messages: this.state.messages, + }) + ).then((shouldSend) => { + if (shouldSend) { + return this.makeRequest({ + trigger: 'submit-message', + }); + } + return Promise.resolve(); + }); + } + + return Promise.resolve(); + }); + }; + + /** + * Abort the current request immediately, keep the generated tokens if any. + */ + stop = (): Promise => { + if (this.activeResponse) { + this.activeResponse.abortController.abort(); + this.activeResponse = null; + } + this.setStatus({ status: 'ready' }); + return Promise.resolve(); + }; + + private makeRequest( + options: { + trigger: 'submit-message' | 'regenerate-message'; + messageId?: string; + } & ChatRequestOptions + ): Promise { + if (!this.transport) { + return Promise.reject( + new Error( + 'Transport is required for sending messages. Please provide a transport when initializing the chat.' + ) + ); + } + + // Abort any existing request + if (this.activeResponse) { + this.activeResponse.abortController.abort(); + } + + const abortController = new AbortController(); + this.activeResponse = { abortController }; + + this.setStatus({ status: 'submitted' }); + + return this.transport + .sendMessages({ + chatId: this.id, + messages: this.state.messages, + abortSignal: abortController.signal, + trigger: options.trigger, + messageId: options.messageId, + headers: options.headers, + body: options.body, + requestMetadata: options.metadata, + }) + .then( + (stream) => { + this.activeResponse!.stream = stream; + return this.processStreamWithCallbacks(stream); + }, + (error) => { + if ((error as Error).name === 'AbortError') { + // Request was aborted, don't treat as error + return Promise.resolve(); + } + this.handleError(error as Error); + return Promise.resolve(); + } + ); + } + + private processStreamWithCallbacks( + stream: ReadableStream> + ): Promise { + this.setStatus({ status: 'streaming' }); + + let currentMessageId: string | undefined; + let currentMessage: TUIMessage | undefined; + let currentMessageIndex = -1; + let isAbort = false; + let isDisconnect = false; + let isError = false; + + // Track current text/reasoning part state + let currentTextPartId: string | undefined; + let currentReasoningPartId: string | undefined; + const toolRawInputByCallId: Record = {}; + const toolRawOutputByCallId: Record = {}; + + // Promise chain for handling tool calls that return promises + let pendingToolCall: Promise = Promise.resolve(); + + return new Promise((resolve) => { + processStream( + stream as ReadableStream, + // eslint-disable-next-line complexity + (chunk) => { + switch (chunk.type) { + case 'start': { + currentMessageId = chunk.messageId || this.generateId(); + + // Check if we're continuing an existing message or creating a new one + const lastMessage = this.lastMessage; + if ( + lastMessage && + lastMessage.role === 'assistant' && + lastMessage.id === currentMessageId + ) { + currentMessage = lastMessage; + currentMessageIndex = this.state.messages.length - 1; + } else { + currentMessage = { + id: currentMessageId, + role: 'assistant', + parts: [], + metadata: chunk.messageMetadata, + } as unknown as TUIMessage; + this.state.pushMessage(currentMessage); + currentMessageIndex = this.state.messages.length - 1; + } + break; + } + + case 'text-start': { + if (!currentMessage) break; + currentTextPartId = chunk.id; + + const textPart = { + type: 'text' as const, + text: '', + state: 'streaming' as const, + providerMetadata: chunk.providerMetadata, + }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, textPart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'text-delta': { + if (!currentMessage || !currentTextPartId) break; + + const partIndex = currentMessage.parts.findIndex( + (p) => p.type === 'text' && p.state === 'streaming' + ); + if (partIndex === -1) break; + + const updatedParts = [...currentMessage.parts]; + const textPart = updatedParts[partIndex] as { + type: 'text'; + text: string; + state?: 'streaming' | 'done'; + }; + updatedParts[partIndex] = { + ...textPart, + text: textPart.text + chunk.delta, + }; + + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'text-end': { + if (!currentMessage) break; + + const partIndex = currentMessage.parts.findIndex( + (p) => p.type === 'text' && p.state === 'streaming' + ); + if (partIndex === -1) break; + + const updatedParts = [...currentMessage.parts]; + const textPart = updatedParts[partIndex] as { + type: 'text'; + text: string; + state?: 'streaming' | 'done'; + }; + updatedParts[partIndex] = { + ...textPart, + state: 'done' as const, + }; + + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + currentTextPartId = undefined; + break; + } + + case 'reasoning-start': { + if (!currentMessage) break; + currentReasoningPartId = chunk.id; + + const reasoningPart = { + type: 'reasoning' as const, + text: '', + state: 'streaming' as const, + providerMetadata: chunk.providerMetadata, + }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, reasoningPart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'reasoning-delta': { + if (!currentMessage || !currentReasoningPartId) break; + + const partIndex = currentMessage.parts.findIndex( + (p) => p.type === 'reasoning' && p.state === 'streaming' + ); + if (partIndex === -1) break; + + const updatedParts = [...currentMessage.parts]; + const reasoningPart = updatedParts[partIndex] as { + type: 'reasoning'; + text: string; + state?: 'streaming' | 'done'; + }; + updatedParts[partIndex] = { + ...reasoningPart, + text: reasoningPart.text + chunk.delta, + }; + + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'reasoning-end': { + if (!currentMessage) break; + + const partIndex = currentMessage.parts.findIndex( + (p) => p.type === 'reasoning' && p.state === 'streaming' + ); + if (partIndex === -1) break; + + const updatedParts = [...currentMessage.parts]; + const reasoningPart = updatedParts[partIndex] as { + type: 'reasoning'; + text: string; + state?: 'streaming' | 'done'; + }; + updatedParts[partIndex] = { + ...reasoningPart, + state: 'done' as const, + }; + + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + currentReasoningPartId = undefined; + break; + } + + case 'tool-input-start': { + if (!currentMessage) break; + + const initialRawInput = + typeof chunk.input === 'string' + ? chunk.input + : chunk.input !== undefined + ? JSON.stringify(chunk.input) + : ''; + + toolRawInputByCallId[chunk.toolCallId] = initialRawInput; + + const toolPart = { + type: `tool-${chunk.toolName}` as const, + toolCallId: chunk.toolCallId, + state: 'input-streaming' as const, + input: chunk.input, + rawInput: initialRawInput || undefined, + providerExecuted: chunk.providerExecuted, + }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, toolPart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'tool-input-delta': { + if (!currentMessage) break; + + const toolIndex = currentMessage.parts.findIndex( + (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId + ); + + const existingPart = + toolIndex >= 0 + ? (currentMessage.parts[toolIndex] as any) + : null; + const previousRawInput = + existingPart?.rawInput ?? + toolRawInputByCallId[chunk.toolCallId] ?? + ''; + const nextRawInput = `${previousRawInput}${chunk.inputTextDelta}`; + toolRawInputByCallId[chunk.toolCallId] = nextRawInput; + + const toolName = + chunk.toolName ?? existingPart?.type?.replace('tool-', ''); + const shouldRepair = toolName + ? this.shouldRepairToolInput?.(toolName) ?? true + : true; + const parsedInput = shouldRepair + ? parseToolInputDelta(nextRawInput, existingPart?.input) + : existingPart?.input; + + const nextToolPart = { + ...(existingPart ?? { + type: `tool-${chunk.toolName}` as const, + toolCallId: chunk.toolCallId, + }), + state: 'input-streaming' as const, + input: parsedInput, + rawInput: nextRawInput, + }; + + if (toolIndex >= 0) { + const updatedParts = [...currentMessage.parts]; + updatedParts[toolIndex] = nextToolPart; + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + } else { + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, nextToolPart], + } as TUIMessage; + } + + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'tool-input-available': { + if (!currentMessage) break; + + delete toolRawInputByCallId[chunk.toolCallId]; + + // Find existing tool part or create new one + const existingIndex = currentMessage.parts.findIndex( + (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId + ); + + const toolPart = { + type: `tool-${chunk.toolName}` as const, + toolCallId: chunk.toolCallId, + state: 'input-available' as const, + input: chunk.input, + callProviderMetadata: chunk.callProviderMetadata, + providerExecuted: chunk.providerExecuted, + }; + + if (existingIndex >= 0) { + const updatedParts = [...currentMessage.parts]; + updatedParts[existingIndex] = toolPart; + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + } else { + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, toolPart], + } as TUIMessage; + } + this.state.replaceMessage(currentMessageIndex, currentMessage); + + // Trigger onToolCall callback only for client-executed tools + // (server-executed tools have providerExecuted: true and don't need client handling) + if (this.onToolCall && !chunk.providerExecuted) { + const result = this.onToolCall({ + toolCall: { + toolName: chunk.toolName, + toolCallId: chunk.toolCallId, + input: chunk.input, + dynamic: 'dynamic' in chunk ? chunk.dynamic : undefined, + } as InferUIMessageToolCall, + }); + if (result && typeof result.then === 'function') { + pendingToolCall = pendingToolCall.then(() => result); + } + } + break; + } + + case 'data-tool-output-delta': { + if (!currentMessage) break; + + const { toolCallId, toolName, delta } = chunk.data as { + toolCallId: string; + toolName: string; + delta: string; + }; + + const toolIndex = currentMessage.parts.findIndex( + (p) => 'toolCallId' in p && p.toolCallId === toolCallId + ); + + const existingPart = + toolIndex >= 0 + ? (currentMessage.parts[toolIndex] as any) + : null; + const previousRawOutput = + existingPart?.rawOutput ?? + toolRawOutputByCallId[toolCallId] ?? + ''; + const nextRawOutput = `${previousRawOutput}${delta}`; + toolRawOutputByCallId[toolCallId] = nextRawOutput; + + const parsedOutput = parseToolInputDelta( + nextRawOutput, + existingPart?.output + ); + + const nextToolPart = { + ...(existingPart ?? { + type: `tool-${toolName}` as const, + toolCallId, + input: undefined, + }), + state: 'output-available' as const, + output: parsedOutput, + rawOutput: nextRawOutput, + preliminary: true, + }; + + if (toolIndex >= 0) { + const updatedParts = [...currentMessage.parts]; + updatedParts[toolIndex] = nextToolPart; + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + } else { + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, nextToolPart], + } as TUIMessage; + } + + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'tool-output-available': { + if (!currentMessage) break; + + const toolIndex = currentMessage.parts.findIndex( + (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId + ); + + if (toolIndex >= 0) { + delete toolRawInputByCallId[chunk.toolCallId]; + delete toolRawOutputByCallId[chunk.toolCallId]; + + const updatedParts = [...currentMessage.parts]; + const existingPart = updatedParts[toolIndex] as any; + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + const { rawOutput: _ignored, ...rest } = existingPart; + updatedParts[toolIndex] = { + ...rest, + state: 'output-available', + output: chunk.output, + callProviderMetadata: chunk.callProviderMetadata, + preliminary: chunk.preliminary, + }; + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + } + break; + } + + case 'tool-error': { + if (!currentMessage) break; + + const toolIndex = currentMessage.parts.findIndex( + (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId + ); + + if (toolIndex >= 0) { + delete toolRawInputByCallId[chunk.toolCallId]; + delete toolRawOutputByCallId[chunk.toolCallId]; + + const updatedParts = [...currentMessage.parts]; + const existingPart = updatedParts[toolIndex] as any; + const { + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + rawOutput: _ignoredRawOutput, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars + preliminary: _ignoredPreliminary, + ...rest + } = existingPart; + updatedParts[toolIndex] = { + ...rest, + state: 'output-error', + errorText: chunk.errorText, + input: chunk.input ?? existingPart.input, + callProviderMetadata: chunk.callProviderMetadata, + }; + currentMessage = { + ...currentMessage, + parts: updatedParts, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + } + break; + } + + case 'source-url': { + if (!currentMessage) break; + + const sourcePart = { + type: 'source-url' as const, + sourceId: chunk.sourceId, + url: chunk.url, + title: chunk.title, + }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, sourcePart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'source-document': { + if (!currentMessage) break; + + const docPart = { + type: 'source-document' as const, + sourceId: chunk.sourceId, + mediaType: chunk.mediaType, + title: chunk.title, + filename: chunk.filename, + providerMetadata: chunk.providerMetadata, + }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, docPart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'file': { + if (!currentMessage) break; + + const filePart = { + type: 'file' as const, + url: chunk.url, + mediaType: chunk.mediaType, + }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, filePart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'start-step': { + if (!currentMessage) break; + + const stepPart = { type: 'step-start' as const }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, stepPart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'message-metadata': { + if (!currentMessage) break; + + currentMessage = { + ...currentMessage, + metadata: chunk.messageMetadata, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + break; + } + + case 'error': { + isError = true; + throw new Error(chunk.errorText); + } + + case 'abort': { + isAbort = true; + break; + } + + case 'finish': { + if (currentMessage && chunk.messageMetadata !== undefined) { + currentMessage = { + ...currentMessage, + metadata: chunk.messageMetadata, + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + } + break; + } + + default: { + // Handle data parts (data-*) + const chunkType = (chunk as any).type as string; + if (chunkType?.startsWith('data-') && currentMessage) { + const dataPart = { + type: chunkType, + id: (chunk as any).id, + data: (chunk as any).data, + }; + + currentMessage = { + ...currentMessage, + parts: [...currentMessage.parts, dataPart], + } as TUIMessage; + this.state.replaceMessage(currentMessageIndex, currentMessage); + + // Trigger onData callback + if (this.onData) { + this.onData(dataPart as any); + } + } + } + } + }, + () => { + // Wait for any pending tool calls to complete + pendingToolCall.then(() => { + // Stream finished successfully + this.setStatus({ status: 'ready' }); + this.activeResponse = null; + + // Trigger onFinish callback + if (this.onFinish && currentMessage) { + this.onFinish({ + message: currentMessage, + messages: this.state.messages, + isAbort, + isDisconnect, + isError, + }); + } + + // Note: sendAutomaticallyWhen is only checked in addToolResult, + // not here. For server-executed tools, the server continues the + // conversation. For client-executed tools, addToolResult handles it. + resolve(); + }); + }, + (error) => { + if (error.name === 'AbortError') { + isAbort = true; + this.setStatus({ status: 'ready' }); + } else { + isDisconnect = true; + this.handleError(error); + } + + // Still call onFinish even on error/abort + if (this.onFinish && currentMessage) { + this.onFinish({ + message: currentMessage, + messages: this.state.messages, + isAbort, + isDisconnect, + isError, + }); + } + + resolve(); + } + ); + }); + } + + private handleError(error: Error): void { + this.setStatus({ status: 'error', error }); + + if (this.onError) { + this.onError(error); + } + } + + private convertFilesToParts( + files: FileList | FileUIPart[] + ): Promise { + if (Array.isArray(files)) { + return Promise.resolve(files); + } + + const promises: Array> = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + promises.push( + this.fileToDataUrl(file).then((dataUrl) => ({ + type: 'file' as const, + mediaType: file.type, + filename: file.name, + url: dataUrl, + })) + ); + } + return Promise.all(promises); + } + + private fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } +} diff --git a/packages/instantsearch-core/src/lib/ai-lite/index.ts b/packages/instantsearch-core/src/lib/ai-lite/index.ts new file mode 100644 index 00000000000..3f69531f593 --- /dev/null +++ b/packages/instantsearch-core/src/lib/ai-lite/index.ts @@ -0,0 +1,74 @@ +/** + * ai-lite module - a minimal reimplementation of the 'ai' package. + * + * This module provides the core chat functionality needed for InstantSearch + * without the full weight of the Vercel AI SDK. + */ + +// Classes +export { AbstractChat } from './abstract-chat'; +export { DefaultChatTransport, HttpChatTransport } from './transport'; + +// Utilities +export { + generateId, + lastAssistantMessageIsCompleteWithToolCalls, + SerialJobExecutor, +} from './utils'; + +// Stream parsing +export { parseJsonEventStream, processStream } from './stream-parser'; + +// Types +export type { + // Status + ChatStatus, + + // Message types + UIMessage, + UIMessagePart, + UIMessageChunk, + UIDataTypes, + UITools, + UITool, + ProviderMetadata, + + // Message part types + TextUIPart, + ReasoningUIPart, + ToolUIPart, + DynamicToolUIPart, + SourceUrlUIPart, + SourceDocumentUIPart, + FileUIPart, + StepStartUIPart, + DataUIPart, + + // Inference types + InferUIMessageMetadata, + InferUIMessageData, + InferUIMessageTools, + InferUIMessageToolCall, + InferUIMessageChunk, + + // State types + ChatState, + + // Transport types + ChatTransport, + ChatRequestOptions, + HttpChatTransportInitOptions, + PrepareSendMessagesRequest, + PrepareReconnectToStreamRequest, + Resolvable, + FetchFunction, + + // Init types + ChatInit, + IdGenerator, + ChatOnErrorCallback, + ChatOnToolCallCallback, + ChatOnFinishCallback, + ChatOnDataCallback, + CreateUIMessage, +} from './types'; diff --git a/packages/instantsearch-core/src/lib/ai-lite/stream-parser.ts b/packages/instantsearch-core/src/lib/ai-lite/stream-parser.ts new file mode 100644 index 00000000000..f4d9687756a --- /dev/null +++ b/packages/instantsearch-core/src/lib/ai-lite/stream-parser.ts @@ -0,0 +1,148 @@ +/** + * Stream parser for parsing SSE (Server-Sent Events) streams. + * The AI SDK 5 format uses SSE with JSON payloads prefixed by "data: ". + */ +import type { UIMessageChunk } from './types'; + +/** + * Parse a stream of bytes as SSE (Server-Sent Events) and convert to UIMessageChunk events. + * Handles the "data: " prefix used by the AI SDK 5 streaming format. + * + * @param stream - The input stream of raw bytes + * @returns A ReadableStream of parsed UIMessageChunk events + */ +export function parseJsonEventStream< + TChunk extends UIMessageChunk = UIMessageChunk +>(stream: ReadableStream): ReadableStream { + const decoder = new TextDecoder(); + let buffer = ''; + + return new ReadableStream({ + start(controller) { + const reader = stream.getReader(); + + const processChunk = (): void => { + reader.read().then( + ({ done, value }) => { + if (done) { + // Process any remaining data in the buffer + if (buffer.trim()) { + const jsonData = extractJsonFromLine(buffer.trim()); + if (jsonData) { + try { + const chunk = JSON.parse(jsonData) as TChunk; + controller.enqueue(chunk); + } catch { + // Ignore parsing errors for incomplete data at end + } + } + } + controller.close(); + return; + } + + // Decode the chunk and add to buffer + buffer += decoder.decode(value, { stream: true }); + + // Process complete lines + const lines = buffer.split('\n'); + // Keep the last potentially incomplete line in the buffer + buffer = lines.pop() || ''; + + for (let i = 0; i < lines.length; i++) { + const trimmedLine = lines[i].trim(); + // eslint-disable-next-line no-continue + if (!trimmedLine) continue; + + // Extract JSON from SSE data line or plain JSON + const jsonData = extractJsonFromLine(trimmedLine); + // eslint-disable-next-line no-continue + if (!jsonData) continue; + + try { + const chunk = JSON.parse(jsonData) as TChunk; + controller.enqueue(chunk); + } catch { + // Skip malformed lines + } + } + + // Continue reading + processChunk(); + }, + (error) => { + controller.error(error); + } + ); + }; + + processChunk(); + }, + }); +} + +/** + * Extract JSON data from an SSE line or plain JSON line. + * Handles both "data: {...}" SSE format and plain "{...}" NDJSON format. + */ +function extractJsonFromLine(line: string): string | null { + // Handle SSE format: "data: {...}" + if (line.startsWith('data:')) { + const data = line.slice(5).trim(); + // Skip SSE stream termination signal + if (data === '[DONE]') return null; + return data; + } + + // Handle plain JSON (NDJSON format) + if (line.startsWith('{')) { + return line; + } + + // Skip other SSE fields (event:, id:, retry:, etc.) + return null; +} + +/** + * Process a ReadableStream using a callback for each value. + * This is a non-async alternative to for-await-of iteration. + */ +export function processStream( + stream: ReadableStream, + onChunk: (chunk: T) => void | Promise, + onDone: () => void, + onError: (error: Error) => void +): void { + const reader = stream.getReader(); + + const read = (): void => { + reader.read().then( + ({ done, value }) => { + if (done) { + reader.releaseLock(); + onDone(); + return; + } + + const result = onChunk(value); + if (result && typeof result.then === 'function') { + result.then( + () => read(), + (error) => { + reader.releaseLock(); + onError(error as Error); + } + ); + } else { + read(); + } + }, + (error) => { + reader.releaseLock(); + onError(error as Error); + } + ); + }; + + read(); +} diff --git a/packages/instantsearch-core/src/lib/ai-lite/transport.ts b/packages/instantsearch-core/src/lib/ai-lite/transport.ts new file mode 100644 index 00000000000..3d8b70dee17 --- /dev/null +++ b/packages/instantsearch-core/src/lib/ai-lite/transport.ts @@ -0,0 +1,251 @@ +/** + * HTTP transport implementation for chat. + */ +import { parseJsonEventStream } from './stream-parser'; +import { resolveValue } from './utils'; + +import type { + ChatTransport, + HttpChatTransportInitOptions, + InferUIMessageChunk, + UIMessage, + FetchFunction, + PrepareSendMessagesRequest, + PrepareReconnectToStreamRequest, + Resolvable, +} from './types'; + +/** + * Abstract base class for HTTP-based chat transports. + */ +export abstract class HttpChatTransport + implements ChatTransport +{ + protected api: string; + protected credentials: Resolvable | undefined; + protected headers: Resolvable | Headers> | undefined; + protected body: Resolvable | undefined; + protected fetch?: FetchFunction; + protected prepareSendMessagesRequest?: PrepareSendMessagesRequest; + protected prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest; + + constructor({ + api = '/api/chat', + credentials, + headers, + body, + fetch: customFetch, + prepareSendMessagesRequest, + prepareReconnectToStreamRequest, + }: HttpChatTransportInitOptions) { + this.api = api; + this.credentials = credentials; + this.headers = headers; + this.body = body; + this.fetch = customFetch; + this.prepareSendMessagesRequest = prepareSendMessagesRequest; + this.prepareReconnectToStreamRequest = prepareReconnectToStreamRequest; + } + + sendMessages({ + abortSignal, + chatId, + messages, + requestMetadata, + trigger, + messageId, + headers: requestHeaders, + body: requestBody, + }: Parameters['sendMessages']>[0]): Promise< + ReadableStream> + > { + const fetchFn = this.fetch ?? fetch; + + // Resolve configurable values + return Promise.all([ + resolveValue(this.credentials), + resolveValue(this.headers), + resolveValue(this.body), + ]).then(([resolvedCredentials, resolvedHeaders, resolvedBody]) => { + // Build default request options + let api = this.api; + let body: object = { + id: chatId, + messages, + ...resolvedBody, + ...requestBody, + }; + let headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(resolvedHeaders instanceof Headers + ? Object.fromEntries(resolvedHeaders.entries()) + : resolvedHeaders), + ...(requestHeaders instanceof Headers + ? Object.fromEntries(requestHeaders.entries()) + : requestHeaders), + }; + let credentials: RequestCredentials | undefined = resolvedCredentials; + + // Apply custom preparation if provided + const prepareRequestBody: Record = { + ...resolvedBody, + ...requestBody, + }; + const preparePromise = this.prepareSendMessagesRequest + ? Promise.resolve( + this.prepareSendMessagesRequest({ + id: chatId, + messages, + requestMetadata, + body: prepareRequestBody, + credentials: resolvedCredentials, + headers: resolvedHeaders, + api: this.api, + trigger, + messageId, + }) + ) + : Promise.resolve(null); + + return preparePromise.then((prepared) => { + if (prepared) { + body = prepared.body; + if (prepared.api) api = prepared.api; + if (prepared.headers) { + headers = { + 'Content-Type': 'application/json', + ...(prepared.headers instanceof Headers + ? Object.fromEntries(prepared.headers.entries()) + : prepared.headers), + }; + } + if (prepared.credentials) credentials = prepared.credentials; + } + + return fetchFn(api, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: abortSignal, + credentials, + }).then((response) => { + if (!response.ok) { + throw new Error( + `HTTP error: ${response.status} ${response.statusText}` + ); + } + + if (!response.body) { + throw new Error('Response body is empty'); + } + + return this.processResponseStream(response.body); + }); + }); + }); + } + + reconnectToStream({ + chatId, + headers: requestHeaders, + body: requestBody, + }: Parameters< + ChatTransport['reconnectToStream'] + >[0]): Promise> | null> { + const fetchFn = this.fetch ?? fetch; + + // Resolve configurable values + return Promise.all([ + resolveValue(this.credentials), + resolveValue(this.headers), + resolveValue(this.body), + ]).then(([resolvedCredentials, resolvedHeaders, resolvedBody]) => { + // Build default request options + let api = this.api; + let headers: HeadersInit = { + ...(resolvedHeaders instanceof Headers + ? Object.fromEntries(resolvedHeaders.entries()) + : resolvedHeaders), + ...(requestHeaders instanceof Headers + ? Object.fromEntries(requestHeaders.entries()) + : requestHeaders), + }; + let credentials: RequestCredentials | undefined = resolvedCredentials; + + // Apply custom preparation if provided + const prepareRequestBody: Record = { + ...resolvedBody, + ...requestBody, + }; + const preparePromise = this.prepareReconnectToStreamRequest + ? Promise.resolve( + this.prepareReconnectToStreamRequest({ + id: chatId, + requestMetadata: undefined, + body: prepareRequestBody, + credentials: resolvedCredentials, + headers: resolvedHeaders, + api: this.api, + }) + ) + : Promise.resolve(null); + + return preparePromise.then((prepared) => { + if (prepared) { + if (prepared.api) api = prepared.api; + if (prepared.headers) { + headers = + prepared.headers instanceof Headers + ? Object.fromEntries(prepared.headers.entries()) + : prepared.headers; + } + if (prepared.credentials) credentials = prepared.credentials; + } + + // GET request for reconnection + return fetchFn(`${api}?chatId=${chatId}`, { + method: 'GET', + headers, + credentials, + }).then((response) => { + if (!response.ok) { + // 404 means no stream to reconnect to, which is not an error + if (response.status === 404) { + return null; + } + throw new Error( + `HTTP error: ${response.status} ${response.statusText}` + ); + } + + if (!response.body) { + return null; + } + + return this.processResponseStream(response.body); + }); + }); + }); + } + + protected abstract processResponseStream( + stream: ReadableStream + ): ReadableStream>; +} + +/** + * Default chat transport implementation using NDJSON streaming. + */ +export class DefaultChatTransport< + TUIMessage extends UIMessage +> extends HttpChatTransport { + constructor(options: HttpChatTransportInitOptions = {}) { + super(options); + } + + protected processResponseStream( + stream: ReadableStream + ): ReadableStream> { + return parseJsonEventStream>(stream); + } +} diff --git a/packages/instantsearch-core/src/lib/ai-lite/types.ts b/packages/instantsearch-core/src/lib/ai-lite/types.ts new file mode 100644 index 00000000000..335153bdd25 --- /dev/null +++ b/packages/instantsearch-core/src/lib/ai-lite/types.ts @@ -0,0 +1,512 @@ +/* eslint-disable instantsearch/naming-convention */ +/** + * Chat status: + * - `submitted`: The message has been sent to the API and we're awaiting the start of the response stream. + * - `streaming`: The response is actively streaming in from the API, receiving chunks of data. + * - `ready`: The full response has been received and processed; a new user message can be submitted. + * - `error`: An error occurred during the API request, preventing successful completion. + */ +export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error'; + +export type UIDataTypes = Record; + +export type UITool = { + input: unknown; + output: unknown | undefined; +}; + +export type UITools = Record; + +export type ProviderMetadata = Record>; + +type ValueOf = T[keyof T]; + +type DeepPartial = T extends object + ? { [P in keyof T]?: DeepPartial } + : T; + +export type TextUIPart = { + type: 'text'; + text: string; + state?: 'streaming' | 'done'; + providerMetadata?: ProviderMetadata; +}; + +export type ReasoningUIPart = { + type: 'reasoning'; + text: string; + state?: 'streaming' | 'done'; + providerMetadata?: ProviderMetadata; +}; + +export type SourceUrlUIPart = { + type: 'source-url'; + sourceId: string; + url: string; + title?: string; + providerMetadata?: ProviderMetadata; +}; + +export type SourceDocumentUIPart = { + type: 'source-document'; + sourceId: string; + mediaType: string; + title: string; + filename?: string; + providerMetadata?: ProviderMetadata; +}; + +export type FileUIPart = { + type: 'file'; + mediaType: string; + filename?: string; + url: string; + providerMetadata?: ProviderMetadata; +}; + +export type StepStartUIPart = { + type: 'step-start'; +}; + +export type DataUIPart = ValueOf<{ + [NAME in keyof DATA_TYPES & string]: { + type: `data-${NAME}`; + id?: string; + data: DATA_TYPES[NAME]; + }; +}>; + +export type ToolUIPart = ValueOf<{ + [NAME in keyof TOOLS & string]: { + type: `tool-${NAME}`; + toolCallId: string; + } & ( + | { + state: 'input-streaming'; + input: DeepPartial | undefined; + rawInput?: string; + providerExecuted?: boolean; + output?: never; + errorText?: never; + } + | { + state: 'input-available'; + input: TOOLS[NAME]['input']; + providerExecuted?: boolean; + output?: never; + errorText?: never; + callProviderMetadata?: ProviderMetadata; + } + | { + state: 'output-available'; + input: TOOLS[NAME]['input']; + output: TOOLS[NAME]['output']; + errorText?: never; + providerExecuted?: boolean; + callProviderMetadata?: ProviderMetadata; + preliminary?: boolean; + } + | { + state: 'output-error'; + input: TOOLS[NAME]['input'] | undefined; + rawInput?: unknown; + output?: never; + errorText: string; + providerExecuted?: boolean; + callProviderMetadata?: ProviderMetadata; + } + ); +}>; + +export type DynamicToolUIPart = { + type: 'dynamic-tool'; + toolName: string; + toolCallId: string; +} & ( + | { + state: 'input-streaming'; + input: unknown | undefined; + rawInput?: string; + output?: never; + errorText?: never; + } + | { + state: 'input-available'; + input: unknown; + output?: never; + errorText?: never; + callProviderMetadata?: ProviderMetadata; + } + | { + state: 'output-available'; + input: unknown; + output: unknown; + errorText?: never; + callProviderMetadata?: ProviderMetadata; + preliminary?: boolean; + } + | { + state: 'output-error'; + input: unknown; + output?: never; + errorText: string; + callProviderMetadata?: ProviderMetadata; + } +); + +export type UIMessagePart< + DATA_TYPES extends UIDataTypes = UIDataTypes, + TOOLS extends UITools = UITools +> = + | TextUIPart + | ReasoningUIPart + | ToolUIPart + | DynamicToolUIPart + | SourceUrlUIPart + | SourceDocumentUIPart + | FileUIPart + | DataUIPart + | StepStartUIPart; + +export interface UIMessage< + METADATA = unknown, + DATA_PARTS extends UIDataTypes = UIDataTypes, + TOOLS extends UITools = UITools +> { + id: string; + role: 'system' | 'user' | 'assistant'; + metadata?: METADATA; + parts: Array>; +} + +export type InferUIMessageMetadata = T extends UIMessage< + infer METADATA +> + ? METADATA + : unknown; + +export type InferUIMessageData = T extends UIMessage< + unknown, + infer DATA_TYPES +> + ? DATA_TYPES + : UIDataTypes; + +export type InferUIMessageTools = T extends UIMessage< + unknown, + any, + infer TOOLS +> + ? TOOLS + : UITools; + +export type InferUIMessageToolCall = + | ValueOf<{ + [NAME in keyof InferUIMessageTools]: { + toolName: NAME & string; + toolCallId: string; + input: InferUIMessageTools[NAME] extends { + input: infer INPUT; + } + ? INPUT + : never; + dynamic?: false; + }; + }> + | { + toolName: string; + toolCallId: string; + input: unknown; + dynamic: true; + }; + +type DataUIMessageChunk = ValueOf<{ + [NAME in keyof DATA_TYPES & string]: { + type: `data-${NAME}`; + id?: string; + data: DATA_TYPES[NAME]; + transient?: boolean; + }; +}>; + +type ToolUIMessageChunk = + | ValueOf<{ + [NAME in keyof TOOLS & string]: { + type: 'tool-input-available'; + toolName: NAME; + toolCallId: string; + input: TOOLS[NAME]['input']; + callProviderMetadata?: ProviderMetadata; + providerExecuted?: boolean; + }; + }> + | ValueOf<{ + [NAME in keyof TOOLS & string]: { + type: 'tool-input-start'; + toolName: NAME; + toolCallId: string; + input?: DeepPartial; + providerExecuted?: boolean; + }; + }> + | ValueOf<{ + [NAME in keyof TOOLS & string]: { + type: 'tool-input-delta'; + toolName: NAME; + toolCallId: string; + inputTextDelta: string; + }; + }> + | ValueOf<{ + [NAME in keyof TOOLS & string]: { + type: 'tool-output-available'; + toolName: NAME; + toolCallId: string; + output: TOOLS[NAME]['output']; + callProviderMetadata?: ProviderMetadata; + preliminary?: boolean; + }; + }> + | ValueOf<{ + [NAME in keyof TOOLS & string]: { + type: 'tool-error'; + toolName: NAME; + toolCallId: string; + errorText: string; + input?: TOOLS[NAME]['input']; + callProviderMetadata?: ProviderMetadata; + }; + }> + | { + type: 'tool-input-available'; + toolName: string; + toolCallId: string; + input: unknown; + callProviderMetadata?: ProviderMetadata; + providerExecuted?: boolean; + dynamic: true; + } + | { + type: 'tool-input-start'; + toolName: string; + toolCallId: string; + input?: unknown; + providerExecuted?: boolean; + dynamic: true; + } + | { + type: 'tool-input-delta'; + toolName: string; + toolCallId: string; + inputTextDelta: string; + dynamic: true; + } + | { + type: 'tool-output-available'; + toolName: string; + toolCallId: string; + output: unknown; + callProviderMetadata?: ProviderMetadata; + preliminary?: boolean; + dynamic: true; + } + | { + type: 'tool-error'; + toolName: string; + toolCallId: string; + errorText: string; + input?: unknown; + callProviderMetadata?: ProviderMetadata; + dynamic: true; + }; + +export type UIMessageChunk< + METADATA = unknown, + DATA_TYPES extends UIDataTypes = UIDataTypes, + TOOLS extends UITools = UITools +> = + | { type: 'text-start'; id: string; providerMetadata?: ProviderMetadata } + | { + type: 'text-delta'; + delta: string; + id: string; + providerMetadata?: ProviderMetadata; + } + | { type: 'text-end'; id: string; providerMetadata?: ProviderMetadata } + | { type: 'reasoning-start'; id: string; providerMetadata?: ProviderMetadata } + | { + type: 'reasoning-delta'; + id: string; + delta: string; + providerMetadata?: ProviderMetadata; + } + | { type: 'reasoning-end'; id: string; providerMetadata?: ProviderMetadata } + | { type: 'error'; errorText: string } + | ToolUIMessageChunk + | { + type: 'data-tool-output-delta'; + data: { + toolCallId: string; + toolName: string; + delta: string; + }; + transient?: boolean; + } + | { type: 'source-url'; sourceId: string; url: string; title?: string } + | { + type: 'source-document'; + sourceId: string; + mediaType: string; + title: string; + filename?: string; + providerMetadata?: ProviderMetadata; + } + | { type: 'file'; url: string; mediaType: string } + | DataUIMessageChunk + | { type: 'start-step' } + | { type: 'finish-step' } + | { type: 'start'; messageId?: string; messageMetadata?: METADATA } + | { type: 'finish'; messageMetadata?: METADATA } + | { type: 'abort' } + | { type: 'message-metadata'; messageMetadata: METADATA }; + +export type InferUIMessageChunk = UIMessageChunk< + InferUIMessageMetadata, + InferUIMessageData, + InferUIMessageTools +>; + +export interface ChatState { + status: ChatStatus; + error: Error | undefined; + messages: UI_MESSAGE[]; + pushMessage: (message: UI_MESSAGE) => void; + popMessage: () => void; + replaceMessage: (index: number, message: UI_MESSAGE) => void; + snapshot: (thing: T) => T; +} + +export type ChatRequestOptions = { + headers?: Record | Headers; + body?: object; + metadata?: unknown; +}; + +export interface ChatTransport { + sendMessages: ( + options: { + chatId: string; + messages: UI_MESSAGE[]; + abortSignal: AbortSignal; + requestMetadata?: unknown; + trigger: 'submit-message' | 'regenerate-message'; + messageId?: string; + } & ChatRequestOptions + ) => Promise>>; + + reconnectToStream: ( + options: { + chatId: string; + } & ChatRequestOptions + ) => Promise> | null>; +} + +export type PrepareSendMessagesRequest = ( + options: { + id: string; + messages: UI_MESSAGE[]; + requestMetadata: unknown; + body: Record | undefined; + credentials: RequestCredentials | undefined; + headers: HeadersInit | undefined; + api: string; + } & { + trigger: 'submit-message' | 'regenerate-message'; + messageId: string | undefined; + } +) => + | { + body: object; + headers?: HeadersInit; + credentials?: RequestCredentials; + api?: string; + } + | PromiseLike<{ + body: object; + headers?: HeadersInit; + credentials?: RequestCredentials; + api?: string; + }>; + +export type PrepareReconnectToStreamRequest = (options: { + id: string; + requestMetadata: unknown; + body: Record | undefined; + credentials: RequestCredentials | undefined; + headers: HeadersInit | undefined; + api: string; +}) => + | { headers?: HeadersInit; credentials?: RequestCredentials; api?: string } + | PromiseLike<{ + headers?: HeadersInit; + credentials?: RequestCredentials; + api?: string; + }>; + +export type Resolvable = T | (() => T) | (() => Promise); + +export type FetchFunction = typeof fetch; + +export type HttpChatTransportInitOptions = { + api?: string; + credentials?: Resolvable; + headers?: Resolvable | Headers>; + body?: Resolvable; + fetch?: FetchFunction; + prepareSendMessagesRequest?: PrepareSendMessagesRequest; + prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest; +}; + +export type IdGenerator = () => string; + +export type ChatOnErrorCallback = (error: Error) => void; + +export type ChatOnToolCallCallback = + (options: { + toolCall: InferUIMessageToolCall; + }) => void | PromiseLike; + +export type ChatOnFinishCallback = (options: { + message: UI_MESSAGE; + messages: UI_MESSAGE[]; + isAbort: boolean; + isDisconnect: boolean; + isError: boolean; +}) => void; + +export type ChatOnDataCallback = ( + dataPart: DataUIPart> +) => void; + +export interface ChatInit { + id?: string; + messages?: UI_MESSAGE[]; + generateId?: IdGenerator; + transport?: ChatTransport; + onError?: ChatOnErrorCallback; + onToolCall?: ChatOnToolCallCallback; + onFinish?: ChatOnFinishCallback; + onData?: ChatOnDataCallback; + sendAutomaticallyWhen?: (options: { + messages: UI_MESSAGE[]; + }) => boolean | PromiseLike; + shouldRepairToolInput?: (toolName: string) => boolean; +} + +export type CreateUIMessage = Omit< + UI_MESSAGE, + 'id' | 'role' +> & { + id?: UI_MESSAGE['id']; + role?: UI_MESSAGE['role']; +}; diff --git a/packages/instantsearch-core/src/lib/ai-lite/utils.ts b/packages/instantsearch-core/src/lib/ai-lite/utils.ts new file mode 100644 index 00000000000..40fbd4931c8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/ai-lite/utils.ts @@ -0,0 +1,94 @@ +import type { + UIMessage, + ToolUIPart, + DynamicToolUIPart, + UITools, +} from './types'; + +export function generateId(): string { + return Math.random().toString(36).substring(2, 9); +} + +function isToolOrDynamicToolUIPart( + part: unknown +): part is ToolUIPart | DynamicToolUIPart { + if (typeof part !== 'object' || part === null) return false; + const p = part as { type?: string }; + return ( + typeof p.type === 'string' && + (p.type.startsWith('tool-') || p.type === 'dynamic-tool') + ); +} + +export function lastAssistantMessageIsCompleteWithToolCalls({ + messages, +}: { + messages: UIMessage[]; +}): boolean { + if (messages.length === 0) return false; + + const lastMessage = messages[messages.length - 1]; + + if (lastMessage.role !== 'assistant') return false; + + if (!lastMessage.parts || lastMessage.parts.length === 0) return false; + + const toolParts = lastMessage.parts.filter(isToolOrDynamicToolUIPart); + + if (toolParts.length === 0) return false; + + return toolParts.every( + (part) => part.state === 'output-available' || part.state === 'output-error' + ); +} + +export class SerialJobExecutor { + private queue: Array<() => Promise> = []; + private isRunning = false; + + run(job: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.queue.push(() => { + return job().then( + (result) => { + resolve(result); + }, + (error) => { + reject(error); + } + ); + }); + + this.processQueue(); + }); + } + + private processQueue(): void { + if (this.isRunning) return; + this.isRunning = true; + + const processNext = (): void => { + if (this.queue.length === 0) { + this.isRunning = false; + return; + } + + const job = this.queue.shift(); + if (job) { + job().then(processNext, processNext); + } + }; + + processNext(); + } +} + +export function resolveValue( + value: T | (() => T) | (() => Promise) | undefined +): Promise { + if (value === undefined) return Promise.resolve(undefined); + if (typeof value === 'function') { + return Promise.resolve((value as () => T | Promise)()); + } + return Promise.resolve(value); +} diff --git a/packages/instantsearch-core/src/lib/chat/chat.ts b/packages/instantsearch-core/src/lib/chat/chat.ts new file mode 100644 index 00000000000..a629a8886ab --- /dev/null +++ b/packages/instantsearch-core/src/lib/chat/chat.ts @@ -0,0 +1,166 @@ +import { AbstractChat } from '../ai-lite'; + +import type { + UIMessage, + ChatState as BaseChatState, + ChatStatus, + ChatInit, +} from '../ai-lite'; + +export type { UIMessage }; +export { AbstractChat }; +export { ChatInit }; + +export const CACHE_KEY = 'instantsearch-chat-initial-messages'; + +function getDefaultInitialMessages( + id?: string +): TUIMessage[] { + const initialMessages = sessionStorage.getItem( + CACHE_KEY + (id ? `-${id}` : '') + ); + return initialMessages ? JSON.parse(initialMessages) : []; +} + +export class ChatState + implements BaseChatState +{ + _messages: TUiMessage[]; + _status: ChatStatus = 'ready'; + _error: Error | undefined = undefined; + + _messagesCallbacks = new Set<() => void>(); + _statusCallbacks = new Set<() => void>(); + _errorCallbacks = new Set<() => void>(); + + constructor( + id: string | undefined = undefined, + initialMessages: TUiMessage[] = getDefaultInitialMessages(id) + ) { + this._messages = initialMessages; + const saveMessagesInLocalStorage = () => { + if (this.status === 'ready') { + try { + sessionStorage.setItem( + CACHE_KEY + (id ? `-${id}` : ''), + JSON.stringify(this.messages) + ); + } catch (e) { + // Do nothing if sessionStorage is not available or full + } + } + }; + this['~registerMessagesCallback'](saveMessagesInLocalStorage); + this['~registerStatusCallback'](saveMessagesInLocalStorage); + } + + get status(): ChatStatus { + return this._status; + } + + set status(newStatus: ChatStatus) { + this._status = newStatus; + this._callStatusCallbacks(); + } + + get error(): Error | undefined { + return this._error; + } + + set error(newError: Error | undefined) { + this._error = newError; + this._callErrorCallbacks(); + } + + get messages(): TUiMessage[] { + return this._messages; + } + + set messages(newMessages: TUiMessage[]) { + this._messages = [...newMessages]; + this._callMessagesCallbacks(); + } + + pushMessage = (message: TUiMessage) => { + this._messages = this._messages.concat(message); + this._callMessagesCallbacks(); + }; + + popMessage = () => { + this._messages = this._messages.slice(0, -1); + this._callMessagesCallbacks(); + }; + + replaceMessage = (index: number, message: TUiMessage) => { + this._messages = [ + ...this._messages.slice(0, index), + // We deep clone the message here to ensure the new React Compiler (currently in RC) detects deeply nested parts/metadata changes: + this.snapshot(message), + ...this._messages.slice(index + 1), + ]; + this._callMessagesCallbacks(); + }; + + snapshot = (thing: T): T => { + return JSON.parse(JSON.stringify(thing)) as T; + }; + + '~registerMessagesCallback' = (onChange: () => void): (() => void) => { + const callback = onChange; + this._messagesCallbacks.add(callback); + return () => { + this._messagesCallbacks.delete(callback); + }; + }; + + '~registerStatusCallback' = (onChange: () => void): (() => void) => { + this._statusCallbacks.add(onChange); + return () => { + this._statusCallbacks.delete(onChange); + }; + }; + + '~registerErrorCallback' = (onChange: () => void): (() => void) => { + this._errorCallbacks.add(onChange); + return () => { + this._errorCallbacks.delete(onChange); + }; + }; + + _callMessagesCallbacks = () => { + this._messagesCallbacks.forEach((callback) => callback()); + }; + + _callStatusCallbacks = () => { + this._statusCallbacks.forEach((callback) => callback()); + }; + + _callErrorCallbacks = () => { + this._errorCallbacks.forEach((callback) => callback()); + }; +} + +export class Chat< + TUiMessage extends UIMessage +> extends AbstractChat { + _state: ChatState; + + constructor({ + messages, + agentId, + ...init + }: ChatInit & { agentId?: string }) { + const state = new ChatState(agentId, messages); + super({ ...init, state }); + this._state = state; + } + + '~registerMessagesCallback' = (onChange: () => void): (() => void) => + this._state['~registerMessagesCallback'](onChange); + + '~registerStatusCallback' = (onChange: () => void): (() => void) => + this._state['~registerStatusCallback'](onChange); + + '~registerErrorCallback' = (onChange: () => void): (() => void) => + this._state['~registerErrorCallback'](onChange); +} diff --git a/packages/instantsearch-core/src/lib/chat/index.ts b/packages/instantsearch-core/src/lib/chat/index.ts new file mode 100644 index 00000000000..ab22c6fccbd --- /dev/null +++ b/packages/instantsearch-core/src/lib/chat/index.ts @@ -0,0 +1,13 @@ +export type { UIMessage } from './chat'; +export type { ChatInit } from './chat'; +export { AbstractChat } from './chat'; +export { ChatState } from './chat'; +export { Chat } from './chat'; +export { CACHE_KEY } from './chat'; + +export const SearchIndexToolType = 'algolia_search_index'; +export const RecommendToolType = 'algolia_recommend'; +export const MemorizeToolType = 'algolia_memorize'; +export const MemorySearchToolType = 'algolia_memory_search'; +export const PonderToolType = 'algolia_ponder'; +export const DisplayResultsToolType = 'algolia_display_results'; diff --git a/packages/instantsearch-core/src/lib/infiniteHitsCache/index.ts b/packages/instantsearch-core/src/lib/infiniteHitsCache/index.ts new file mode 100644 index 00000000000..6fd47d4f820 --- /dev/null +++ b/packages/instantsearch-core/src/lib/infiniteHitsCache/index.ts @@ -0,0 +1 @@ +export { default as createInfiniteHitsSessionStorageCache } from './sessionStorage'; diff --git a/packages/instantsearch-core/src/lib/infiniteHitsCache/sessionStorage.ts b/packages/instantsearch-core/src/lib/infiniteHitsCache/sessionStorage.ts new file mode 100644 index 00000000000..af4d74d6fbd --- /dev/null +++ b/packages/instantsearch-core/src/lib/infiniteHitsCache/sessionStorage.ts @@ -0,0 +1,74 @@ +import { isEqual, safelyRunOnBrowser } from '../utils'; + +import type { InfiniteHitsCache } from '../../connectors/infinite-hits/connectInfiniteHits'; +import type { PlainSearchParameters } from 'algoliasearch-helper'; + +function getStateWithoutPage(state: PlainSearchParameters) { + const { page, ...rest } = state || {}; + return rest; +} + +export default function createInfiniteHitsSessionStorageCache({ + key, +}: { + /** + * If you display multiple instances of infiniteHits on the same page, + * you must provide a unique key for each instance. + */ + key?: string; +} = {}): InfiniteHitsCache { + const KEY = ['ais.infiniteHits', key].filter(Boolean).join(':'); + + return { + read({ state }) { + const sessionStorage = safelyRunOnBrowser( + ({ window }) => window.sessionStorage + ); + + if (!sessionStorage) { + return null; + } + + try { + const cache = JSON.parse( + // @ts-expect-error JSON.parse() requires a string, but it actually accepts null, too. + sessionStorage.getItem(KEY) + ); + + return cache && isEqual(cache.state, getStateWithoutPage(state)) + ? cache.hits + : null; + } catch (error) { + if (error instanceof SyntaxError) { + try { + sessionStorage.removeItem(KEY); + } catch (err) { + // do nothing + } + } + return null; + } + }, + write({ state, hits }) { + const sessionStorage = safelyRunOnBrowser( + ({ window }) => window.sessionStorage + ); + + if (!sessionStorage) { + return; + } + + try { + sessionStorage.setItem( + KEY, + JSON.stringify({ + state: getStateWithoutPage(state), + hits, + }) + ); + } catch (error) { + // do nothing + } + }, + }; +} diff --git a/packages/instantsearch-core/src/lib/insights/client.ts b/packages/instantsearch-core/src/lib/insights/client.ts new file mode 100644 index 00000000000..99bd1f73e1d --- /dev/null +++ b/packages/instantsearch-core/src/lib/insights/client.ts @@ -0,0 +1,135 @@ +import { + uniq, + find, + createDocumentationMessageGenerator, + warning, +} from '../utils'; + +import type { + Hit, + InsightsClient, + InsightsClientMethod, + InsightsClientPayload, + Connector, +} from '../../types'; +import type { SearchResults } from 'algoliasearch-helper'; + +const getSelectedHits = (hits: Hit[], selectedObjectIDs: string[]): Hit[] => { + return selectedObjectIDs.map((objectID) => { + const hit = find(hits, (h) => h.objectID === objectID); + if (typeof hit === 'undefined') { + throw new Error( + `Could not find objectID "${objectID}" passed to \`clickedObjectIDsAfterSearch\` in the returned hits. This is necessary to infer the absolute position and the query ID.` + ); + } + return hit; + }); +}; + +const getQueryID = (selectedHits: Hit[]): string => { + const queryIDs = uniq(selectedHits.map((hit) => hit.__queryID)); + if (queryIDs.length > 1) { + throw new Error( + 'Insights currently allows a single `queryID`. The `objectIDs` provided map to multiple `queryID`s.' + ); + } + const queryID = queryIDs[0]; + if (typeof queryID !== 'string') { + throw new Error( + `Could not infer \`queryID\`. Ensure InstantSearch \`clickAnalytics: true\` was added with the Configure widget. + +See: https://alg.li/lNiZZ7` + ); + } + return queryID; +}; + +const getPositions = (selectedHits: Hit[]): number[] => + selectedHits.map((hit) => hit.__position); + +export const inferPayload = ({ + method, + results, + hits, + objectIDs, +}: { + method: InsightsClientMethod; + results: SearchResults; + hits: Hit[]; + objectIDs: string[]; +}): Omit => { + const { index } = results; + const selectedHits = getSelectedHits(hits, objectIDs); + const queryID = getQueryID(selectedHits); + + switch (method) { + case 'clickedObjectIDsAfterSearch': { + const positions = getPositions(selectedHits); + return { index, queryID, objectIDs, positions }; + } + + case 'convertedObjectIDsAfterSearch': + return { index, queryID, objectIDs }; + + default: + throw new Error(`Unsupported method passed to insights: "${method}".`); + } +}; + +const wrapInsightsClient = + ( + aa: InsightsClient | null, + results: SearchResults, + hits: Hit[] + ): InsightsClient => + (method, ...payloads) => { + const [payload] = payloads; + warning( + false, + `\`insights\` function has been deprecated. It is still supported in 4.x releases, but not further. It is replaced by the \`insights\` middleware. + +For more information, visit https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/how-to/send-click-and-conversion-events-with-instantsearch/js/` + ); + if (!aa) { + const withInstantSearchUsage = createDocumentationMessageGenerator({ + name: 'instantsearch', + }); + throw new Error( + withInstantSearchUsage( + 'The `insightsClient` option has not been provided to `instantsearch`.' + ) + ); + } + if (!Array.isArray(payload.objectIDs)) { + throw new TypeError('Expected `objectIDs` to be an array.'); + } + const inferredPayload = inferPayload({ + method, + results, + hits, + objectIDs: payload.objectIDs, + }); + aa(method, { ...inferredPayload, ...payload }); + }; + +/** + * @deprecated This function will be still supported in 4.x releases, but not further. It is replaced by the `insights` middleware. For more information, visit https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/how-to/send-click-and-conversion-events-with-instantsearch/js/ + * It passes `insights` to `HitsWithInsightsListener` and `InfiniteHitsWithInsightsListener`. + */ +export default function withInsights>( + connector: TConnector +): TConnector { + return ((renderFn, unmountFn) => + connector((renderOptions, isFirstRender) => { + const { results, hits, instantSearchInstance } = renderOptions; + if (results && hits && instantSearchInstance) { + const insights = wrapInsightsClient( + instantSearchInstance.insightsClient, + results, + hits + ); + return renderFn({ ...renderOptions, insights }, isFirstRender); + } + return renderFn(renderOptions, isFirstRender); + }, unmountFn)) as TConnector; +} diff --git a/packages/instantsearch-core/src/lib/insights/index.ts b/packages/instantsearch-core/src/lib/insights/index.ts new file mode 100644 index 00000000000..880fa63eb8c --- /dev/null +++ b/packages/instantsearch-core/src/lib/insights/index.ts @@ -0,0 +1 @@ +export { default as withInsights, inferPayload as inferInsightsPayload } from './client'; diff --git a/packages/instantsearch-core/src/lib/public/addWidgetId.ts b/packages/instantsearch-core/src/lib/public/addWidgetId.ts new file mode 100644 index 00000000000..b19ec1a8753 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/addWidgetId.ts @@ -0,0 +1,15 @@ +import type { Widget } from '../../types'; + +let id = 0; + +export function addWidgetId(widget: Widget) { + if (widget.dependsOn !== 'recommend') { + return; + } + + widget.$$id = id++; +} + +export function resetWidgetId() { + id = 0; +} diff --git a/packages/instantsearch-core/src/lib/public/capitalize.ts b/packages/instantsearch-core/src/lib/public/capitalize.ts new file mode 100644 index 00000000000..f1e451b6449 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/capitalize.ts @@ -0,0 +1,3 @@ +export function capitalize(text: string): string { + return text.toString().charAt(0).toUpperCase() + text.toString().slice(1); +} diff --git a/packages/instantsearch-core/src/lib/public/checkIndexUiState.ts b/packages/instantsearch-core/src/lib/public/checkIndexUiState.ts new file mode 100644 index 00000000000..f8874d89021 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/checkIndexUiState.ts @@ -0,0 +1,198 @@ +import { capitalize } from './capitalize'; +import { warning } from './logger'; +import { keys } from './typedObject'; + +import type { Widget, IndexUiState, IndexWidget } from '../../types'; + +// Some connectors are responsible for multiple widgets so we need +// to map them. +function getWidgetNames(connectorName: string): string[] { + switch (connectorName) { + case 'range': + return []; + + case 'menu': + return ['menu', 'menuSelect']; + + default: + return [connectorName]; + } +} + +type WidgetType = Required['$$type']; + +type StateDescription = { + connectors: string[]; + widgets: WidgetType[]; +}; + +type StateToWidgets = { + [TParameter in keyof IndexUiState]: StateDescription; +}; + +type WidgetDescription = { + connectors: string[]; + // no longer widget type, "ais." is stripped + widgets: string[]; +}; + +type MissingWidgets = Array<[string, WidgetDescription]>; + +const stateToWidgetsMap: StateToWidgets = { + query: { + connectors: ['connectSearchBox'], + widgets: ['ais.searchBox', 'ais.autocomplete', 'ais.voiceSearch'], + }, + refinementList: { + connectors: ['connectRefinementList'], + widgets: ['ais.refinementList'], + }, + menu: { + connectors: ['connectMenu'], + widgets: ['ais.menu'], + }, + hierarchicalMenu: { + connectors: ['connectHierarchicalMenu'], + widgets: ['ais.hierarchicalMenu'], + }, + numericMenu: { + connectors: ['connectNumericMenu'], + widgets: ['ais.numericMenu'], + }, + ratingMenu: { + connectors: ['connectRatingMenu'], + widgets: ['ais.ratingMenu'], + }, + range: { + connectors: ['connectRange'], + widgets: ['ais.rangeInput', 'ais.rangeSlider', 'ais.range'], + }, + toggle: { + connectors: ['connectToggleRefinement'], + widgets: ['ais.toggleRefinement'], + }, + geoSearch: { + connectors: ['connectGeoSearch'], + widgets: ['ais.geoSearch'], + }, + sortBy: { + connectors: ['connectSortBy'], + widgets: ['ais.sortBy'], + }, + page: { + connectors: ['connectPagination'], + widgets: ['ais.pagination', 'ais.infiniteHits'], + }, + hitsPerPage: { + connectors: ['connectHitsPerPage'], + widgets: ['ais.hitsPerPage'], + }, + configure: { + connectors: ['connectConfigure'], + widgets: ['ais.configure'], + }, + places: { + connectors: [], + widgets: ['ais.places'], + }, +}; + +type CheckIndexUiStateParams = { + index: IndexWidget; + indexUiState: IndexUiState; +}; + +export function checkIndexUiState({ + index, + indexUiState, +}: CheckIndexUiStateParams) { + const mountedWidgets = index + .getWidgets() + .map((widget) => widget.$$type) + .filter(Boolean); + + const missingWidgets = keys(indexUiState).reduce( + (acc, parameter) => { + const widgetUiState = stateToWidgetsMap[parameter]; + + if (!widgetUiState) { + return acc; + } + + const requiredWidgets = widgetUiState.widgets; + + if ( + requiredWidgets && + !requiredWidgets.some((requiredWidget) => + mountedWidgets.includes(requiredWidget) + ) + ) { + acc.push([ + parameter, + { + connectors: widgetUiState.connectors, + widgets: widgetUiState.widgets.map( + (widgetIdentifier) => widgetIdentifier.split('ais.')[1] + ), + }, + ]); + } + + return acc; + }, + [] + ); + + warning( + missingWidgets.length === 0, + `The UI state for the index "${index.getIndexId()}" is not consistent with the widgets mounted. + +This can happen when the UI state is specified via \`initialUiState\`, \`routing\` or \`setUiState\` but that the widgets responsible for this state were not added. This results in those query parameters not being sent to the API. + +To fully reflect the state, some widgets need to be added to the index "${index.getIndexId()}": + +${missingWidgets + .map(([stateParameter, { widgets }]) => { + return `- \`${stateParameter}\` needs one of these widgets: ${( + [] as string[] + ) + .concat(...widgets.map((name) => getWidgetNames(name))) + .map((name: string) => `"${name}"`) + .join(', ')}`; + }) + .join('\n')} + +If you do not wish to display widgets but still want to support their search parameters, you can mount "virtual widgets" that don't render anything: + +\`\`\` +${missingWidgets + .filter(([_stateParameter, { connectors }]) => { + return connectors.length > 0; + }) + .map(([_stateParameter, { connectors, widgets }]) => { + const capitalizedWidget = capitalize(widgets[0]); + const connectorName = connectors[0]; + + return `const virtual${capitalizedWidget} = ${connectorName}(() => null);`; + }) + .join('\n')} + +search.addWidgets([ + ${missingWidgets + .filter(([_stateParameter, { connectors }]) => { + return connectors.length > 0; + }) + .map(([_stateParameter, { widgets }]) => { + const capitalizedWidget = capitalize(widgets[0]); + + return `virtual${capitalizedWidget}({ /* ... */ })`; + }) + .join(',\n ')} +]); +\`\`\` + +If you're using custom widgets that do set these query parameters, we recommend using connectors instead. + +See https://www.algolia.com/doc/guides/building-search-ui/widgets/customize-an-existing-widget/js/#customize-the-complete-ui-of-the-widgets` + ); +} diff --git a/packages/instantsearch-core/src/lib/public/checkRendering.ts b/packages/instantsearch-core/src/lib/public/checkRendering.ts new file mode 100644 index 00000000000..e6d91aceaa5 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/checkRendering.ts @@ -0,0 +1,16 @@ +import { getObjectType } from './getObjectType'; + +import type { Renderer } from '../../types/connector'; + +export function checkRendering( + rendering: any, + usage: string +): asserts rendering is Renderer { + if (rendering === undefined || typeof rendering !== 'function') { + throw new Error(`The render function is not valid (received type ${getObjectType( + rendering + )}). + +${usage}`); + } +} diff --git a/packages/instantsearch-core/src/lib/public/clearRefinements.ts b/packages/instantsearch-core/src/lib/public/clearRefinements.ts new file mode 100644 index 00000000000..1a357f6df7a --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/clearRefinements.ts @@ -0,0 +1,43 @@ +import type { + AlgoliaSearchHelper, + SearchParameters, +} from 'algoliasearch-helper'; + +/** + * Clears the refinements of a SearchParameters object based on rules provided. + * The included attributes list is applied before the excluded attributes list. If the list + * is not provided, this list of all the currently refined attributes is used as included attributes. + * @returns search parameters with refinements cleared + */ +export function clearRefinements({ + helper, + attributesToClear = [], +}: { + helper: AlgoliaSearchHelper; + attributesToClear?: string[]; +}): SearchParameters { + let finalState = helper.state.setPage(0); + + finalState = attributesToClear.reduce((state, attribute) => { + if (finalState.isNumericRefined(attribute)) { + return state.removeNumericRefinement(attribute); + } + if (finalState.isHierarchicalFacet(attribute)) { + return state.removeHierarchicalFacetRefinement(attribute); + } + if (finalState.isDisjunctiveFacet(attribute)) { + return state.removeDisjunctiveFacetRefinement(attribute); + } + if (finalState.isConjunctiveFacet(attribute)) { + return state.removeFacetRefinement(attribute); + } + + return state; + }, finalState); + + if (attributesToClear.indexOf('query') !== -1) { + finalState = finalState.setQuery(''); + } + + return finalState; +} diff --git a/packages/instantsearch-core/src/lib/public/concatHighlightedParts.ts b/packages/instantsearch-core/src/lib/public/concatHighlightedParts.ts new file mode 100644 index 00000000000..fd6d97fe6c2 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/concatHighlightedParts.ts @@ -0,0 +1,15 @@ +import { TAG_REPLACEMENT } from './escape-highlight'; + +import type { HighlightedParts } from '../../types'; + +export function concatHighlightedParts(parts: HighlightedParts[]) { + const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT; + + return parts + .map((part) => + part.isHighlighted + ? highlightPreTag + part.value + highlightPostTag + : part.value + ) + .join(''); +} diff --git a/packages/instantsearch-core/src/lib/public/createConcurrentSafePromise.ts b/packages/instantsearch-core/src/lib/public/createConcurrentSafePromise.ts new file mode 100644 index 00000000000..200ccb5cc67 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/createConcurrentSafePromise.ts @@ -0,0 +1,46 @@ +export type MaybePromise = + | Readonly> + | Promise + | TResolution; + +// copied from +// https://github.com/algolia/autocomplete.js/blob/307a7acc4283e10a19cb7d067f04f1bea79dc56f/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts#L1:L1 +/** + * Creates a runner that executes promises in a concurrent-safe way. + * + * This is useful to prevent older promises to resolve after a newer promise, + * otherwise resulting in stale resolved values. + */ +export function createConcurrentSafePromise() { + let basePromiseId = -1; + let latestResolvedId = -1; + let latestResolvedValue: TValue | undefined = undefined; + + return function runConcurrentSafePromise(promise: MaybePromise) { + const currentPromiseId = ++basePromiseId; + + return Promise.resolve(promise).then((x) => { + // The promise might take too long to resolve and get outdated. This would + // result in resolving stale values. + // When this happens, we ignore the promise value and return the one + // coming from the latest resolved value. + // + // +----------------------------------+ + // | 100ms | + // | run(1) +---> R1 | + // | 300ms | + // | run(2) +-------------> R2 (SKIP) | + // | 200ms | + // | run(3) +--------> R3 | + // +----------------------------------+ + if (latestResolvedValue && currentPromiseId < latestResolvedId) { + return latestResolvedValue; + } + + latestResolvedId = currentPromiseId; + latestResolvedValue = x; + + return x; + }); + }; +} diff --git a/packages/instantsearch-core/src/lib/public/createSendEventForFacet.ts b/packages/instantsearch-core/src/lib/public/createSendEventForFacet.ts new file mode 100644 index 00000000000..952afbac1e8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/createSendEventForFacet.ts @@ -0,0 +1,67 @@ +import { isFacetRefined } from './isFacetRefined'; + +import type { InstantSearch } from '../../types'; +import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; + +type BuiltInSendEventForFacet = ( + eventType: string, + facetValue: string, + eventName?: string, + additionalData?: Record +) => void; +type CustomSendEventForFacet = (customPayload: any) => void; + +export type SendEventForFacet = BuiltInSendEventForFacet & + CustomSendEventForFacet; + +type CreateSendEventForFacetOptions = { + instantSearchInstance: InstantSearch; + helper: AlgoliaSearchHelper; + attribute: string | ((facetValue: string) => string); + widgetType: string; +}; + +export function createSendEventForFacet({ + instantSearchInstance, + helper, + attribute: attr, + widgetType, +}: CreateSendEventForFacetOptions): SendEventForFacet { + const sendEventForFacet: SendEventForFacet = (...args: any[]) => { + const [, facetValue, eventName = 'Filter Applied', additionalData = {}] = + args; + const [eventType, eventModifier]: [string, string] = args[0].split(':'); + const attribute = typeof attr === 'string' ? attr : attr(facetValue); + + if (args.length === 1 && typeof args[0] === 'object') { + instantSearchInstance.sendEventToInsights(args[0]); + } else if (eventType === 'click' && args.length >= 2 && args.length <= 4) { + if (!isFacetRefined(helper, attribute, facetValue)) { + // send event only when the facet is being checked "ON" + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'clickedFilters', + widgetType, + eventType, + eventModifier, + payload: { + eventName, + index: helper.lastResults?.index || helper.state.index, + filters: [`${attribute}:${facetValue}`], + ...additionalData, + }, + attribute, + }); + } + } else if (__DEV__) { + throw new Error( + `You need to pass between two and four arguments like: + sendEvent('click', facetValue, eventName?, additionalData?); + +If you want to send a custom payload, you can pass one object: sendEvent(customPayload); +` + ); + } + }; + + return sendEventForFacet; +} diff --git a/packages/instantsearch-core/src/lib/public/createSendEventForHits.ts b/packages/instantsearch-core/src/lib/public/createSendEventForHits.ts new file mode 100644 index 00000000000..adf5fcca9b7 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/createSendEventForHits.ts @@ -0,0 +1,222 @@ +import { serializePayload } from './serializer'; + +import type { InsightsEvent } from '../../middlewares/createInsightsMiddleware'; +import type { InstantSearch, Hit, EscapedHits } from '../../types'; +import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; + +type BuiltInSendEventForHits = ( + eventType: string, + hits: Hit | Hit[], + eventName?: string, + additionalData?: Record +) => void; +type CustomSendEventForHits = (customPayload: any) => void; +export type SendEventForHits = BuiltInSendEventForHits & CustomSendEventForHits; + +export type BuiltInBindEventForHits = ( + eventType: string, + hits: Hit | Hit[], + eventName?: string, + additionalData?: Record +) => string; +export type CustomBindEventForHits = (customPayload: any) => string; +export type BindEventForHits = BuiltInBindEventForHits & CustomBindEventForHits; + +function chunk(arr: TItem[], chunkSize: number = 20): TItem[][] { + const chunks: TItem[][] = []; + for (let i = 0; i < Math.ceil(arr.length / chunkSize); i++) { + chunks.push(arr.slice(i * chunkSize, (i + 1) * chunkSize)); + } + return chunks; +} + +export function _buildEventPayloadsForHits({ + helper, + widgetType, + methodName, + args, + instantSearchInstance, +}: { + widgetType: string; + helper: AlgoliaSearchHelper; + methodName: 'sendEvent' | 'bindEvent'; + args: any[]; + instantSearchInstance: InstantSearch; +}): InsightsEvent[] { + // when there's only one argument, that means it's custom + if (args.length === 1 && typeof args[0] === 'object') { + return [args[0]]; + } + const [eventType, eventModifier]: [string, string] = args[0].split(':'); + + const hits: Hit | Hit[] | EscapedHits = args[1]; + const eventName: string | undefined = args[2]; + const additionalData: Record = args[3] || {}; + + if (!hits) { + if (__DEV__) { + throw new Error( + `You need to pass hit or hits as the second argument like: + ${methodName}(eventType, hit); + ` + ); + } else { + return []; + } + } + if ((eventType === 'click' || eventType === 'conversion') && !eventName) { + if (__DEV__) { + throw new Error( + `You need to pass eventName as the third argument for 'click' or 'conversion' events like: + ${methodName}('click', hit, 'Product Purchased'); + + To learn more about event naming: https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/in-depth/clicks-conversions-best-practices/ + ` + ); + } else { + return []; + } + } + const hitsArray: Hit[] = Array.isArray(hits) ? hits : [hits]; + + if (hitsArray.length === 0) { + return []; + } + const queryID = hitsArray[0].__queryID; + const hitsChunks = chunk(hitsArray); + const objectIDsByChunk = hitsChunks.map((batch) => + batch.map((hit) => hit.objectID) + ); + const positionsByChunk = hitsChunks.map((batch) => + batch.map((hit) => hit.__position) + ); + + if (eventType === 'view') { + if (instantSearchInstance.status !== 'idle') { + return []; + } + return hitsChunks.map((batch, i) => { + return { + insightsMethod: 'viewedObjectIDs', + widgetType, + eventType, + payload: { + eventName: eventName || 'Hits Viewed', + index: helper.lastResults?.index || helper.state.index, + objectIDs: objectIDsByChunk[i], + ...additionalData, + }, + hits: batch, + eventModifier, + }; + }); + } else if (eventType === 'click') { + return hitsChunks.map((batch, i) => { + return { + insightsMethod: 'clickedObjectIDsAfterSearch', + widgetType, + eventType, + payload: { + eventName: eventName || 'Hit Clicked', + index: helper.lastResults?.index || helper.state.index, + queryID, + objectIDs: objectIDsByChunk[i], + positions: positionsByChunk[i], + ...additionalData, + }, + hits: batch, + eventModifier, + }; + }); + } else if (eventType === 'conversion') { + return hitsChunks.map((batch, i) => { + return { + insightsMethod: 'convertedObjectIDsAfterSearch', + widgetType, + eventType, + payload: { + eventName: eventName || 'Hit Converted', + index: helper.lastResults?.index || helper.state.index, + queryID, + objectIDs: objectIDsByChunk[i], + ...additionalData, + }, + hits: batch, + eventModifier, + }; + }); + } else if (__DEV__) { + throw new Error(`eventType("${eventType}") is not supported. + If you want to send a custom payload, you can pass one object: ${methodName}(customPayload); + `); + } else { + return []; + } +} + +export function createSendEventForHits({ + instantSearchInstance, + helper, + widgetType, +}: { + instantSearchInstance: InstantSearch; + helper: AlgoliaSearchHelper; + widgetType: string; +}): SendEventForHits { + let sentEvents: Record = {}; + let timer: ReturnType | undefined = undefined; + + const sendEventForHits: SendEventForHits = (...args: any[]) => { + const payloads = _buildEventPayloadsForHits({ + widgetType, + helper, + methodName: 'sendEvent', + args, + instantSearchInstance, + }); + + payloads.forEach((payload) => { + if ( + payload.eventType === 'click' && + payload.eventModifier === 'internal' && + sentEvents[payload.eventType] + ) { + return; + } + + sentEvents[payload.eventType] = true; + instantSearchInstance.sendEventToInsights(payload); + }); + + clearTimeout(timer); + timer = setTimeout(() => { + sentEvents = {}; + }, 0); + }; + return sendEventForHits; +} + +export function createBindEventForHits({ + helper, + widgetType, + instantSearchInstance, +}: { + helper: AlgoliaSearchHelper; + widgetType: string; + instantSearchInstance: InstantSearch; +}): BindEventForHits { + const bindEventForHits: BindEventForHits = (...args: any[]) => { + const payloads = _buildEventPayloadsForHits({ + widgetType, + helper, + methodName: 'bindEvent', + args, + instantSearchInstance, + }); + + return payloads.length + ? `data-insights-event=${serializePayload(payloads)}` + : ''; + }; + return bindEventForHits; +} diff --git a/packages/instantsearch-core/src/lib/public/debounce.ts b/packages/instantsearch-core/src/lib/public/debounce.ts new file mode 100644 index 00000000000..ddb140242fb --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/debounce.ts @@ -0,0 +1,30 @@ +import type { Awaited } from '../../types'; + +type Func = (...args: any[]) => any; + +export type DebouncedFunction = ( + this: ThisParameterType, + ...args: Parameters +) => Promise>>; + +// Debounce a function call to the trailing edge. +// The debounced function returns a promise. +export function debounce( + func: TFunction, + wait: number +): DebouncedFunction { + let lastTimeout: ReturnType | null = null; + return function (...args) { + return new Promise((resolve, reject) => { + if (lastTimeout) { + clearTimeout(lastTimeout); + } + lastTimeout = setTimeout(() => { + lastTimeout = null; + Promise.resolve(func(...args)) + .then(resolve) + .catch(reject); + }, wait); + }); + }; +} diff --git a/packages/instantsearch-core/src/lib/public/defer.ts b/packages/instantsearch-core/src/lib/public/defer.ts new file mode 100644 index 00000000000..51eb56ae371 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/defer.ts @@ -0,0 +1,51 @@ +const nextMicroTask = Promise.resolve(); + +type Callback = (...args: any[]) => void; +type Defer = { + wait: () => Promise; + cancel: () => void; +}; + +export function defer( + callback: TCallback +): TCallback & Defer { + let progress: Promise | null = null; + let cancelled = false; + + const fn = ((...args: Parameters) => { + if (progress !== null) { + return; + } + + progress = nextMicroTask.then(() => { + progress = null; + + if (cancelled) { + cancelled = false; + return; + } + + callback(...args); + }); + }) as TCallback & Defer; + + fn.wait = () => { + if (progress === null) { + throw new Error( + 'The deferred function should be called before calling `wait()`' + ); + } + + return progress; + }; + + fn.cancel = () => { + if (progress === null) { + return; + } + + cancelled = true; + }; + + return fn; +} diff --git a/packages/instantsearch-core/src/lib/public/documentation.ts b/packages/instantsearch-core/src/lib/public/documentation.ts new file mode 100644 index 00000000000..7261d385d0d --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/documentation.ts @@ -0,0 +1,29 @@ +type WidgetParam = { + name: string; + connector?: boolean; +}; + +export function createDocumentationLink({ + name, + connector = false, +}: WidgetParam): string { + return [ + 'https://www.algolia.com/doc/api-reference/widgets/', + name, + '/js/', + connector ? '#connector' : '', + ].join(''); +} + +type DocumentationMessageGenerator = (message?: string) => string; + +export function createDocumentationMessageGenerator( + ...widgets: WidgetParam[] +): DocumentationMessageGenerator { + const links = widgets + .map((widget) => createDocumentationLink(widget)) + .join(', '); + + return (message?: string) => + [message, `See documentation: ${links}`].filter(Boolean).join('\n\n'); +} diff --git a/packages/instantsearch-core/src/lib/public/escape-highlight.ts b/packages/instantsearch-core/src/lib/public/escape-highlight.ts new file mode 100644 index 00000000000..d1e05d0a8a8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/escape-highlight.ts @@ -0,0 +1,79 @@ +import { escape } from './escape-html'; +import { isPlainObject } from './isPlainObject'; + +import type { Hit, FacetHit, EscapedHits } from '../../types'; + +export const TAG_PLACEHOLDER = { + highlightPreTag: '__ais-highlight__', + highlightPostTag: '__/ais-highlight__', +}; + +export const TAG_REPLACEMENT = { + highlightPreTag: '', + highlightPostTag: '', +}; + +// @MAJOR: in the future, this should only escape, not replace +function replaceTagsAndEscape(value: string): string { + return escape(value) + .replace( + new RegExp(TAG_PLACEHOLDER.highlightPreTag, 'g'), + TAG_REPLACEMENT.highlightPreTag + ) + .replace( + new RegExp(TAG_PLACEHOLDER.highlightPostTag, 'g'), + TAG_REPLACEMENT.highlightPostTag + ); +} + +function recursiveEscape(input: any): any { + if (isPlainObject(input) && typeof input.value !== 'string') { + return Object.keys(input).reduce( + (acc, key) => ({ + ...acc, + [key]: recursiveEscape(input[key]), + }), + {} + ); + } + + if (Array.isArray(input)) { + return input.map(recursiveEscape); + } + + return { + ...input, + value: replaceTagsAndEscape(input.value), + }; +} + +export function escapeHits( + hits: THit[] | EscapedHits +): EscapedHits { + if ((hits as EscapedHits).__escaped === undefined) { + // We don't override the value on hit because it will mutate the raw results + // instead we make a shallow copy and we assign the escaped values on it. + hits = hits.map(({ ...hit }) => { + if (hit._highlightResult) { + hit._highlightResult = recursiveEscape(hit._highlightResult); + } + + if (hit._snippetResult) { + hit._snippetResult = recursiveEscape(hit._snippetResult); + } + + return hit; + }); + + (hits as EscapedHits).__escaped = true; + } + + return hits as EscapedHits; +} + +export function escapeFacets(facetHits: FacetHit[]): FacetHit[] { + return facetHits.map((h) => ({ + ...h, + highlighted: replaceTagsAndEscape(h.highlighted), + })); +} diff --git a/packages/instantsearch-core/src/lib/public/escape-html.ts b/packages/instantsearch-core/src/lib/public/escape-html.ts new file mode 100644 index 00000000000..ecaee5f031b --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/escape-html.ts @@ -0,0 +1,61 @@ +/** + * This implementation is taken from Lodash implementation. + * See: https://github.com/lodash/lodash/blob/4.17.11-npm/escape.js + */ + +// Used to map characters to HTML entities. +const htmlEntities = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +// Used to match HTML entities and HTML characters. +const regexUnescapedHtml = /[&<>"']/g; +const regexHasUnescapedHtml = RegExp(regexUnescapedHtml.source); + +/** + * Converts the characters "&", "<", ">", '"', and "'" in `string` to their + * corresponding HTML entities. + */ +export function escape(value: string): string { + return value && regexHasUnescapedHtml.test(value) + ? value.replace( + regexUnescapedHtml, + (character) => htmlEntities[character as keyof typeof htmlEntities] + ) + : value; +} + +/** + * This implementation is taken from Lodash implementation. + * See: https://github.com/lodash/lodash/blob/4.17.11-npm/unescape.js + */ + +// Used to map HTML entities to characters. +const htmlCharacters = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", +}; + +// Used to match HTML entities and HTML characters. +const regexEscapedHtml = /&(amp|quot|lt|gt|#39);/g; +const regexHasEscapedHtml = RegExp(regexEscapedHtml.source); + +/** + * Converts the HTML entities "&", "<", ">", '"', and "'" in `string` to their + * characters. + */ +export function unescape(value: string): string { + return value && regexHasEscapedHtml.test(value) + ? value.replace( + regexEscapedHtml, + (character) => htmlCharacters[character as keyof typeof htmlCharacters] + ) + : value; +} diff --git a/packages/instantsearch-core/src/lib/public/escapeFacetValue.ts b/packages/instantsearch-core/src/lib/public/escapeFacetValue.ts new file mode 100644 index 00000000000..99065bc13e9 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/escapeFacetValue.ts @@ -0,0 +1,21 @@ +type FacetValue = string | number | boolean | undefined; + +export function unescapeFacetValue( + value: TFacetValue +): TFacetValue { + if (typeof value === 'string') { + return value.replace(/^\\-/, '-') as TFacetValue; + } + + return value; +} + +export function escapeFacetValue( + value: TFacetValue +): TFacetValue { + if ((typeof value === 'number' && value < 0) || typeof value === 'string') { + return String(value).replace(/^-/, '\\-') as TFacetValue; + } + + return value; +} diff --git a/packages/instantsearch-core/src/lib/public/find.ts b/packages/instantsearch-core/src/lib/public/find.ts new file mode 100644 index 00000000000..33a9ebb3c1e --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/find.ts @@ -0,0 +1,21 @@ +// We aren't using the native `Array.prototype.find` because the refactor away from Lodash is not +// published as a major version. +// Relying on the `find` polyfill on user-land, which before was only required for niche use-cases, +// was decided as too risky. +// @MAJOR Replace with the native `Array.prototype.find` method +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find +export function find( + items: TItem[], + predicate: (value: TItem, index: number, obj: TItem[]) => boolean +): TItem | undefined { + let value: TItem; + for (let i = 0; i < items.length; i++) { + value = items[i]; + // inlined for performance: if (Call(predicate, thisArg, [value, i, list])) { + if (predicate(value, i, items)) { + return value; + } + } + + return undefined; +} diff --git a/packages/instantsearch-core/src/lib/public/findIndex.ts b/packages/instantsearch-core/src/lib/public/findIndex.ts new file mode 100644 index 00000000000..efc19ad5c4f --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/findIndex.ts @@ -0,0 +1,21 @@ +// We aren't using the native `Array.prototype.findIndex` because the refactor away from Lodash is not +// published as a major version. +// Relying on the `findIndex` polyfill on user-land, which before was only required for niche use-cases, +// was decided as too risky. +// @MAJOR Replace with the native `Array.prototype.findIndex` method +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex +export function findIndex( + array: TItem[], + comparator: (value: TItem) => boolean +): number { + if (!Array.isArray(array)) { + return -1; + } + + for (let i = 0; i < array.length; i++) { + if (comparator(array[i])) { + return i; + } + } + return -1; +} diff --git a/packages/instantsearch-core/src/lib/public/flat.ts b/packages/instantsearch-core/src/lib/public/flat.ts new file mode 100644 index 00000000000..81ed14dcf4c --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/flat.ts @@ -0,0 +1,3 @@ +export function flat(arr: T[][]): T[] { + return arr.reduce((acc, array) => acc.concat(array), []); +} diff --git a/packages/instantsearch-core/src/lib/public/geo-search.ts b/packages/instantsearch-core/src/lib/public/geo-search.ts new file mode 100644 index 00000000000..ec3e0b0a927 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/geo-search.ts @@ -0,0 +1,74 @@ +const latLngRegExp = /^(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)$/; + +export function aroundLatLngToPosition(value: string) { + const pattern = value.match(latLngRegExp); + + // Since the value provided is the one send with the request, the API should + // throw an error due to the wrong format. So throw an error should be safe. + if (!pattern) { + throw new Error(`Invalid value for "aroundLatLng" parameter: "${value}"`); + } + + return { + lat: parseFloat(pattern[1]), + lng: parseFloat(pattern[2]), + }; +} + +export type LatLng = Array<[number, number, number, number]>; + +function insideBoundingBoxArrayToBoundingBox(value: LatLng) { + const [ + [neLat, neLng, swLat, swLng] = [undefined, undefined, undefined, undefined], + ] = value; + + // Since the value provided is the one send with the request, the API should + // throw an error due to the wrong format. So throw an error should be safe. + if (!neLat || !neLng || !swLat || !swLng) { + throw new Error( + `Invalid value for "insideBoundingBox" parameter: [${value}]` + ); + } + + return { + northEast: { + lat: neLat, + lng: neLng, + }, + southWest: { + lat: swLat, + lng: swLng, + }, + }; +} + +function insideBoundingBoxStringToBoundingBox(value: string) { + const [neLat, neLng, swLat, swLng] = value.split(',').map(parseFloat); + + // Since the value provided is the one send with the request, the API should + // throw an error due to the wrong format. So throw an error should be safe. + if (!neLat || !neLng || !swLat || !swLng) { + throw new Error( + `Invalid value for "insideBoundingBox" parameter: "${value}"` + ); + } + + return { + northEast: { + lat: neLat, + lng: neLng, + }, + southWest: { + lat: swLat, + lng: swLng, + }, + }; +} + +export function insideBoundingBoxToBoundingBox(value: string | LatLng) { + if (Array.isArray(value)) { + return insideBoundingBoxArrayToBoundingBox(value); + } + + return insideBoundingBoxStringToBoundingBox(value); +} diff --git a/packages/instantsearch-core/src/lib/public/getAlgoliaAgent.ts b/packages/instantsearch-core/src/lib/public/getAlgoliaAgent.ts new file mode 100644 index 00000000000..8437d22b30f --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getAlgoliaAgent.ts @@ -0,0 +1,10 @@ +type v4 = { transporter?: { userAgent: { value: string } } }; +type v3 = { _ua: string }; +type AnySearchClient = v4 & v3; + +export function getAlgoliaAgent(client: unknown): string { + const clientTyped = client as AnySearchClient; + return clientTyped.transporter && clientTyped.transporter.userAgent + ? clientTyped.transporter.userAgent.value + : clientTyped._ua; +} diff --git a/packages/instantsearch-core/src/lib/public/getAppIdAndApiKey.ts b/packages/instantsearch-core/src/lib/public/getAppIdAndApiKey.ts new file mode 100644 index 00000000000..707656fbd1d --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getAppIdAndApiKey.ts @@ -0,0 +1,23 @@ +// typed as any, since it accepts the _real_ js clients, not the interface we otherwise expect +export function getAppIdAndApiKey( + searchClient: any +): [appId: string, apiKey: string] | [appId: undefined, apiKey: undefined] { + if (searchClient.appId && searchClient.apiKey) { + // searchClient v5 + return [searchClient.appId, searchClient.apiKey]; + } else if (searchClient.transporter) { + // searchClient v4 or v5 + const transporter = searchClient.transporter; + const headers = transporter.headers || transporter.baseHeaders; + const queryParameters = + transporter.queryParameters || transporter.baseQueryParameters; + const APP_ID = 'x-algolia-application-id'; + const API_KEY = 'x-algolia-api-key'; + const appId = headers[APP_ID] || queryParameters[APP_ID]; + const apiKey = headers[API_KEY] || queryParameters[API_KEY]; + return [appId, apiKey]; + } else { + // searchClient v3 + return [searchClient.applicationID, searchClient.apiKey]; + } +} diff --git a/packages/instantsearch-core/src/lib/public/getHighlightFromSiblings.ts b/packages/instantsearch-core/src/lib/public/getHighlightFromSiblings.ts new file mode 100644 index 00000000000..03aba197eb2 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getHighlightFromSiblings.ts @@ -0,0 +1,20 @@ +import { unescape } from './escape-html'; + +import type { HighlightedParts } from '../../types'; + +const hasAlphanumeric = new RegExp(/\w/i); + +export function getHighlightFromSiblings(parts: HighlightedParts[], i: number) { + const current = parts[i]; + const isNextHighlighted = parts[i + 1]?.isHighlighted || true; + const isPreviousHighlighted = parts[i - 1]?.isHighlighted || true; + + if ( + !hasAlphanumeric.test(unescape(current.value)) && + isPreviousHighlighted === isNextHighlighted + ) { + return isPreviousHighlighted; + } + + return current.isHighlighted; +} diff --git a/packages/instantsearch-core/src/lib/public/getHighlightedParts.ts b/packages/instantsearch-core/src/lib/public/getHighlightedParts.ts new file mode 100644 index 00000000000..c95f2269fb5 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getHighlightedParts.ts @@ -0,0 +1,30 @@ +import { TAG_REPLACEMENT } from './escape-highlight'; + +export function getHighlightedParts(highlightedValue: string) { + // @MAJOR: this should use TAG_PLACEHOLDER + const { highlightPostTag, highlightPreTag } = TAG_REPLACEMENT; + + const splitByPreTag = highlightedValue.split(highlightPreTag); + const firstValue = splitByPreTag.shift(); + const elements = !firstValue + ? [] + : [{ value: firstValue, isHighlighted: false }]; + + splitByPreTag.forEach((split) => { + const splitByPostTag = split.split(highlightPostTag); + + elements.push({ + value: splitByPostTag[0], + isHighlighted: true, + }); + + if (splitByPostTag[1] !== '') { + elements.push({ + value: splitByPostTag[1], + isHighlighted: false, + }); + } + }); + + return elements; +} diff --git a/packages/instantsearch-core/src/lib/public/getObjectType.ts b/packages/instantsearch-core/src/lib/public/getObjectType.ts new file mode 100644 index 00000000000..cb6707fddb5 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getObjectType.ts @@ -0,0 +1,3 @@ +export function getObjectType(object: unknown): string { + return Object.prototype.toString.call(object).slice(8, -1); +} diff --git a/packages/instantsearch-core/src/lib/public/getPropertyByPath.ts b/packages/instantsearch-core/src/lib/public/getPropertyByPath.ts new file mode 100644 index 00000000000..5c86b33be58 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getPropertyByPath.ts @@ -0,0 +1,8 @@ +export function getPropertyByPath( + object: Record | undefined, + path: string | string[] +): any { + const parts = Array.isArray(path) ? path : path.split('.'); + + return parts.reduce((current, key) => current && current[key], object); +} diff --git a/packages/instantsearch-core/src/lib/public/getRefinements.ts b/packages/instantsearch-core/src/lib/public/getRefinements.ts new file mode 100644 index 00000000000..caca0f47a6e --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getRefinements.ts @@ -0,0 +1,217 @@ +import { unescapeFacetValue, escapeFacetValue } from './escapeFacetValue'; +import { find } from './find'; + +import type { SearchParameters, SearchResults } from 'algoliasearch-helper'; + +export type FacetRefinement = { + type: 'facet' | 'disjunctive' | 'hierarchical'; + attribute: string; + name: string; + escapedValue: string; + count?: number; + exhaustive?: boolean; +}; + +export type TagRefinement = { + type: 'tag'; + attribute: string; + name: string; +}; + +export type QueryRefinement = { + type: 'query'; + attribute: 'query'; + query: string; + name: string; +}; + +export type NumericRefinement = { + type: 'numeric'; + numericValue: number; + operator: '<' | '<=' | '=' | '!=' | '>=' | '>'; + attribute: string; + name: string; + count?: number; + exhaustive?: boolean; +}; + +export type FacetExcludeRefinement = { + type: 'exclude'; + exclude: boolean; + attribute: string; + name: string; + count?: number; + exhaustive?: boolean; +}; + +export type Refinement = + | FacetRefinement + | QueryRefinement + | NumericRefinement + | FacetExcludeRefinement + | TagRefinement; + +function getRefinement( + state: SearchParameters, + type: FacetRefinement['type'], + attribute: FacetRefinement['attribute'], + name: FacetRefinement['name'], + resultsFacets: SearchResults['facets' | 'hierarchicalFacets'] = [] +): FacetRefinement { + const res: FacetRefinement = { + type, + attribute, + name, + escapedValue: escapeFacetValue(name), + }; + let facet: any = find( + resultsFacets, + (resultsFacet) => resultsFacet.name === attribute + ); + let count: number; + + if (type === 'hierarchical') { + const facetDeclaration = state.getHierarchicalFacetByName(attribute); + const nameParts = name.split(facetDeclaration.separator); + + const getFacetRefinement = + (facetData: any): ((refinementKey: string) => any) => + (refinementKey: string): any => + facetData[refinementKey]; + + for (let i = 0; facet !== undefined && i < nameParts.length; ++i) { + facet = + facet && + facet.data && + find( + Object.keys(facet.data).map(getFacetRefinement(facet.data)), + (refinement) => refinement.name === nameParts[i] + ); + } + + count = facet && facet.count; + } else { + count = facet && facet.data && facet.data[res.name]; + } + + if (count !== undefined) { + res.count = count; + } + + if (facet && facet.exhaustive !== undefined) { + res.exhaustive = facet.exhaustive; + } + + return res; +} + +export function getRefinements( + _results: SearchResults | Record | null, + state: SearchParameters, + includesQuery: boolean = false +): Refinement[] { + const results = _results || {}; + const refinements: Refinement[] = []; + const { + facetsRefinements = {}, + facetsExcludes = {}, + disjunctiveFacetsRefinements = {}, + hierarchicalFacetsRefinements = {}, + numericRefinements = {}, + tagRefinements = [], + } = state; + + Object.keys(facetsRefinements).forEach((attribute) => { + const refinementNames = facetsRefinements[attribute]; + + refinementNames.forEach((refinementName) => { + refinements.push( + getRefinement(state, 'facet', attribute, refinementName, results.facets) + ); + }); + }); + + Object.keys(facetsExcludes).forEach((attribute) => { + const refinementNames = facetsExcludes[attribute]; + + refinementNames.forEach((refinementName) => { + refinements.push({ + type: 'exclude', + attribute, + name: refinementName, + exclude: true, + }); + }); + }); + + Object.keys(disjunctiveFacetsRefinements).forEach((attribute) => { + const refinementNames = disjunctiveFacetsRefinements[attribute]; + + refinementNames.forEach((refinementName) => { + refinements.push( + getRefinement( + state, + 'disjunctive', + attribute, + // We unescape any disjunctive refined values with `unescapeFacetValue` because + // they can be escaped on negative numeric values with `escapeFacetValue`. + unescapeFacetValue(refinementName), + results.disjunctiveFacets + ) + ); + }); + }); + + Object.keys(hierarchicalFacetsRefinements).forEach((attribute) => { + const refinementNames = hierarchicalFacetsRefinements[attribute]; + + refinementNames.forEach((refinement) => { + refinements.push( + getRefinement( + state, + 'hierarchical', + attribute, + refinement, + results.hierarchicalFacets + ) + ); + }); + }); + + Object.keys(numericRefinements).forEach((attribute) => { + const operators = numericRefinements[attribute]; + + Object.keys(operators).forEach((operatorOriginal) => { + const operator = operatorOriginal as SearchParameters.Operator; + const valueOrValues = operators[operator]; + const refinementNames = Array.isArray(valueOrValues) + ? valueOrValues + : [valueOrValues]; + + refinementNames.forEach((refinementName: any) => { + refinements.push({ + type: 'numeric', + attribute, + name: `${refinementName}`, + numericValue: refinementName, + operator: operator as NumericRefinement['operator'], + }); + }); + }); + }); + + tagRefinements.forEach((refinementName) => { + refinements.push({ type: 'tag', attribute: '_tags', name: refinementName }); + }); + + if (includesQuery && state.query && state.query.trim()) { + refinements.push({ + attribute: 'query', + type: 'query', + name: state.query, + query: state.query, + }); + } + + return refinements; +} diff --git a/packages/instantsearch-core/src/lib/public/getWidgetAttribute.ts b/packages/instantsearch-core/src/lib/public/getWidgetAttribute.ts new file mode 100644 index 00000000000..153c99bc4e1 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/getWidgetAttribute.ts @@ -0,0 +1,31 @@ +import type { InitOptions, Widget, IndexWidget } from '../../types'; + +export function getWidgetAttribute( + widget: Widget | IndexWidget, + initOptions: InitOptions +): string { + const renderState = widget.getWidgetRenderState?.(initOptions); + + let attribute = null; + + if (renderState && renderState.widgetParams) { + // casting as widgetParams is checked just before + const widgetParams = renderState.widgetParams as Record; + + if (widgetParams.attribute) { + attribute = widgetParams.attribute; + } else if (Array.isArray(widgetParams.attributes)) { + attribute = widgetParams.attributes[0]; + } + } + + if (typeof attribute !== 'string') { + throw new Error(`Could not find the attribute of the widget: + +${JSON.stringify(widget)} + +Please check whether the widget's getWidgetRenderState returns widgetParams.attribute correctly.`); + } + + return attribute; +} diff --git a/packages/instantsearch-core/src/lib/public/hits-absolute-position.ts b/packages/instantsearch-core/src/lib/public/hits-absolute-position.ts new file mode 100644 index 00000000000..48e83f471b6 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/hits-absolute-position.ts @@ -0,0 +1,12 @@ +import type { AlgoliaHit } from '../../types'; + +export function addAbsolutePosition( + hits: THit[], + page: number, + hitsPerPage: number +): Array { + return hits.map((hit, idx) => ({ + ...hit, + __position: hitsPerPage * page + idx + 1, + })); +} diff --git a/packages/instantsearch-core/src/lib/public/hits-query-id.ts b/packages/instantsearch-core/src/lib/public/hits-query-id.ts new file mode 100644 index 00000000000..3dc61f63f0d --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/hits-query-id.ts @@ -0,0 +1,14 @@ +import type { AlgoliaHit } from '../../types'; + +export function addQueryID( + hits: THit[], + queryID?: string +): Array { + if (!queryID) { + return hits; + } + return hits.map((hit) => ({ + ...hit, + __queryID: queryID, + })); +} diff --git a/packages/instantsearch-core/src/lib/public/hydrateRecommendCache.ts b/packages/instantsearch-core/src/lib/public/hydrateRecommendCache.ts new file mode 100644 index 00000000000..f91d7658bc7 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/hydrateRecommendCache.ts @@ -0,0 +1,20 @@ +import type { InitialResults } from '../../types'; +import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; + +export function hydrateRecommendCache( + helper: AlgoliaSearchHelper, + initialResults: InitialResults +) { + const recommendCache = Object.keys(initialResults).reduce( + (acc, indexName) => { + const initialResult = initialResults[indexName]; + if (initialResult.recommendResults) { + // @MAJOR: Use `Object.assign` instead of spread operator + return { ...acc, ...initialResult.recommendResults.results }; + } + return acc; + }, + {} + ); + helper._recommendCache = recommendCache; +} diff --git a/packages/instantsearch-core/src/lib/public/hydrateSearchClient.ts b/packages/instantsearch-core/src/lib/public/hydrateSearchClient.ts new file mode 100644 index 00000000000..2d1bf55daf6 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/hydrateSearchClient.ts @@ -0,0 +1,190 @@ +import type { + SearchClient, + InitialResults, + ClientV3_4, + SearchOptions, + SearchResponse, + CompositionClient, +} from '../../types'; + +type ClientWithCache = SearchClient & { cache: Record }; +type ClientWithTransporter = ClientV3_4 & { + transporter: { responsesCache: any }; + search: (requests: any, ...args: any[]) => any; +}; + +function getServerResults(entry: InitialResults[string]) { + return entry.compositionFeedsResults?.length + ? entry.compositionFeedsResults + : entry.results || []; +} + +export function hydrateSearchClient( + client: (SearchClient | CompositionClient) & { + _cacheHydrated?: boolean; + _useCache?: boolean; + }, + results?: InitialResults +) { + if (!results) { + return; + } + + // Disable cache hydration on: + // - Algoliasearch API Client < v4 with cache disabled + // - Third party clients (detected by the `addAlgoliaAgent` function missing) + + if ( + (!('transporter' in client) || client._cacheHydrated) && + (!client._useCache || typeof client.addAlgoliaAgent !== 'function') + ) { + return; + } + + const cachedRequest = [ + Object.keys(results).reduce< + Array<{ + params?: string; + indexName?: string; + }> + >((acc, key) => { + const entry = results[key]; + const { state, requestParams } = entry; + const serverResults = getServerResults(entry); + const mappedResults = + serverResults && state + ? serverResults.map((result, idx) => ({ + indexName: state.index || result.index, + // We normalize the params received from the server as they can + // be serialized differently depending on the engine. + // We use search parameters from the server request to craft the cache + // if possible, and fallback to those from results if not. + ...(requestParams?.[idx] || result.params + ? { + params: serializeQueryParameters( + requestParams?.[idx] || + deserializeQueryParameters(result.params) + ), + } + : {}), + })) + : []; + return acc.concat(mappedResults); + }, []), + ]; + + const cachedResults = Object.keys(results).reduce>>( + (acc, key) => { + const res = getServerResults(results[key]); + if (!res) { + return acc; + } + return acc.concat(res); + }, + [] + ); + + // Algoliasearch API Client >= v4 + // To hydrate the client we need to populate the cache with the data from + // the server (done in `hydrateSearchClientWithMultiIndexRequest` or + // `hydrateSearchClientWithSingleIndexRequest`). But since there is no way + // for us to compute the key the same way as `algoliasearch-client` we need + // to populate it on a custom key and override the `search` method to + // search on it first. + if ('transporter' in client && !client._cacheHydrated) { + client._cacheHydrated = true; + + const baseMethod = client.search.bind(client) as unknown as ( + query: any, + ...args: any[] + ) => any; + client.search = ( + requests: Parameters<(SearchClient | CompositionClient)['search']>[0], + // @ts-ignore wanting type checks for v3 on this would make this too complex + ...methodArgs + ) => { + const requestsWithSerializedParams = Array.isArray(requests) + ? // search client + requests.map((request) => ({ + ...request, + params: serializeQueryParameters(request.params), + })) + : // composition client + serializeQueryParameters(requests.requestBody.params); + + return (client as ClientWithTransporter).transporter.responsesCache.get( + { + method: 'search', + args: [requestsWithSerializedParams, ...methodArgs], + }, + () => { + return baseMethod(requests, ...methodArgs); + } + ); + }; + + (client as ClientWithTransporter).transporter.responsesCache.set( + { + method: 'search', + args: cachedRequest, + }, + { + results: cachedResults, + } + ); + } + + // Algoliasearch API Client < v4 + // Prior to client v4 we didn't have a proper API to hydrate the client + // cache from the outside. The following code populates the cache with + // a single-index result. You can find more information about the + // computation of the key inside the client (see link below). + // https://github.com/algolia/algoliasearch-client-javascript/blob/c27e89ff92b2a854ae6f40dc524bffe0f0cbc169/src/AlgoliaSearchCore.js#L232-L240 + if (!('transporter' in client)) { + const cacheKey = `/1/indexes/*/queries_body_${JSON.stringify({ + requests: cachedRequest, + })}`; + + (client as ClientWithCache).cache = { + ...(client as ClientWithCache).cache, + [cacheKey]: JSON.stringify({ + results: Object.keys(results).map((key) => + getServerResults(results[key]) + ), + }), + }; + } +} + +function deserializeQueryParameters(parameters: string) { + return parameters.split('&').reduce>((acc, parameter) => { + const [key, value] = parameter.split('='); + acc[key] = value ? decodeURIComponent(value) : ''; + return acc; + }, {}); +} + +// This function is copied from the algoliasearch v4 API Client. If modified, +// consider updating it also in `serializeQueryParameters` from `@algolia/transporter`. +function serializeQueryParameters(parameters: SearchOptions) { + const isObjectOrArray = (value: any) => + Object.prototype.toString.call(value) === '[object Object]' || + Object.prototype.toString.call(value) === '[object Array]'; + + const encode = (format: string, ...args: [string, any]) => { + let i = 0; + return format.replace(/%s/g, () => encodeURIComponent(args[i++])); + }; + + return Object.keys(parameters) + .map((key) => + encode( + '%s=%s', + key, + isObjectOrArray(parameters[key as keyof SearchOptions]) + ? JSON.stringify(parameters[key as keyof SearchOptions]) + : parameters[key as keyof SearchOptions] + ) + ) + .join('&'); +} diff --git a/packages/instantsearch-core/src/lib/public/index.ts b/packages/instantsearch-core/src/lib/public/index.ts new file mode 100644 index 00000000000..48da2e71dba --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/index.ts @@ -0,0 +1,55 @@ +export * from './addWidgetId'; +export * from './capitalize'; +export * from './checkIndexUiState'; +export * from './checkRendering'; +export * from './clearRefinements'; +export * from './concatHighlightedParts'; +export * from './createConcurrentSafePromise'; +export * from './createSendEventForFacet'; +export * from './createSendEventForHits'; +export * from './debounce'; +export * from './defer'; +export * from './documentation'; +export * from './escape-highlight'; +export * from './escape-html'; +export * from './escapeFacetValue'; +export * from './find'; +export * from './findIndex'; +export * from './flat'; +export * from './geo-search'; +export * from './getAlgoliaAgent'; +export * from './getAppIdAndApiKey'; +export * from './getHighlightFromSiblings'; +export * from './getHighlightedParts'; +export * from './getObjectType'; +export * from './getPropertyByPath'; +export * from './getRefinements'; +export * from './getWidgetAttribute'; +export * from './hits-absolute-position'; +export * from './hits-query-id'; +export * from './hydrateRecommendCache'; +export * from './hydrateSearchClient'; +export * from './isEqual'; +export * from './isFacetRefined'; +export * from './isFiniteNumber'; +export * from './isIndexWidget'; +export * from './isPlainObject'; +export * from './isSpecialClick'; +export * from './isTwoPassWidget'; +export * from './logger'; +export * from './mergeSearchParameters'; +export * from './noop'; +export * from './omit'; +export * from './range'; +export * from './render-args'; +export * from './resolveSearchParameters'; +export * from './reverseHighlightedParts'; +export * from './safelyRunOnBrowser'; +export * from './sendChatMessageFeedback'; +export * from './serializer'; +export * from './setIndexHelperState'; +export * from './toArray'; +export * from './typedObject'; +export * from './uniq'; +export * from './uuid'; +export * from './walkIndex'; diff --git a/packages/instantsearch-core/src/lib/public/isEqual.ts b/packages/instantsearch-core/src/lib/public/isEqual.ts new file mode 100644 index 00000000000..88f496fc769 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/isEqual.ts @@ -0,0 +1,36 @@ +function isPrimitive(obj: any): boolean { + return obj !== Object(obj); +} + +export function isEqual(first: any, second: any): boolean { + if (first === second) { + return true; + } + + if ( + isPrimitive(first) || + isPrimitive(second) || + typeof first === 'function' || + typeof second === 'function' + ) { + return first === second; + } + + if (Object.keys(first).length !== Object.keys(second).length) { + return false; + } + + // @TODO avoid for..of because of the large polyfill + // eslint-disable-next-line instantsearch/no-for-of + for (const key of Object.keys(first)) { + if (!(key in second)) { + return false; + } + + if (!isEqual(first[key], second[key])) { + return false; + } + } + + return true; +} diff --git a/packages/instantsearch-core/src/lib/public/isFacetRefined.ts b/packages/instantsearch-core/src/lib/public/isFacetRefined.ts new file mode 100644 index 00000000000..c28ebb75ed3 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/isFacetRefined.ts @@ -0,0 +1,15 @@ +import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; + +export function isFacetRefined( + helper: AlgoliaSearchHelper, + facet: string, + value: string +) { + if (helper.state.isHierarchicalFacet(facet)) { + return helper.state.isHierarchicalFacetRefined(facet, value); + } else if (helper.state.isConjunctiveFacet(facet)) { + return helper.state.isFacetRefined(facet, value); + } else { + return helper.state.isDisjunctiveFacetRefined(facet, value); + } +} diff --git a/packages/instantsearch-core/src/lib/public/isFiniteNumber.ts b/packages/instantsearch-core/src/lib/public/isFiniteNumber.ts new file mode 100644 index 00000000000..fb6c2fd47d5 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/isFiniteNumber.ts @@ -0,0 +1,7 @@ +// This is the `Number.isFinite()` polyfill recommended by MDN. +// We do not provide any tests for this function. +// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isFinite#Polyfill +// @MAJOR Replace with the native `Number.isFinite` method +export function isFiniteNumber(value: any): value is number { + return typeof value === 'number' && isFinite(value); +} diff --git a/packages/instantsearch-core/src/lib/public/isIndexWidget.ts b/packages/instantsearch-core/src/lib/public/isIndexWidget.ts new file mode 100644 index 00000000000..0cfa1af05eb --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/isIndexWidget.ts @@ -0,0 +1,9 @@ +import { indexWidgetTypes } from '../../types'; + +import type { Widget, IndexWidget } from '../../types'; + +export function isIndexWidget( + widget: Widget | IndexWidget +): widget is IndexWidget { + return indexWidgetTypes.includes(widget.$$type as (typeof indexWidgetTypes)[number]); +} diff --git a/packages/instantsearch-core/src/lib/public/isPlainObject.ts b/packages/instantsearch-core/src/lib/public/isPlainObject.ts new file mode 100644 index 00000000000..e54c40355f2 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/isPlainObject.ts @@ -0,0 +1,40 @@ +/** + * This implementation is taken from Lodash implementation. + * See: https://github.com/lodash/lodash/blob/master/isPlainObject.js + */ + +function getTag(value: any): string { + if (value === null) { + return value === undefined ? '[object Undefined]' : '[object Null]'; + } + + return Object.prototype.toString.call(value); +} + +function isObjectLike(value: any): boolean { + return typeof value === 'object' && value !== null; +} + +/** + * Checks if `value` is a plain object. + * + * A plain object is an object created by the `Object` + * constructor or with a `[[Prototype]]` of `null`. + */ +export function isPlainObject(value: any): boolean { + if (!isObjectLike(value) || getTag(value) !== '[object Object]') { + return false; + } + + if (Object.getPrototypeOf(value) === null) { + return true; + } + + let proto = value; + + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + + return Object.getPrototypeOf(value) === proto; +} diff --git a/packages/instantsearch-core/src/lib/public/isSpecialClick.ts b/packages/instantsearch-core/src/lib/public/isSpecialClick.ts new file mode 100644 index 00000000000..e4877da9816 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/isSpecialClick.ts @@ -0,0 +1,11 @@ +export function isSpecialClick(event: MouseEvent): boolean { + const isMiddleClick = event.button === 1; + + return ( + isMiddleClick || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ); +} diff --git a/packages/instantsearch-core/src/lib/public/isTwoPassWidget.ts b/packages/instantsearch-core/src/lib/public/isTwoPassWidget.ts new file mode 100644 index 00000000000..93911389fe5 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/isTwoPassWidget.ts @@ -0,0 +1,11 @@ +import type { Widget, IndexWidget } from '../../types'; + +/** + * Returns true if the widget requires a second SSR pass to discover and + * mount child widgets (e.g. DynamicWidgets, Feeds). + */ +export function isTwoPassWidget(widget: Widget | IndexWidget): boolean { + return ( + widget.$$type === 'ais.dynamicWidgets' || widget.$$type === 'ais.feeds' + ); +} diff --git a/packages/instantsearch-core/src/lib/public/logger.ts b/packages/instantsearch-core/src/lib/public/logger.ts new file mode 100644 index 00000000000..512789672a9 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/logger.ts @@ -0,0 +1,69 @@ +import { noop } from './noop'; + +type Warn = (message: string) => void; + +type Warning = { + (condition: boolean, message: string): void; + cache: { [message: string]: boolean }; +}; + +/** + * Logs a warning when this function is called, in development environment only. + */ +let deprecate = any>( + fn: TCallback, + // @ts-ignore this parameter is used in the __DEV__ branch + // eslint-disable-next-line no-unused-vars + message: string +) => fn; + +/** + * Logs a warning + * This is used to log issues in development environment only. + */ +let warn: Warn = noop; + +/** + * Logs a warning if the condition is not met. + * This is used to log issues in development environment only. + */ +let warning = noop as Warning; + +if (__DEV__) { + warn = (message) => { + // eslint-disable-next-line no-console + console.warn(`[InstantSearch.js]: ${message.trim()}`); + }; + + deprecate = (fn, message) => { + let hasAlreadyPrinted = false; + + return function (...args) { + if (!hasAlreadyPrinted) { + hasAlreadyPrinted = true; + + warn(message); + } + + return fn(...args); + } as typeof fn; + }; + + warning = ((condition, message) => { + if (condition) { + return; + } + + const hasAlreadyPrinted = warning.cache[message]; + + if (!hasAlreadyPrinted) { + warning.cache[message] = true; + + warn(message); + } + }) as Warning; + + warning.cache = {}; +} + +export { warn, deprecate, warning }; diff --git a/packages/instantsearch-core/src/lib/public/mergeSearchParameters.ts b/packages/instantsearch-core/src/lib/public/mergeSearchParameters.ts new file mode 100644 index 00000000000..16309a425fa --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/mergeSearchParameters.ts @@ -0,0 +1,155 @@ +import { findIndex } from './findIndex'; +import { uniq } from './uniq'; + +import type { SearchParameters } from 'algoliasearch-helper'; + +type Merger = ( + left: SearchParameters, + right: SearchParameters +) => SearchParameters; + +const mergeWithRest: Merger = (left, right) => { + const { + facets, + disjunctiveFacets, + facetsRefinements, + facetsExcludes, + disjunctiveFacetsRefinements, + numericRefinements, + tagRefinements, + hierarchicalFacets, + hierarchicalFacetsRefinements, + ruleContexts, + ...rest + } = right; + + return left.setQueryParameters(rest); +}; + +// Merge facets +const mergeFacets: Merger = (left, right) => + right.facets!.reduce((_, name) => _.addFacet(name), left); + +const mergeDisjunctiveFacets: Merger = (left, right) => + right.disjunctiveFacets.reduce( + (_, name) => _.addDisjunctiveFacet(name), + left + ); + +const mergeHierarchicalFacets: Merger = (left, right) => + left.setQueryParameters({ + hierarchicalFacets: right.hierarchicalFacets.reduce((facets, facet) => { + const index = findIndex(facets, (_) => _.name === facet.name); + + if (index === -1) { + return facets.concat(facet); + } + + const nextFacets = facets.slice(); + nextFacets.splice(index, 1, facet); + + return nextFacets; + }, left.hierarchicalFacets), + }); + +// Merge facet refinements +const mergeTagRefinements: Merger = (left, right) => + right.tagRefinements.reduce((_, value) => _.addTagRefinement(value), left); + +const mergeFacetRefinements: Merger = (left, right) => + left.setQueryParameters({ + facetsRefinements: { + ...left.facetsRefinements, + ...right.facetsRefinements, + }, + }); + +const mergeFacetsExcludes: Merger = (left, right) => + left.setQueryParameters({ + facetsExcludes: { + ...left.facetsExcludes, + ...right.facetsExcludes, + }, + }); + +const mergeDisjunctiveFacetsRefinements: Merger = (left, right) => + left.setQueryParameters({ + disjunctiveFacetsRefinements: { + ...left.disjunctiveFacetsRefinements, + ...right.disjunctiveFacetsRefinements, + }, + }); + +const mergeNumericRefinements: Merger = (left, right) => + left.setQueryParameters({ + numericRefinements: { + ...left.numericRefinements, + ...right.numericRefinements, + }, + }); + +const mergeHierarchicalFacetsRefinements: Merger = (left, right) => + left.setQueryParameters({ + hierarchicalFacetsRefinements: { + ...left.hierarchicalFacetsRefinements, + ...right.hierarchicalFacetsRefinements, + }, + }); + +const mergeRuleContexts: Merger = (left, right) => { + const ruleContexts: string[] = uniq( + ([] as any) + .concat(left.ruleContexts) + .concat(right.ruleContexts) + .filter(Boolean) + ); + + if (ruleContexts.length > 0) { + return left.setQueryParameters({ + ruleContexts, + }); + } + + return left; +}; + +export const mergeSearchParameters = ( + ...parameters: SearchParameters[] +): SearchParameters => + parameters.reduce((left, right) => { + const hierarchicalFacetsRefinementsMerged = + mergeHierarchicalFacetsRefinements(left, right); + const hierarchicalFacetsMerged = mergeHierarchicalFacets( + hierarchicalFacetsRefinementsMerged, + right + ); + const tagRefinementsMerged = mergeTagRefinements( + hierarchicalFacetsMerged, + right + ); + const numericRefinementsMerged = mergeNumericRefinements( + tagRefinementsMerged, + right + ); + const disjunctiveFacetsRefinementsMerged = + mergeDisjunctiveFacetsRefinements(numericRefinementsMerged, right); + const facetsExcludesMerged = mergeFacetsExcludes( + disjunctiveFacetsRefinementsMerged, + right + ); + const facetRefinementsMerged = mergeFacetRefinements( + facetsExcludesMerged, + right + ); + const disjunctiveFacetsMerged = mergeDisjunctiveFacets( + facetRefinementsMerged, + right + ); + const ruleContextsMerged = mergeRuleContexts( + disjunctiveFacetsMerged, + right + ); + const facetsMerged = mergeFacets(ruleContextsMerged, right); + + return mergeWithRest(facetsMerged, right); + }); diff --git a/packages/instantsearch-core/src/lib/public/noop.ts b/packages/instantsearch-core/src/lib/public/noop.ts new file mode 100644 index 00000000000..cadb2cd0f59 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/noop.ts @@ -0,0 +1 @@ +export function noop(..._args: any[]): void {} diff --git a/packages/instantsearch-core/src/lib/public/omit.ts b/packages/instantsearch-core/src/lib/public/omit.ts new file mode 100644 index 00000000000..81e58b4ce8a --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/omit.ts @@ -0,0 +1,26 @@ +/** + * Creates a new object with the same keys as the original object, but without the excluded keys. + * @param source original object + * @param excluded keys to remove from the original object + * @returns the new object + */ +export function omit< + TSource extends Record, + TExcluded extends keyof TSource +>(source: TSource, excluded: TExcluded[]): Omit { + if (source === null || source === undefined) { + return source; + } + + type Output = Omit; + return Object.keys(source).reduce((target, key) => { + if ((excluded as Array).indexOf(key) >= 0) { + return target; + } + + const validKey = key as keyof Output; + target[validKey] = source[validKey]; + + return target; + }, {} as unknown as Output); +} diff --git a/packages/instantsearch-core/src/lib/public/range.ts b/packages/instantsearch-core/src/lib/public/range.ts new file mode 100644 index 00000000000..c724bbd88fe --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/range.ts @@ -0,0 +1,21 @@ +type RangeOptions = { + start?: number; + end: number; + step?: number; +}; + +export function range({ start = 0, end, step = 1 }: RangeOptions): number[] { + // We can't divide by 0 so we re-assign the step to 1 if it happens. + const limitStep = step === 0 ? 1 : step; + + // In some cases the array to create has a decimal length. + // We therefore need to round the value. + // Example: + // { start: 1, end: 5000, step: 500 } + // => Array length = (5000 - 1) / 500 = 9.998 + const arrayLength = Math.round((end - start) / limitStep); + + return [...Array(arrayLength)].map( + (_, current) => start + current * limitStep + ); +} diff --git a/packages/instantsearch-core/src/lib/public/render-args.ts b/packages/instantsearch-core/src/lib/public/render-args.ts new file mode 100644 index 00000000000..7a3bb6eafbc --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/render-args.ts @@ -0,0 +1,79 @@ +import type { + InstantSearch, + UiState, + Widget, + IndexWidget, + IndexRenderState, +} from '../../types'; + +export function createInitArgs( + instantSearchInstance: InstantSearch, + parent: IndexWidget, + uiState: UiState +) { + const helper = parent.getHelper()!; + return { + uiState, + helper, + parent, + instantSearchInstance, + state: helper.state, + renderState: instantSearchInstance.renderState, + templatesConfig: instantSearchInstance.templatesConfig, + createURL: parent.createURL, + scopedResults: [], + searchMetadata: { + isSearchStalled: instantSearchInstance.status === 'stalled', + }, + status: instantSearchInstance.status, + error: instantSearchInstance.error, + }; +} + +export function createRenderArgs( + instantSearchInstance: InstantSearch, + parent: IndexWidget, + widget: IndexWidget | Widget +) { + const results = parent.getResultsForWidget(widget); + const helper = parent.getHelper()!; + + return { + helper, + parent, + instantSearchInstance, + results, + scopedResults: parent.getScopedResults(), + state: results && '_state' in results ? results._state : helper.state, + renderState: instantSearchInstance.renderState, + templatesConfig: instantSearchInstance.templatesConfig, + createURL: parent.createURL, + searchMetadata: { + isSearchStalled: instantSearchInstance.status === 'stalled', + }, + status: instantSearchInstance.status, + error: instantSearchInstance.error, + }; +} + +export function storeRenderState({ + renderState, + instantSearchInstance, + parent, +}: { + renderState: IndexRenderState; + instantSearchInstance: InstantSearch; + parent?: IndexWidget; +}) { + const parentIndexName = parent + ? parent.getIndexId() + : instantSearchInstance.mainIndex.getIndexId(); + + instantSearchInstance.renderState = { + ...instantSearchInstance.renderState, + [parentIndexName]: { + ...instantSearchInstance.renderState[parentIndexName], + ...renderState, + }, + }; +} diff --git a/packages/instantsearch-core/src/lib/public/resolveSearchParameters.ts b/packages/instantsearch-core/src/lib/public/resolveSearchParameters.ts new file mode 100644 index 00000000000..025febb2281 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/resolveSearchParameters.ts @@ -0,0 +1,16 @@ +import type { IndexWidget } from '../../types'; +import type { SearchParameters } from 'algoliasearch-helper'; + +export function resolveSearchParameters( + current: IndexWidget +): SearchParameters[] { + let parent = current.getParent(); + let states = [current.getHelper()!.state]; + + while (parent !== null) { + states = [parent.getHelper()!.state].concat(states); + parent = parent.getParent(); + } + + return states; +} diff --git a/packages/instantsearch-core/src/lib/public/reverseHighlightedParts.ts b/packages/instantsearch-core/src/lib/public/reverseHighlightedParts.ts new file mode 100644 index 00000000000..559c6bd5dad --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/reverseHighlightedParts.ts @@ -0,0 +1,14 @@ +import { getHighlightFromSiblings } from './getHighlightFromSiblings'; + +import type { HighlightedParts } from '../../types'; + +export function reverseHighlightedParts(parts: HighlightedParts[]) { + if (!parts.some((part) => part.isHighlighted)) { + return parts.map((part) => ({ ...part, isHighlighted: false })); + } + + return parts.map((part, i) => ({ + ...part, + isHighlighted: !getHighlightFromSiblings(parts, i), + })); +} diff --git a/packages/instantsearch-core/src/lib/public/safelyRunOnBrowser.ts b/packages/instantsearch-core/src/lib/public/safelyRunOnBrowser.ts new file mode 100644 index 00000000000..02cc21e537e --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/safelyRunOnBrowser.ts @@ -0,0 +1,26 @@ +// eslint-disable-next-line no-restricted-globals +type BrowserCallback = (params: { window: typeof window }) => TReturn; +type SafelyRunOnBrowserOptions = { + /** + * Fallback to run on server environments. + */ + fallback: () => TReturn; +}; + +/** + * Runs code on browser environments safely. + */ +export function safelyRunOnBrowser( + callback: BrowserCallback, + { fallback }: SafelyRunOnBrowserOptions = { + fallback: () => undefined as unknown as TReturn, + } +): TReturn { + // eslint-disable-next-line no-restricted-globals + if (typeof window === 'undefined') { + return fallback(); + } + + // eslint-disable-next-line no-restricted-globals + return callback({ window }); +} diff --git a/packages/instantsearch-core/src/lib/public/sendChatMessageFeedback.ts b/packages/instantsearch-core/src/lib/public/sendChatMessageFeedback.ts new file mode 100644 index 00000000000..40ead65e0b8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/sendChatMessageFeedback.ts @@ -0,0 +1,33 @@ +export function sendChatMessageFeedback({ + agentId, + vote, + messageId, + appId, + apiKey, +}: { + agentId: string; + vote: 0 | 1; + messageId: string; + appId: string; + apiKey: string; +}): Promise { + return fetch(`https://${appId}.algolia.net/agent-studio/1/feedback`, { + method: 'POST', + body: JSON.stringify({ messageId, agentId, vote }), + headers: { + 'x-algolia-application-id': appId, + 'x-algolia-api-key': apiKey, + 'content-type': 'application/json', + }, + }).then((response) => { + if (response.status >= 300) { + return response.json().then((data) => { + throw new Error( + `Feedback request failed with status ${response.status}: ${data.message}` + ); + }); + } + + return response.json(); + }); +} diff --git a/packages/instantsearch-core/src/lib/public/serializer.ts b/packages/instantsearch-core/src/lib/public/serializer.ts new file mode 100644 index 00000000000..2697881950f --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/serializer.ts @@ -0,0 +1,7 @@ +export function serializePayload(payload: TPayload): string { + return btoa(encodeURIComponent(JSON.stringify(payload))); +} + +export function deserializePayload(serialized: string): TPayload { + return JSON.parse(decodeURIComponent(atob(serialized))); +} diff --git a/packages/instantsearch-core/src/lib/public/setIndexHelperState.ts b/packages/instantsearch-core/src/lib/public/setIndexHelperState.ts new file mode 100644 index 00000000000..70894f87d72 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/setIndexHelperState.ts @@ -0,0 +1,29 @@ +import { checkIndexUiState } from './checkIndexUiState'; +import { isIndexWidget } from './isIndexWidget'; + +import type { UiState, IndexWidget } from '../../types'; + +export function setIndexHelperState( + finalUiState: TUiState, + indexWidget: IndexWidget +) { + const nextIndexUiState = finalUiState[indexWidget.getIndexId()] || {}; + + if (__DEV__) { + checkIndexUiState({ + index: indexWidget, + indexUiState: nextIndexUiState, + }); + } + + indexWidget.getHelper()!.setState( + indexWidget.getWidgetSearchParameters(indexWidget.getHelper()!.state, { + uiState: nextIndexUiState, + }) + ); + + indexWidget + .getWidgets() + .filter(isIndexWidget) + .forEach((widget) => setIndexHelperState(finalUiState, widget)); +} diff --git a/packages/instantsearch-core/src/lib/public/toArray.ts b/packages/instantsearch-core/src/lib/public/toArray.ts new file mode 100644 index 00000000000..7ca13382713 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/toArray.ts @@ -0,0 +1,5 @@ +type ToArray = T extends unknown[] ? T : T[]; + +export function toArray(value: T): ToArray { + return (Array.isArray(value) ? value : [value]) as ToArray; +} diff --git a/packages/instantsearch-core/src/lib/public/typedObject.ts b/packages/instantsearch-core/src/lib/public/typedObject.ts new file mode 100644 index 00000000000..2fca5961da5 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/typedObject.ts @@ -0,0 +1,7 @@ +/** + * A typed version of Object.keys, to use when looping over a static object + * inspired from https://stackoverflow.com/a/65117465/3185307 + */ +export const keys = Object.keys as >( + yourObject: TObject +) => Array; diff --git a/packages/instantsearch-core/src/lib/public/uniq.ts b/packages/instantsearch-core/src/lib/public/uniq.ts new file mode 100644 index 00000000000..c465f9894e6 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/uniq.ts @@ -0,0 +1,3 @@ +export function uniq(array: TItem[]): TItem[] { + return array.filter((value, index, self) => self.indexOf(value) === index); +} diff --git a/packages/instantsearch-core/src/lib/public/uuid.ts b/packages/instantsearch-core/src/lib/public/uuid.ts new file mode 100644 index 00000000000..a2737070d36 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/uuid.ts @@ -0,0 +1,15 @@ +/** + * Create UUID according to + * https://www.ietf.org/rfc/rfc4122.txt. + * + * @returns Generated UUID. + */ +export function createUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + /* eslint-disable no-bitwise */ + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + /* eslint-enable */ + return v.toString(16); + }); +} diff --git a/packages/instantsearch-core/src/lib/public/walkIndex.ts b/packages/instantsearch-core/src/lib/public/walkIndex.ts new file mode 100644 index 00000000000..bda2b982e35 --- /dev/null +++ b/packages/instantsearch-core/src/lib/public/walkIndex.ts @@ -0,0 +1,19 @@ +import { isIndexWidget } from './isIndexWidget'; + +import type { IndexWidget } from '../../types'; + +/** + * Recurse over all child indices + */ +export function walkIndex( + indexWidget: IndexWidget, + callback: (widget: IndexWidget) => void +) { + callback(indexWidget); + + indexWidget.getWidgets().forEach((widget) => { + if (isIndexWidget(widget)) { + walkIndex(widget, callback); + } + }); +} diff --git a/packages/instantsearch-core/src/lib/routers/history.ts b/packages/instantsearch-core/src/lib/routers/history.ts new file mode 100644 index 00000000000..4c7f34baefa --- /dev/null +++ b/packages/instantsearch-core/src/lib/routers/history.ts @@ -0,0 +1,371 @@ +import qs from 'qs'; + +import { createDocumentationLink, safelyRunOnBrowser, warning } from '../utils'; + +import type { Router, UiState } from '../../types'; + +type CreateURL = (args: { + qsModule: typeof qs; + routeState: TRouteState; + location: Location; +}) => string; + +type ParseURL = (args: { + qsModule: typeof qs; + location: Location; +}) => TRouteState; + +export type BrowserHistoryArgs = { + windowTitle?: (routeState: TRouteState) => string; + writeDelay: number; + createURL: CreateURL; + parseURL: ParseURL; + // @MAJOR: The `Location` type is hard to simulate in non-browser environments + // so we should accept a subset of it that is easier to work with in any + // environments. + getLocation: () => Location; + start?: (onUpdate: () => void) => void; + dispose?: () => void; + push?: (url: string) => void; + /** + * Whether the URL should be cleaned up when the router is disposed. + * This can be useful when closing a modal containing InstantSearch, to + * remove active refinements from the URL. + * @default true + */ + // @MAJOR: Switch the default to `false` and remove the console info in the next major version. + cleanUrlOnDispose?: boolean; +}; + +const setWindowTitle = (title?: string): void => { + if (title) { + // This function is only executed on browsers so we can disable this check. + // eslint-disable-next-line no-restricted-globals + window.document.title = title; + } +}; + +class BrowserHistory implements Router { + public $$type = 'ais.browser'; + /** + * Transforms a UI state into a title for the page. + */ + private readonly windowTitle?: BrowserHistoryArgs['windowTitle']; + /** + * Time in milliseconds before performing a write in the history. + * It prevents from adding too many entries in the history and + * makes the back button more usable. + * + * @default 400 + */ + private readonly writeDelay: Required< + BrowserHistoryArgs + >['writeDelay']; + /** + * Creates a full URL based on the route state. + * The storage adaptor maps all syncable keys to the query string of the URL. + */ + private readonly _createURL: Required< + BrowserHistoryArgs + >['createURL']; + /** + * Parses the URL into a route state. + * It should be symmetrical to `createURL`. + */ + private readonly parseURL: Required< + BrowserHistoryArgs + >['parseURL']; + /** + * Returns the location to store in the history. + * @default () => window.location + */ + private readonly getLocation: Required< + BrowserHistoryArgs + >['getLocation']; + + private writeTimer?: ReturnType; + private _onPopState?: (event: PopStateEvent) => void; + + /** + * Indicates if last action was back/forward in the browser. + */ + private inPopState: boolean = false; + + /** + * Indicates whether the history router is disposed or not. + */ + protected isDisposed: boolean = false; + + /** + * Indicates the window.history.length before the last call to + * window.history.pushState (called in `write`). + * It allows to determine if a `pushState` has been triggered elsewhere, + * and thus to prevent the `write` method from calling `pushState`. + */ + private latestAcknowledgedHistory: number = 0; + + private _start?: (onUpdate: () => void) => void; + private _dispose?: () => void; + private _push?: (url: string) => void; + private _cleanUrlOnDispose: boolean; + + /** + * Initializes a new storage provider that syncs the search state to the URL + * using web APIs (`window.location.pushState` and `onpopstate` event). + */ + public constructor({ + windowTitle, + writeDelay = 400, + createURL, + parseURL, + getLocation, + start, + dispose, + push, + cleanUrlOnDispose, + }: BrowserHistoryArgs) { + this.windowTitle = windowTitle; + this.writeTimer = undefined; + this.writeDelay = writeDelay; + this._createURL = createURL; + this.parseURL = parseURL; + this.getLocation = getLocation; + this._start = start; + this._dispose = dispose; + this._push = push; + this._cleanUrlOnDispose = + typeof cleanUrlOnDispose === 'undefined' ? true : cleanUrlOnDispose; + + if (__DEV__ && typeof cleanUrlOnDispose === 'undefined') { + // eslint-disable-next-line no-console + console.info(`Starting from the next major version, InstantSearch will not clean up the URL from active refinements when it is disposed. + +We recommend setting \`cleanUrlOnDispose\` to false to adopt this change today. +To stay with the current behaviour and remove this warning, set the option to true. + +See documentation: ${createDocumentationLink({ + name: 'history-router', + })}#widget-param-cleanurlondispose`); + } + + safelyRunOnBrowser(({ window: browserWindow }) => { + const title = this.windowTitle && this.windowTitle(this.read()); + setWindowTitle(title); + + this.latestAcknowledgedHistory = browserWindow.history.length; + }); + } + + /** + * Reads the URL and returns a syncable UI search state. + */ + public read(): TRouteState { + return this.parseURL({ qsModule: qs, location: this.getLocation() }); + } + + /** + * Pushes a search state into the URL. + */ + public write(routeState: TRouteState): void { + safelyRunOnBrowser(({ window: browserWindow }) => { + const url = this.createURL(routeState); + const title = this.windowTitle && this.windowTitle(routeState); + + if (this.writeTimer) { + clearTimeout(this.writeTimer); + } + + this.writeTimer = setTimeout(() => { + setWindowTitle(title); + + if (this.shouldWrite(url)) { + if (this._push) { + this._push(url); + } else { + browserWindow.history.pushState(routeState, title || '', url); + } + this.latestAcknowledgedHistory = browserWindow.history.length; + } + this.inPopState = false; + this.writeTimer = undefined; + }, this.writeDelay); + }); + } + + /** + * Sets a callback on the `onpopstate` event of the history API of the current page. + * It enables the URL sync to keep track of the changes. + */ + public onUpdate(callback: (routeState: TRouteState) => void): void { + if (this._start) { + this._start(() => { + callback(this.read()); + }); + } + + this._onPopState = () => { + if (this.writeTimer) { + clearTimeout(this.writeTimer); + this.writeTimer = undefined; + } + + this.inPopState = true; + + // We always read the state from the URL because the state of the history + // can be incorect in some cases (e.g. using React Router). + callback(this.read()); + }; + + safelyRunOnBrowser(({ window: browserWindow }) => { + browserWindow.addEventListener('popstate', this._onPopState!); + }); + } + + /** + * Creates a complete URL from a given syncable UI state. + * + * It always generates the full URL, not a relative one. + * This allows to handle cases like using a . + * See: https://github.com/algolia/instantsearch/issues/790 + */ + public createURL(routeState: TRouteState): string { + const url = this._createURL({ + qsModule: qs, + routeState, + location: this.getLocation(), + }); + + if (__DEV__) { + try { + // We just want to check if the URL is valid. + // eslint-disable-next-line no-new + new URL(url); + } catch (e) { + warning( + false, + `The URL returned by the \`createURL\` function is invalid. +Please make sure it returns an absolute URL to avoid issues, e.g: \`https://algolia.com/search?query=iphone\`.` + ); + } + } + + return url; + } + + /** + * Removes the event listener and cleans up the URL. + */ + public dispose(): void { + if (this._dispose) { + this._dispose(); + } + + this.isDisposed = true; + + safelyRunOnBrowser(({ window: browserWindow }) => { + if (this._onPopState) { + browserWindow.removeEventListener('popstate', this._onPopState); + } + }); + + if (this.writeTimer) { + clearTimeout(this.writeTimer); + } + + if (this._cleanUrlOnDispose) { + this.write({} as TRouteState); + } + } + + public start() { + this.isDisposed = false; + } + + private shouldWrite(url: string): boolean { + return safelyRunOnBrowser(({ window: browserWindow }) => { + // When disposed and the cleanUrlOnDispose is set to false, we do not want to write the URL. + if (this.isDisposed && !this._cleanUrlOnDispose) { + return false; + } + + // We do want to `pushState` if: + // - the router is not disposed, IS.js needs to update the URL + // OR + // - the last write was from InstantSearch.js + // (unlike a SPA, where it would have last written) + const lastPushWasByISAfterDispose = !( + this.isDisposed && + this.latestAcknowledgedHistory !== browserWindow.history.length + ); + + return ( + // When the last state change was through popstate, the IS.js state changes, + // but that should not write the URL. + !this.inPopState && + // When the previous pushState after dispose was by IS.js, we want to write the URL. + lastPushWasByISAfterDispose && + // When the URL is the same as the current one, we do not want to write it. + url !== browserWindow.location.href + ); + }); + } +} + +export default function historyRouter({ + createURL = ({ qsModule, routeState, location }) => { + const { protocol, hostname, port = '', pathname, hash } = location; + const queryString = qsModule.stringify(routeState); + const portWithPrefix = port === '' ? '' : `:${port}`; + + // IE <= 11 has no proper `location.origin` so we cannot rely on it. + if (!queryString) { + return `${protocol}//${hostname}${portWithPrefix}${pathname}${hash}`; + } + + return `${protocol}//${hostname}${portWithPrefix}${pathname}?${queryString}${hash}`; + }, + parseURL = ({ qsModule, location }) => { + // `qs` by default converts arrays with more than 20 items to an object. + // We want to avoid this because the data structure manipulated can therefore vary. + // Setting the limit to `100` seems a good number because the engine's default is 100 + // (it can go up to 1000 but it is very unlikely to select more than 100 items in the UI). + // + // Using an `arrayLimit` of `n` allows `n + 1` items. + // + // See: + // - https://github.com/ljharb/qs#parsing-arrays + // - https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/ + return qsModule.parse(location.search.slice(1), { + arrayLimit: 99, + }) as unknown as TRouteState; + }, + writeDelay = 400, + windowTitle, + getLocation = () => { + return safelyRunOnBrowser( + ({ window: browserWindow }) => browserWindow.location, + { + fallback: () => { + throw new Error( + 'You need to provide `getLocation` to the `history` router in environments where `window` does not exist.' + ); + }, + }); + }, + start, + dispose, + push, + cleanUrlOnDispose, +}: Partial> = {}): BrowserHistory { + return new BrowserHistory({ + createURL, + parseURL, + writeDelay, + windowTitle, + getLocation, + start, + dispose, + push, + cleanUrlOnDispose, + }); +} diff --git a/packages/instantsearch-core/src/lib/routers/index.ts b/packages/instantsearch-core/src/lib/routers/index.ts new file mode 100644 index 00000000000..344a7fccbce --- /dev/null +++ b/packages/instantsearch-core/src/lib/routers/index.ts @@ -0,0 +1,3 @@ +export { default as history } from './history'; +export { default as historyRouter } from './history'; +export type * from './history'; diff --git a/packages/instantsearch-core/src/lib/server.ts b/packages/instantsearch-core/src/lib/server.ts new file mode 100644 index 00000000000..99cfce33fc4 --- /dev/null +++ b/packages/instantsearch-core/src/lib/server.ts @@ -0,0 +1,150 @@ +import { walkIndex } from './utils'; + +import type { + SearchClient, + CompositionClient, + IndexWidget, + InitialResults, + InstantSearch, + SearchOptions, +} from '../types'; + +/** + * Waits for the results from the search instance to coordinate the next steps + * in `getServerState()`. + */ +export function waitForResults( + search: InstantSearch, + skipRecommend: boolean = false +): Promise { + const helper = search.mainHelper!; + + // Extract search parameters from the search client to use them + // later during hydration. + let requestParamsList: SearchOptions[]; + const client = helper.getClient(); + if (search.compositionID) { + helper.setClient({ + ...client, + search(query) { + requestParamsList = [query.requestBody.params]; + return (client as CompositionClient).search(query); + }, + } as CompositionClient); + } else { + helper.setClient({ + ...client, + search(queries) { + requestParamsList = queries.map(({ params }) => params); + return (client as SearchClient).search(queries); + }, + } as SearchClient); + } + + if (search._hasSearchWidget) { + if (search.compositionID) { + helper.searchWithComposition(); + } else { + helper.searchOnlyWithDerivedHelpers(); + } + } + !skipRecommend && search._hasRecommendWidget && helper.recommend(); + + return new Promise((resolve, reject) => { + let searchResultsReceived = !search._hasSearchWidget; + let recommendResultsReceived = !search._hasRecommendWidget || skipRecommend; + // All derived helpers resolve in the same tick so we're safe only relying + // on the first one. + helper.derivedHelpers[0].on('result', () => { + searchResultsReceived = true; + if (recommendResultsReceived) { + resolve(requestParamsList!); + } + }); + helper.derivedHelpers[0].on('recommend:result', () => { + recommendResultsReceived = true; + if (searchResultsReceived) { + resolve(requestParamsList!); + } + }); + + // However, we listen to errors that can happen on any derived helper because + // any error is critical. + helper.on('error', (error) => { + reject(error); + }); + search.on('error', (error) => { + reject(error); + }); + helper.derivedHelpers.forEach((derivedHelper) => + derivedHelper.on('error', (error) => { + reject(error); + }) + ); + }); +} + +/** + * Walks the InstantSearch root index to construct the initial results. + */ +export function getInitialResults( + rootIndex: IndexWidget, + /** + * Search parameters sent to the search client, + * returned by `waitForResults()`. + */ + requestParamsList?: SearchOptions[] +): InitialResults { + const initialResults: InitialResults = {}; + + let requestParamsIndex = 0; + walkIndex(rootIndex, (widget) => { + const searchResults = widget.getResults(); + const recommendResults = widget.getHelper()?.lastRecommendResults; + if (searchResults || recommendResults) { + const resultsCount = searchResults?._rawResults?.length || 0; + const requestParams = resultsCount + ? requestParamsList?.slice( + requestParamsIndex, + requestParamsIndex + resultsCount + ) + : []; + requestParamsIndex += resultsCount; + initialResults[widget.getIndexId()] = { + // We convert the Helper state to a plain object to pass parsable data + // structures from server to client. + ...(searchResults && { + state: { + ...searchResults._state, + clickAnalytics: requestParams?.[0]?.clickAnalytics, + userToken: requestParams?.[0]?.userToken, + }, + results: searchResults._rawResults, + ...(searchResults.feeds && + searchResults.feeds.length > 0 && { + compositionFeedsResults: searchResults.feeds.map((feed) => ({ + ...feed._rawResults[0], + feedID: feed.feedID, + })), + }), + }), + ...(recommendResults && { + recommendResults: { + // We have to stringify + parse because of some explicitly undefined values. + params: JSON.parse(JSON.stringify(recommendResults._state.params)), + results: recommendResults._rawResults, + }, + }), + ...(requestParams && { requestParams }), + }; + } + }); + + if (Object.keys(initialResults).length === 0) { + throw new Error( + 'The root index does not have any results. Make sure you have at least one widget that provides results.' + ); + } + + return initialResults; +} diff --git a/packages/instantsearch-core/src/lib/stateMappings/index.ts b/packages/instantsearch-core/src/lib/stateMappings/index.ts new file mode 100644 index 00000000000..208987fef25 --- /dev/null +++ b/packages/instantsearch-core/src/lib/stateMappings/index.ts @@ -0,0 +1,4 @@ +export { default as simple } from './simple'; +export { default as simpleStateMapping } from './simple'; +export { default as singleIndex } from './singleIndex'; +export { default as singleIndexStateMapping } from './singleIndex'; diff --git a/packages/instantsearch-core/src/lib/stateMappings/simple.ts b/packages/instantsearch-core/src/lib/stateMappings/simple.ts new file mode 100644 index 00000000000..3ddc3a21a3f --- /dev/null +++ b/packages/instantsearch-core/src/lib/stateMappings/simple.ts @@ -0,0 +1,45 @@ +import type { UiState, IndexUiState, StateMapping } from '../../types'; + +function getIndexStateWithoutConfigure( + uiState: TIndexUiState +): Omit { + const { configure, ...trackedUiState } = uiState; + return trackedUiState; +} + +// technically a URL could contain any key, since users provide it, +// which is why the input to this function is UiState, not something +// which excludes "configure" as this function does. +export default function simpleStateMapping< + TUiState extends UiState = UiState +>(): StateMapping { + return { + $$type: 'ais.simple', + + stateToRoute(uiState) { + return Object.keys(uiState).reduce( + (state, indexId) => ({ + ...state, + [indexId]: getIndexStateWithoutConfigure(uiState[indexId]), + }), + {} as TUiState + ); + }, + + routeToState(routeState = {} as TUiState) { + return Object.keys(routeState).reduce( + (state, indexId) => { + const indexState = routeState[indexId]; + if (typeof indexState !== 'object' || indexState === null) { + return state; + } + return { + ...state, + [indexId]: getIndexStateWithoutConfigure(indexState), + }; + }, + {} as TUiState + ); + }, + }; +} diff --git a/packages/instantsearch-core/src/lib/stateMappings/singleIndex.ts b/packages/instantsearch-core/src/lib/stateMappings/singleIndex.ts new file mode 100644 index 00000000000..0ad943877fb --- /dev/null +++ b/packages/instantsearch-core/src/lib/stateMappings/singleIndex.ts @@ -0,0 +1,26 @@ +import type { StateMapping, IndexUiState, UiState } from '../../types'; + +function getIndexStateWithoutConfigure( + uiState: TIndexUiState +): TIndexUiState { + const { configure, ...trackedUiState } = uiState; + return trackedUiState as TIndexUiState; +} + +export default function singleIndexStateMapping< + TUiState extends UiState = UiState +>( + indexName: keyof TUiState +): StateMapping { + return { + $$type: 'ais.singleIndex', + stateToRoute(uiState) { + return getIndexStateWithoutConfigure(uiState[indexName] || {}); + }, + routeToState(routeState = {} as TUiState[typeof indexName]) { + return { + [indexName]: getIndexStateWithoutConfigure(routeState), + } as unknown as TUiState; + }, + }; +} diff --git a/packages/instantsearch-core/src/lib/utils/addWidgetId.ts b/packages/instantsearch-core/src/lib/utils/addWidgetId.ts new file mode 100644 index 00000000000..c7618f8a1c3 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/addWidgetId.ts @@ -0,0 +1 @@ +export * from '../public/addWidgetId'; diff --git a/packages/instantsearch-core/src/lib/utils/capitalize.ts b/packages/instantsearch-core/src/lib/utils/capitalize.ts new file mode 100644 index 00000000000..e96d86aa34f --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/capitalize.ts @@ -0,0 +1 @@ +export * from '../public/capitalize'; diff --git a/packages/instantsearch-core/src/lib/utils/checkIndexUiState.ts b/packages/instantsearch-core/src/lib/utils/checkIndexUiState.ts new file mode 100644 index 00000000000..ff3a579a2d3 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/checkIndexUiState.ts @@ -0,0 +1 @@ +export * from '../public/checkIndexUiState'; diff --git a/packages/instantsearch-core/src/lib/utils/checkRendering.ts b/packages/instantsearch-core/src/lib/utils/checkRendering.ts new file mode 100644 index 00000000000..b37baf9c3a3 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/checkRendering.ts @@ -0,0 +1 @@ +export * from '../public/checkRendering'; diff --git a/packages/instantsearch-core/src/lib/utils/clearRefinements.ts b/packages/instantsearch-core/src/lib/utils/clearRefinements.ts new file mode 100644 index 00000000000..3cd238ea1ed --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/clearRefinements.ts @@ -0,0 +1 @@ +export * from '../public/clearRefinements'; diff --git a/packages/instantsearch-core/src/lib/utils/concatHighlightedParts.ts b/packages/instantsearch-core/src/lib/utils/concatHighlightedParts.ts new file mode 100644 index 00000000000..bcc1858cbf4 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/concatHighlightedParts.ts @@ -0,0 +1 @@ +export * from '../public/concatHighlightedParts'; diff --git a/packages/instantsearch-core/src/lib/utils/createConcurrentSafePromise.ts b/packages/instantsearch-core/src/lib/utils/createConcurrentSafePromise.ts new file mode 100644 index 00000000000..467e0e3d416 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/createConcurrentSafePromise.ts @@ -0,0 +1 @@ +export * from '../public/createConcurrentSafePromise'; diff --git a/packages/instantsearch-core/src/lib/utils/createSendEventForFacet.ts b/packages/instantsearch-core/src/lib/utils/createSendEventForFacet.ts new file mode 100644 index 00000000000..1de6878f67b --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/createSendEventForFacet.ts @@ -0,0 +1 @@ +export * from '../public/createSendEventForFacet'; diff --git a/packages/instantsearch-core/src/lib/utils/createSendEventForHits.ts b/packages/instantsearch-core/src/lib/utils/createSendEventForHits.ts new file mode 100644 index 00000000000..45cccbe2c2a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/createSendEventForHits.ts @@ -0,0 +1 @@ +export * from '../public/createSendEventForHits'; diff --git a/packages/instantsearch-core/src/lib/utils/debounce.ts b/packages/instantsearch-core/src/lib/utils/debounce.ts new file mode 100644 index 00000000000..29280a6aa28 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/debounce.ts @@ -0,0 +1 @@ +export * from '../public/debounce'; diff --git a/packages/instantsearch-core/src/lib/utils/defer.ts b/packages/instantsearch-core/src/lib/utils/defer.ts new file mode 100644 index 00000000000..e16f1798fa5 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/defer.ts @@ -0,0 +1 @@ +export * from '../public/defer'; diff --git a/packages/instantsearch-core/src/lib/utils/documentation.ts b/packages/instantsearch-core/src/lib/utils/documentation.ts new file mode 100644 index 00000000000..179817fd82f --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/documentation.ts @@ -0,0 +1 @@ +export * from '../public/documentation'; diff --git a/packages/instantsearch-core/src/lib/utils/escape-highlight.ts b/packages/instantsearch-core/src/lib/utils/escape-highlight.ts new file mode 100644 index 00000000000..8d5dea9be7a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/escape-highlight.ts @@ -0,0 +1 @@ +export * from '../public/escape-highlight'; diff --git a/packages/instantsearch-core/src/lib/utils/escape-html.ts b/packages/instantsearch-core/src/lib/utils/escape-html.ts new file mode 100644 index 00000000000..caa707814f8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/escape-html.ts @@ -0,0 +1 @@ +export * from '../public/escape-html'; diff --git a/packages/instantsearch-core/src/lib/utils/escapeFacetValue.ts b/packages/instantsearch-core/src/lib/utils/escapeFacetValue.ts new file mode 100644 index 00000000000..c0bac469777 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/escapeFacetValue.ts @@ -0,0 +1 @@ +export * from '../public/escapeFacetValue'; diff --git a/packages/instantsearch-core/src/lib/utils/find.ts b/packages/instantsearch-core/src/lib/utils/find.ts new file mode 100644 index 00000000000..0d89c391ded --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/find.ts @@ -0,0 +1 @@ +export * from '../public/find'; diff --git a/packages/instantsearch-core/src/lib/utils/findIndex.ts b/packages/instantsearch-core/src/lib/utils/findIndex.ts new file mode 100644 index 00000000000..1abe1c238da --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/findIndex.ts @@ -0,0 +1 @@ +export * from '../public/findIndex'; diff --git a/packages/instantsearch-core/src/lib/utils/flat.ts b/packages/instantsearch-core/src/lib/utils/flat.ts new file mode 100644 index 00000000000..4559759e968 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/flat.ts @@ -0,0 +1 @@ +export * from '../public/flat'; diff --git a/packages/instantsearch-core/src/lib/utils/geo-search.ts b/packages/instantsearch-core/src/lib/utils/geo-search.ts new file mode 100644 index 00000000000..e01f4a54dc9 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/geo-search.ts @@ -0,0 +1 @@ +export * from '../public/geo-search'; diff --git a/packages/instantsearch-core/src/lib/utils/getAlgoliaAgent.ts b/packages/instantsearch-core/src/lib/utils/getAlgoliaAgent.ts new file mode 100644 index 00000000000..30a2468485a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getAlgoliaAgent.ts @@ -0,0 +1 @@ +export * from '../public/getAlgoliaAgent'; diff --git a/packages/instantsearch-core/src/lib/utils/getAppIdAndApiKey.ts b/packages/instantsearch-core/src/lib/utils/getAppIdAndApiKey.ts new file mode 100644 index 00000000000..de929f9e2aa --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getAppIdAndApiKey.ts @@ -0,0 +1 @@ +export * from '../public/getAppIdAndApiKey'; diff --git a/packages/instantsearch-core/src/lib/utils/getHighlightFromSiblings.ts b/packages/instantsearch-core/src/lib/utils/getHighlightFromSiblings.ts new file mode 100644 index 00000000000..991736e994a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getHighlightFromSiblings.ts @@ -0,0 +1 @@ +export * from '../public/getHighlightFromSiblings'; diff --git a/packages/instantsearch-core/src/lib/utils/getHighlightedParts.ts b/packages/instantsearch-core/src/lib/utils/getHighlightedParts.ts new file mode 100644 index 00000000000..93cffbd957a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getHighlightedParts.ts @@ -0,0 +1 @@ +export * from '../public/getHighlightedParts'; diff --git a/packages/instantsearch-core/src/lib/utils/getObjectType.ts b/packages/instantsearch-core/src/lib/utils/getObjectType.ts new file mode 100644 index 00000000000..1e5c302bc70 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getObjectType.ts @@ -0,0 +1 @@ +export * from '../public/getObjectType'; diff --git a/packages/instantsearch-core/src/lib/utils/getPropertyByPath.ts b/packages/instantsearch-core/src/lib/utils/getPropertyByPath.ts new file mode 100644 index 00000000000..4a1eaab0dd6 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getPropertyByPath.ts @@ -0,0 +1 @@ +export * from '../public/getPropertyByPath'; diff --git a/packages/instantsearch-core/src/lib/utils/getRefinements.ts b/packages/instantsearch-core/src/lib/utils/getRefinements.ts new file mode 100644 index 00000000000..5e1c197f302 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getRefinements.ts @@ -0,0 +1 @@ +export * from '../public/getRefinements'; diff --git a/packages/instantsearch-core/src/lib/utils/getWidgetAttribute.ts b/packages/instantsearch-core/src/lib/utils/getWidgetAttribute.ts new file mode 100644 index 00000000000..7886d6e631a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/getWidgetAttribute.ts @@ -0,0 +1 @@ +export * from '../public/getWidgetAttribute'; diff --git a/packages/instantsearch-core/src/lib/utils/hits-absolute-position.ts b/packages/instantsearch-core/src/lib/utils/hits-absolute-position.ts new file mode 100644 index 00000000000..077bc50c12f --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/hits-absolute-position.ts @@ -0,0 +1 @@ +export * from '../public/hits-absolute-position'; diff --git a/packages/instantsearch-core/src/lib/utils/hits-query-id.ts b/packages/instantsearch-core/src/lib/utils/hits-query-id.ts new file mode 100644 index 00000000000..ab041017053 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/hits-query-id.ts @@ -0,0 +1 @@ +export * from '../public/hits-query-id'; diff --git a/packages/instantsearch-core/src/lib/utils/hydrateRecommendCache.ts b/packages/instantsearch-core/src/lib/utils/hydrateRecommendCache.ts new file mode 100644 index 00000000000..bc457636183 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/hydrateRecommendCache.ts @@ -0,0 +1 @@ +export * from '../public/hydrateRecommendCache'; diff --git a/packages/instantsearch-core/src/lib/utils/hydrateSearchClient.ts b/packages/instantsearch-core/src/lib/utils/hydrateSearchClient.ts new file mode 100644 index 00000000000..7ccc595a5e0 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/hydrateSearchClient.ts @@ -0,0 +1 @@ +export * from '../public/hydrateSearchClient'; diff --git a/packages/instantsearch-core/src/lib/utils/index.ts b/packages/instantsearch-core/src/lib/utils/index.ts new file mode 100644 index 00000000000..fbc0f3c30ff --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from '../public'; diff --git a/packages/instantsearch-core/src/lib/utils/isEqual.ts b/packages/instantsearch-core/src/lib/utils/isEqual.ts new file mode 100644 index 00000000000..2a8d438c8f0 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/isEqual.ts @@ -0,0 +1 @@ +export * from '../public/isEqual'; diff --git a/packages/instantsearch-core/src/lib/utils/isFacetRefined.ts b/packages/instantsearch-core/src/lib/utils/isFacetRefined.ts new file mode 100644 index 00000000000..7b88b688ab3 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/isFacetRefined.ts @@ -0,0 +1 @@ +export * from '../public/isFacetRefined'; diff --git a/packages/instantsearch-core/src/lib/utils/isFiniteNumber.ts b/packages/instantsearch-core/src/lib/utils/isFiniteNumber.ts new file mode 100644 index 00000000000..528da0cd86a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/isFiniteNumber.ts @@ -0,0 +1 @@ +export * from '../public/isFiniteNumber'; diff --git a/packages/instantsearch-core/src/lib/utils/isIndexWidget.ts b/packages/instantsearch-core/src/lib/utils/isIndexWidget.ts new file mode 100644 index 00000000000..f4d3d1d688a --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/isIndexWidget.ts @@ -0,0 +1 @@ +export * from '../public/isIndexWidget'; diff --git a/packages/instantsearch-core/src/lib/utils/isPlainObject.ts b/packages/instantsearch-core/src/lib/utils/isPlainObject.ts new file mode 100644 index 00000000000..6b621969408 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/isPlainObject.ts @@ -0,0 +1 @@ +export * from '../public/isPlainObject'; diff --git a/packages/instantsearch-core/src/lib/utils/isSpecialClick.ts b/packages/instantsearch-core/src/lib/utils/isSpecialClick.ts new file mode 100644 index 00000000000..83ec89866bc --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/isSpecialClick.ts @@ -0,0 +1 @@ +export * from '../public/isSpecialClick'; diff --git a/packages/instantsearch-core/src/lib/utils/isTwoPassWidget.ts b/packages/instantsearch-core/src/lib/utils/isTwoPassWidget.ts new file mode 100644 index 00000000000..34221ea7eb7 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/isTwoPassWidget.ts @@ -0,0 +1 @@ +export * from '../public/isTwoPassWidget'; diff --git a/packages/instantsearch-core/src/lib/utils/logger.ts b/packages/instantsearch-core/src/lib/utils/logger.ts new file mode 100644 index 00000000000..6100b9ed1f3 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/logger.ts @@ -0,0 +1 @@ +export * from '../public/logger'; diff --git a/packages/instantsearch-core/src/lib/utils/mergeSearchParameters.ts b/packages/instantsearch-core/src/lib/utils/mergeSearchParameters.ts new file mode 100644 index 00000000000..cb845ea55e4 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/mergeSearchParameters.ts @@ -0,0 +1 @@ +export * from '../public/mergeSearchParameters'; diff --git a/packages/instantsearch-core/src/lib/utils/noop.ts b/packages/instantsearch-core/src/lib/utils/noop.ts new file mode 100644 index 00000000000..ebebf8229ed --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/noop.ts @@ -0,0 +1 @@ +export * from '../public/noop'; diff --git a/packages/instantsearch-core/src/lib/utils/omit.ts b/packages/instantsearch-core/src/lib/utils/omit.ts new file mode 100644 index 00000000000..b6a32a94577 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/omit.ts @@ -0,0 +1 @@ +export * from '../public/omit'; diff --git a/packages/instantsearch-core/src/lib/utils/range.ts b/packages/instantsearch-core/src/lib/utils/range.ts new file mode 100644 index 00000000000..7b59a7322f2 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/range.ts @@ -0,0 +1 @@ +export * from '../public/range'; diff --git a/packages/instantsearch-core/src/lib/utils/render-args.ts b/packages/instantsearch-core/src/lib/utils/render-args.ts new file mode 100644 index 00000000000..bcd8214fe36 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/render-args.ts @@ -0,0 +1 @@ +export * from '../public/render-args'; diff --git a/packages/instantsearch-core/src/lib/utils/resolveSearchParameters.ts b/packages/instantsearch-core/src/lib/utils/resolveSearchParameters.ts new file mode 100644 index 00000000000..762da007d6e --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/resolveSearchParameters.ts @@ -0,0 +1 @@ +export * from '../public/resolveSearchParameters'; diff --git a/packages/instantsearch-core/src/lib/utils/reverseHighlightedParts.ts b/packages/instantsearch-core/src/lib/utils/reverseHighlightedParts.ts new file mode 100644 index 00000000000..50b07dd0dbf --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/reverseHighlightedParts.ts @@ -0,0 +1 @@ +export * from '../public/reverseHighlightedParts'; diff --git a/packages/instantsearch-core/src/lib/utils/safelyRunOnBrowser.ts b/packages/instantsearch-core/src/lib/utils/safelyRunOnBrowser.ts new file mode 100644 index 00000000000..b9eb80be6ac --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/safelyRunOnBrowser.ts @@ -0,0 +1 @@ +export * from '../public/safelyRunOnBrowser'; diff --git a/packages/instantsearch-core/src/lib/utils/sendChatMessageFeedback.ts b/packages/instantsearch-core/src/lib/utils/sendChatMessageFeedback.ts new file mode 100644 index 00000000000..607e574e6d6 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/sendChatMessageFeedback.ts @@ -0,0 +1 @@ +export * from '../public/sendChatMessageFeedback'; diff --git a/packages/instantsearch-core/src/lib/utils/serializer.ts b/packages/instantsearch-core/src/lib/utils/serializer.ts new file mode 100644 index 00000000000..f399d574a36 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/serializer.ts @@ -0,0 +1 @@ +export * from '../public/serializer'; diff --git a/packages/instantsearch-core/src/lib/utils/setIndexHelperState.ts b/packages/instantsearch-core/src/lib/utils/setIndexHelperState.ts new file mode 100644 index 00000000000..c3929f455e8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/setIndexHelperState.ts @@ -0,0 +1 @@ +export * from '../public/setIndexHelperState'; diff --git a/packages/instantsearch-core/src/lib/utils/toArray.ts b/packages/instantsearch-core/src/lib/utils/toArray.ts new file mode 100644 index 00000000000..44ab129d7a8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/toArray.ts @@ -0,0 +1 @@ +export * from '../public/toArray'; diff --git a/packages/instantsearch-core/src/lib/utils/typedObject.ts b/packages/instantsearch-core/src/lib/utils/typedObject.ts new file mode 100644 index 00000000000..6783e38773e --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/typedObject.ts @@ -0,0 +1 @@ +export * from '../public/typedObject'; diff --git a/packages/instantsearch-core/src/lib/utils/uniq.ts b/packages/instantsearch-core/src/lib/utils/uniq.ts new file mode 100644 index 00000000000..4bdc2b192d7 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/uniq.ts @@ -0,0 +1 @@ +export * from '../public/uniq'; diff --git a/packages/instantsearch-core/src/lib/utils/uuid.ts b/packages/instantsearch-core/src/lib/utils/uuid.ts new file mode 100644 index 00000000000..57f0685a682 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/uuid.ts @@ -0,0 +1 @@ +export * from '../public/uuid'; diff --git a/packages/instantsearch-core/src/lib/utils/walkIndex.ts b/packages/instantsearch-core/src/lib/utils/walkIndex.ts new file mode 100644 index 00000000000..09f471713e8 --- /dev/null +++ b/packages/instantsearch-core/src/lib/utils/walkIndex.ts @@ -0,0 +1 @@ +export * from '../public/walkIndex'; diff --git a/packages/instantsearch-core/src/lib/voiceSearchHelper/index.ts b/packages/instantsearch-core/src/lib/voiceSearchHelper/index.ts new file mode 100644 index 00000000000..f33514b4a93 --- /dev/null +++ b/packages/instantsearch-core/src/lib/voiceSearchHelper/index.ts @@ -0,0 +1,131 @@ +// `SpeechRecognition` is an API used on the browser so we can safely disable +// the `window` check. +/* eslint-disable no-restricted-globals */ +/* global SpeechRecognition SpeechRecognitionEvent */ +import type { + CreateVoiceSearchHelper, + Status, + VoiceListeningState, +} from './types'; + +const createVoiceSearchHelper: CreateVoiceSearchHelper = + function createVoiceSearchHelper({ + searchAsYouSpeak, + language, + onQueryChange, + onStateChange, + }) { + const SpeechRecognitionAPI: new () => SpeechRecognition = + (window as any).webkitSpeechRecognition || + (window as any).SpeechRecognition; + const getDefaultState = (status: Status): VoiceListeningState => ({ + status, + transcript: '', + isSpeechFinal: false, + errorCode: undefined, + }); + let state: VoiceListeningState = getDefaultState('initial'); + let recognition: SpeechRecognition | undefined; + + const isBrowserSupported = (): boolean => Boolean(SpeechRecognitionAPI); + + const isListening = (): boolean => + state.status === 'askingPermission' || + state.status === 'waiting' || + state.status === 'recognizing'; + + const setState = (newState: Partial = {}): void => { + state = { ...state, ...newState }; + onStateChange(); + }; + + const getState = (): VoiceListeningState => state; + + const resetState = (status: Status = 'initial'): void => { + setState(getDefaultState(status)); + }; + + const onStart = (): void => { + setState({ + status: 'waiting', + }); + }; + + const onError = (event: Event): void => { + setState({ status: 'error', errorCode: (event as any).error }); + }; + + const onResult = (event: SpeechRecognitionEvent): void => { + setState({ + status: 'recognizing', + transcript: + (event.results[0] && + event.results[0][0] && + event.results[0][0].transcript) || + '', + isSpeechFinal: event.results[0] && event.results[0].isFinal, + }); + if (searchAsYouSpeak && state.transcript) { + onQueryChange(state.transcript); + } + }; + + const onEnd = (): void => { + if (!state.errorCode && state.transcript && !searchAsYouSpeak) { + onQueryChange(state.transcript); + } + if (state.status !== 'error') { + setState({ status: 'finished' }); + } + }; + + const startListening = (): void => { + recognition = new SpeechRecognitionAPI(); + if (!recognition) { + return; + } + resetState('askingPermission'); + recognition.interimResults = true; + + if (language) { + recognition.lang = language; + } + + recognition.addEventListener('start', onStart); + recognition.addEventListener('error', onError); + recognition.addEventListener('result', onResult); + recognition.addEventListener('end', onEnd); + recognition.start(); + }; + + const dispose = (): void => { + if (!recognition) { + return; + } + recognition.stop(); + recognition.removeEventListener('start', onStart); + recognition.removeEventListener('error', onError); + recognition.removeEventListener('result', onResult); + recognition.removeEventListener('end', onEnd); + recognition = undefined; + }; + + const stopListening = (): void => { + dispose(); + // Because `dispose` removes event listeners, `end` listener is not called. + // So we're setting the `status` as `finished` here. + // If we don't do it, it will be still `waiting` or `recognizing`. + resetState('finished'); + }; + + return { + getState, + isBrowserSupported, + isListening, + startListening, + stopListening, + dispose, + }; + }; + +export default createVoiceSearchHelper; diff --git a/packages/instantsearch-core/src/lib/voiceSearchHelper/types.ts b/packages/instantsearch-core/src/lib/voiceSearchHelper/types.ts new file mode 100644 index 00000000000..936a243c3f7 --- /dev/null +++ b/packages/instantsearch-core/src/lib/voiceSearchHelper/types.ts @@ -0,0 +1,34 @@ +export type Status = + | 'initial' + | 'askingPermission' + | 'waiting' + | 'recognizing' + | 'finished' + | 'error'; + +export type VoiceListeningState = { + status: Status; + transcript: string; + isSpeechFinal: boolean; + errorCode?: string; +}; + +export type VoiceSearchHelperParams = { + searchAsYouSpeak: boolean; + language?: string; + onQueryChange: (query: string) => void; + onStateChange: () => void; +}; + +export type VoiceSearchHelper = { + getState: () => VoiceListeningState; + isBrowserSupported: () => boolean; + isListening: () => boolean; + startListening: () => void; + stopListening: () => void; + dispose: () => void; +}; + +export type CreateVoiceSearchHelper = ( + params: VoiceSearchHelperParams +) => VoiceSearchHelper; diff --git a/packages/instantsearch-core/src/middlewares/createInsightsMiddleware.ts b/packages/instantsearch-core/src/middlewares/createInsightsMiddleware.ts new file mode 100644 index 00000000000..3d7b64ef2e2 --- /dev/null +++ b/packages/instantsearch-core/src/middlewares/createInsightsMiddleware.ts @@ -0,0 +1,487 @@ +import { getInsightsAnonymousUserTokenInternal } from '../helpers'; +import { + warning, + noop, + getAppIdAndApiKey, + find, + safelyRunOnBrowser, +} from '../lib/utils'; +import { createUUID } from '../lib/utils/uuid'; + +import type { + InsightsClient, + InsightsEvent as _InsightsEvent, + InsightsMethod, + InsightsMethodMap, + InternalMiddleware, + InstantSearch, +} from '../types'; +import type { + AlgoliaSearchHelper, + PlainSearchParameters, +} from 'algoliasearch-helper'; + +type ProvidedInsightsClient = InsightsClient | null | undefined; + +export type InsightsEvent = + _InsightsEvent; + +export type InsightsProps< + TInsightsClient extends ProvidedInsightsClient = ProvidedInsightsClient +> = { + insightsClient?: TInsightsClient; + insightsInitParams?: Partial; + onEvent?: (event: InsightsEvent, insightsClient: TInsightsClient) => void; + /** + * @internal indicator for the default insights middleware + */ + $$internal?: boolean; + /** + * @internal indicator for sending the `clickAnalytics` search parameter + */ + $$automatic?: boolean; +}; + +const ALGOLIA_INSIGHTS_VERSION = '2.17.2'; +const ALGOLIA_INSIGHTS_SRC = `https://cdn.jsdelivr.net/npm/search-insights@${ALGOLIA_INSIGHTS_VERSION}/dist/search-insights.min.js`; + +export type InsightsClientWithGlobals = InsightsClient & { + shouldAddScript?: boolean; + version?: string; +}; + +export type CreateInsightsMiddleware = typeof createInsightsMiddleware; + +export function createInsightsMiddleware< + TInsightsClient extends ProvidedInsightsClient +>(props: InsightsProps = {}): InternalMiddleware { + const { + insightsClient: _insightsClient, + insightsInitParams, + onEvent, + $$internal = false, + $$automatic = false, + } = props; + + let potentialInsightsClient: ProvidedInsightsClient = _insightsClient; + + if (!_insightsClient && _insightsClient !== null) { + safelyRunOnBrowser(({ window }: { window: any }) => { + const pointer = window.AlgoliaAnalyticsObject || 'aa'; + + if (typeof pointer === 'string') { + potentialInsightsClient = window[pointer]; + } + + if (!potentialInsightsClient) { + window.AlgoliaAnalyticsObject = pointer; + + if (!window[pointer]) { + window[pointer] = (...args: any[]) => { + if (!window[pointer].queue) { + window[pointer].queue = []; + } + window[pointer].queue.push(args); + }; + window[pointer].version = ALGOLIA_INSIGHTS_VERSION; + window[pointer].shouldAddScript = true; + } + + potentialInsightsClient = window[pointer]; + } + }); + } + // if still no insightsClient was found, we use a noop + const insightsClient: InsightsClientWithGlobals = + potentialInsightsClient || noop; + + return ({ instantSearchInstance }) => { + // remove existing default insights middleware + // user-provided insights middleware takes precedence + const existingInsightsMiddlewares = instantSearchInstance.middleware + .filter( + (m) => m.instance.$$type === 'ais.insights' && m.instance.$$internal + ) + .map((m) => m.creator); + instantSearchInstance.unuse(...existingInsightsMiddlewares); + + const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); + + // search-insights.js also throws an error so dev-only clarification is sufficient + warning( + Boolean(appId && apiKey), + 'could not extract Algolia credentials from searchClient in insights middleware.' + ); + + let queuedInitParams: Partial | undefined = + undefined; + let queuedUserToken: string | undefined = undefined; + let userTokenBeforeInit: string | undefined = undefined; + + const { queue } = insightsClient; + + if (Array.isArray(queue)) { + // Context: The umd build of search-insights is asynchronously loaded by the snippet. + // + // When user calls `aa('setUserToken', 'my-user-token')` before `search-insights` is loaded, + // ['setUserToken', 'my-user-token'] gets stored in `aa.queue`. + // Whenever `search-insights` is finally loaded, it will process the queue. + // + // But here's the reason why we handle it here: + // At this point, even though `search-insights` is not loaded yet, + // we still want to read the token from the queue. + // Otherwise, the first search call will be fired without the token. + [queuedUserToken, queuedInitParams] = ['setUserToken', 'init'].map( + (key) => { + const [, value] = + find(queue.slice().reverse(), ([method]) => method === key) || []; + + return value as any as NonNullable; + } + ); + } + + // If user called `aa('setUserToken')` before creating the Insights middleware, + // we temporarily store the token and set it later on. + // + // Otherwise, the `init` call might override them with anonymous user token. + insightsClient('getUserToken', null, (_error, userToken) => { + userTokenBeforeInit = normalizeUserToken(userToken); + }); + + // Only `init` if the `insightsInitParams` option is passed or + // if the `insightsClient` version doesn't supports optional `init` calling. + if (insightsInitParams || !isModernInsightsClient(insightsClient)) { + insightsClient('init', { + appId, + apiKey, + partial: true, + ...insightsInitParams, + }); + } + + let initialParameters: PlainSearchParameters; + let helper: AlgoliaSearchHelper; + + return { + $$type: 'ais.insights', + $$internal, + $$automatic, + onStateChange() {}, + subscribe() { + if (!insightsClient.shouldAddScript) return; + + const errorMessage = + '[insights middleware]: could not load search-insights.js. Please load it manually following https://alg.li/insights-init'; + + try { + const script = document.createElement('script'); + script.async = true; + script.src = ALGOLIA_INSIGHTS_SRC; + script.onerror = () => { + instantSearchInstance.emit('error', new Error(errorMessage)); + }; + document.body.appendChild(script); + insightsClient.shouldAddScript = false; + } catch (cause) { + insightsClient.shouldAddScript = false; + instantSearchInstance.emit('error', new Error(errorMessage)); + } + }, + started() { + insightsClient('addAlgoliaAgent', 'insights-middleware'); + + helper = instantSearchInstance.mainHelper!; + + const { queue: queueAtStart } = insightsClient; + + if (Array.isArray(queueAtStart)) { + [queuedUserToken, queuedInitParams] = ['setUserToken', 'init'].map( + (key) => { + const [, value] = + find( + queueAtStart.slice().reverse(), + ([method]) => method === key + ) || []; + + return value; + } + ); + } + + initialParameters = getInitialParameters(instantSearchInstance); + + // We don't want to force clickAnalytics when the insights is enabled from the search response. + // This means we don't enable insights for indices that don't opt in + if (!$$automatic) { + helper.overrideStateWithoutTriggeringChangeEvent({ + ...helper.state, + clickAnalytics: true, + }); + } + + if (!$$internal) { + instantSearchInstance.scheduleSearch(); + } + + const setUserTokenToSearch = ( + userToken?: string | number, + immediate = false + ) => { + const normalizedUserToken = normalizeUserToken(userToken); + + if (!normalizedUserToken) { + return; + } + + const existingToken = (helper.state as PlainSearchParameters) + .userToken; + + function applyToken() { + helper.overrideStateWithoutTriggeringChangeEvent({ + ...helper.state, + userToken: normalizedUserToken, + }); + + if (existingToken && existingToken !== userToken) { + instantSearchInstance.scheduleSearch(); + } + } + + // Delay the token application to the next render cycle + if (!immediate) { + setTimeout(applyToken, 0); + } else { + applyToken(); + } + }; + + function setUserToken(token: string | number) { + setUserTokenToSearch(token, true); + insightsClient('setUserToken', token); + } + + let anonymousUserToken: string | undefined = undefined; + const anonymousTokenFromInsights = + getInsightsAnonymousUserTokenInternal(); + if (anonymousTokenFromInsights) { + // When `aa('init', { ... })` is called, it creates an anonymous user token in cookie. + // We can set it as userToken on instantsearch and insights. If it's not set as an insights + // userToken before a sendEvent, insights automatically generates a new anonymous token, + // causing a state change and an unnecessary query on instantsearch. + anonymousUserToken = anonymousTokenFromInsights; + } else { + const token = `anonymous-${createUUID()}`; + anonymousUserToken = token; + } + + let userTokenFromInit: string | undefined; + + // With SSR, the token could be be set on the state. We make sure + // that insights is in sync with that token since, there is no + // insights lib on the server. + const tokenFromSearchParameters = initialParameters.userToken; + + // When the first query is sent, the token is possibly not yet set by + // the insights onChange callbacks (if insights isn't yet loaded). + // It is explicitly being set here so that the first query has the + // initial tokens set and ensure a second query isn't automatically + // made when the onChange callback actually changes the state. + if (insightsInitParams?.userToken) { + userTokenFromInit = insightsInitParams.userToken; + } + + if (userTokenFromInit) { + setUserToken(userTokenFromInit); + } else if (tokenFromSearchParameters) { + setUserToken(tokenFromSearchParameters); + } else if (userTokenBeforeInit) { + setUserToken(userTokenBeforeInit); + } else if (queuedUserToken) { + setUserToken(queuedUserToken); + } else if (anonymousUserToken) { + setUserToken(anonymousUserToken); + + if (insightsInitParams?.useCookie || queuedInitParams?.useCookie) { + saveTokenAsCookie( + anonymousUserToken, + insightsInitParams?.cookieDuration || + queuedInitParams?.cookieDuration + ); + } + } + + // This updates userToken which is set explicitly by `aa('setUserToken', userToken)` + insightsClient( + 'onUserTokenChange', + (token) => setUserTokenToSearch(token, true), + { + immediate: true, + } + ); + + type InsightsClientWithLocalCredentials = < + TMethod extends InsightsMethod + >( + method: TMethod, + payload: InsightsMethodMap[TMethod][0][0] + ) => void; + + let insightsClientWithLocalCredentials = + insightsClient as InsightsClientWithLocalCredentials; + + if (isModernInsightsClient(insightsClient)) { + insightsClientWithLocalCredentials = (method, payload) => { + const [latestAppId, latestApiKey] = getAppIdAndApiKey( + instantSearchInstance.client + ); + const extraParams = { + headers: { + 'X-Algolia-Application-Id': latestAppId, + 'X-Algolia-API-Key': latestApiKey, + }, + }; + + // @ts-ignore we are calling this only when we know that the client actually is correct + return insightsClient(method, payload, extraParams); + }; + } + + const viewedObjectIDs = new Set(); + let lastQueryId: string | undefined; + instantSearchInstance.mainHelper!.derivedHelpers[0].on( + 'result', + ({ results }) => { + if ( + results && + (!results.queryID || results.queryID !== lastQueryId) + ) { + lastQueryId = results.queryID; + viewedObjectIDs.clear(); + } + } + ); + + instantSearchInstance.sendEventToInsights = (event: InsightsEvent) => { + if (onEvent) { + onEvent( + event, + insightsClientWithLocalCredentials as TInsightsClient + ); + } else if (event.insightsMethod) { + if (event.insightsMethod === 'viewedObjectIDs') { + const payload = event.payload as { + objectIDs: string[]; + }; + const difference = payload.objectIDs.filter( + (objectID) => !viewedObjectIDs.has(objectID) + ); + if (difference.length === 0) { + return; + } + difference.forEach((objectID) => viewedObjectIDs.add(objectID)); + payload.objectIDs = difference; + } + + // Source is used to differentiate events sent by instantsearch from those sent manually. + (event.payload as any).algoliaSource = ['instantsearch']; + if ($$automatic) { + (event.payload as any).algoliaSource.push( + 'instantsearch-automatic' + ); + } + if (event.eventModifier === 'internal') { + (event.payload as any).algoliaSource.push( + 'instantsearch-internal' + ); + } + + insightsClientWithLocalCredentials( + event.insightsMethod, + event.payload + ); + + warning( + Boolean((helper.state as PlainSearchParameters).userToken), + ` +Cannot send event to Algolia Insights because \`userToken\` is not set. + +See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-further/send-insights-events/js/#setting-the-usertoken +` + ); + } else { + warning( + false, + 'Cannot send event to Algolia Insights because `insightsMethod` option is missing.' + ); + } + }; + }, + unsubscribe() { + insightsClient('onUserTokenChange', undefined); + instantSearchInstance.sendEventToInsights = noop; + if (helper && initialParameters) { + helper.overrideStateWithoutTriggeringChangeEvent({ + ...helper.state, + ...initialParameters, + }); + + instantSearchInstance.scheduleSearch(); + } + }, + }; + }; +} + +function getInitialParameters( + instantSearchInstance: InstantSearch +): PlainSearchParameters { + // in SSR, the initial state we use in this domain is set on the main index + const stateFromInitialResults = + instantSearchInstance._initialResults?.[instantSearchInstance.indexName] + ?.state || {}; + + const stateFromHelper = instantSearchInstance.mainHelper!.state; + + return { + userToken: stateFromInitialResults.userToken || stateFromHelper.userToken, + clickAnalytics: + stateFromInitialResults.clickAnalytics || stateFromHelper.clickAnalytics, + }; +} + +function saveTokenAsCookie(token: string, cookieDuration?: number) { + const MONTH = 30 * 24 * 60 * 60 * 1000; + const d = new Date(); + d.setTime(d.getTime() + (cookieDuration || MONTH * 6)); + const expires = `expires=${d.toUTCString()}`; + document.cookie = `_ALGOLIA=${token};${expires};path=/`; +} + +/** + * Determines if a given insights `client` supports the optional call to `init` + * and the ability to set credentials via extra parameters when sending events. + */ +function isModernInsightsClient(client: InsightsClientWithGlobals): boolean { + const [major, minor] = (client.version || '').split('.').map(Number); + + /* eslint-disable instantsearch/naming-convention */ + const v3 = major >= 3; + const v2_6 = major === 2 && minor >= 6; + const v1_10 = major === 1 && minor >= 10; + /* eslint-enable instantsearch/naming-convention */ + + return v3 || v2_6 || v1_10; +} + +/** + * While `search-insights` supports both string and number user tokens, + * the Search API only accepts strings. This function normalizes the user token. + */ +function normalizeUserToken(userToken?: string | number): string | undefined { + if (!userToken) { + return undefined; + } + + return typeof userToken === 'number' ? userToken.toString() : userToken; +} diff --git a/packages/instantsearch-core/src/middlewares/createMetadataMiddleware.ts b/packages/instantsearch-core/src/middlewares/createMetadataMiddleware.ts new file mode 100644 index 00000000000..9f41696b349 --- /dev/null +++ b/packages/instantsearch-core/src/middlewares/createMetadataMiddleware.ts @@ -0,0 +1,139 @@ +import { + createInitArgs, + getAlgoliaAgent, + isIndexWidget, + safelyRunOnBrowser, +} from '../lib/utils'; + +import type { + InstantSearch, + InternalMiddleware, + Widget, + IndexWidget, +} from '../types'; + +type WidgetMetadata = + | { + type: string | undefined; + widgetType: string | undefined; + params: string[]; + } + | { + type: string; + middleware: true; + internal: boolean; + }; + +type Payload = { + widgets: WidgetMetadata[]; + ua?: string; +}; + +function extractWidgetPayload( + widgets: Array, + instantSearchInstance: InstantSearch, + payload: Payload +) { + const initOptions = createInitArgs( + instantSearchInstance, + instantSearchInstance.mainIndex, + instantSearchInstance._initialUiState + ); + + widgets.forEach((widget) => { + let widgetParams: Record = {}; + + if (widget.getWidgetRenderState) { + const renderState = widget.getWidgetRenderState(initOptions); + + if (renderState && renderState.widgetParams) { + // casting, as we just earlier checked widgetParams exists, and thus an object + widgetParams = renderState.widgetParams as Record; + } + } + + // 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, + widgetType: widget.$$widgetType, + params, + }); + + if (isIndexWidget(widget)) { + extractWidgetPayload( + widget.getWidgets(), + instantSearchInstance, + payload + ); + } + }); +} + +export function isMetadataEnabled() { + return safelyRunOnBrowser( + ({ window }) => + window.navigator?.userAgent?.indexOf('Algolia Crawler') > -1, + { fallback: () => false } + ); +} + +/** + * Exposes the metadata of mounted widgets in a custom + * `` tag. The metadata per widget is: + * - applied parameters + * - widget name + * - connector name + */ +export function createMetadataMiddleware({ + $$internal = false, +}: { + $$internal?: boolean; +} = {}): InternalMiddleware { + return ({ instantSearchInstance }) => { + const payload: Payload = { + widgets: [], + }; + const payloadContainer = document.createElement('meta'); + const refNode = document.querySelector('head')!; + payloadContainer.name = 'instantsearch:widgets'; + + return { + $$type: 'ais.metadata', + $$internal, + onStateChange() {}, + subscribe() { + // using setTimeout here to delay extraction until widgets have been added in a tick (e.g. Vue) + setTimeout(() => { + payload.ua = getAlgoliaAgent(instantSearchInstance.client); + + extractWidgetPayload( + instantSearchInstance.mainIndex.getWidgets(), + instantSearchInstance, + payload + ); + + instantSearchInstance.middleware.forEach((middleware) => + payload.widgets.push({ + middleware: true, + type: middleware.instance.$$type, + internal: middleware.instance.$$internal, + }) + ); + + payloadContainer.content = JSON.stringify(payload); + refNode.appendChild(payloadContainer); + }, 0); + }, + + started() {}, + + unsubscribe() { + payloadContainer.remove(); + }, + }; + }; +} diff --git a/packages/instantsearch-core/src/middlewares/createRouterMiddleware.ts b/packages/instantsearch-core/src/middlewares/createRouterMiddleware.ts new file mode 100644 index 00000000000..acb88a46215 --- /dev/null +++ b/packages/instantsearch-core/src/middlewares/createRouterMiddleware.ts @@ -0,0 +1,126 @@ +import historyRouter from '../lib/routers/history'; +import simpleStateMapping from '../lib/stateMappings/simple'; +import { isEqual, warning } from '../lib/utils'; + +import type { + Router, + StateMapping, + UiState, + InternalMiddleware, + CreateURL, +} from '../types'; + +export type RouterProps< + TUiState extends UiState = UiState, + TRouteState = TUiState +> = { + router?: Router; + // ideally stateMapping should be required if TRouteState is given, + // but there's no way to check if a generic is provided or the default value. + stateMapping?: StateMapping; + /** + * @internal indicator for the default middleware + */ + $$internal?: boolean; +}; + +export const createRouterMiddleware = < + TUiState extends UiState = UiState, + TRouteState = TUiState +>( + props: RouterProps = {} +): InternalMiddleware => { + const { + router = historyRouter(), + // We have to cast simpleStateMapping as a StateMapping. + // this is needed because simpleStateMapping is StateMapping. + // While it's only used when UiState and RouteState are the same, unfortunately + // TypeScript still considers them separate types. + stateMapping = simpleStateMapping() as unknown as StateMapping< + TUiState, + TRouteState + >, + $$internal = false, + } = props; + + return ({ instantSearchInstance }) => { + function topLevelCreateURL(nextState: TUiState) { + const previousUiState = + // If only the mainIndex is initialized, we don't yet know what other + // index widgets are used. Therefore we fall back to the initialUiState. + // We can't indiscriminately use the initialUiState because then we + // reintroduce state that was changed by the user. + // When there are no widgets, we are sure the user can't yet have made + // any changes. + instantSearchInstance.mainIndex.getWidgets().length === 0 + ? (instantSearchInstance._initialUiState as TUiState) + : instantSearchInstance.mainIndex.getWidgetUiState( + {} as TUiState + ); + + const uiState: TUiState = Object.keys(nextState).reduce( + (acc, indexId) => ({ + ...acc, + [indexId]: nextState[indexId], + }), + previousUiState + ); + + const route = stateMapping.stateToRoute(uiState); + + return router.createURL(route); + } + + // casting to UiState here to keep createURL unaware of custom UiState + // (as long as it's an object, it's ok) + instantSearchInstance._createURL = topLevelCreateURL as CreateURL; + + let lastRouteState: TRouteState | undefined = undefined; + + const initialUiState = instantSearchInstance._initialUiState; + + return { + $$type: `ais.router({router:${ + router.$$type || '__unknown__' + }, stateMapping:${stateMapping.$$type || '__unknown__'}})`, + $$internal, + onStateChange({ uiState }) { + const routeState = stateMapping.stateToRoute(uiState); + + if ( + lastRouteState === undefined || + !isEqual(lastRouteState, routeState) + ) { + router.write(routeState); + lastRouteState = routeState; + } + }, + + subscribe() { + warning( + Object.keys(initialUiState).length === 0, + 'Using `initialUiState` together with routing is not recommended. The `initialUiState` will be overwritten by the URL parameters.' + ); + + instantSearchInstance._initialUiState = { + ...initialUiState, + ...stateMapping.routeToState(router.read()), + }; + + router.onUpdate((route) => { + if (instantSearchInstance.mainIndex.getWidgets().length > 0) { + instantSearchInstance.setUiState(stateMapping.routeToState(route)); + } + }); + }, + + started() { + router.start?.(); + }, + + unsubscribe() { + router.dispose(); + }, + }; + }; +}; diff --git a/packages/instantsearch-core/src/middlewares/index.ts b/packages/instantsearch-core/src/middlewares/index.ts new file mode 100644 index 00000000000..51d8b18c38d --- /dev/null +++ b/packages/instantsearch-core/src/middlewares/index.ts @@ -0,0 +1,3 @@ +export * from './createInsightsMiddleware'; +export * from './createMetadataMiddleware'; +export * from './createRouterMiddleware'; diff --git a/packages/instantsearch-core/src/types/algoliasearch.ts b/packages/instantsearch-core/src/types/algoliasearch.ts new file mode 100644 index 00000000000..ac04a682a88 --- /dev/null +++ b/packages/instantsearch-core/src/types/algoliasearch.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/extensions +export * from 'algoliasearch-helper/types/algoliasearch.js'; + +export {}; diff --git a/packages/instantsearch-core/src/types/connector.ts b/packages/instantsearch-core/src/types/connector.ts new file mode 100644 index 00000000000..2e285219adf --- /dev/null +++ b/packages/instantsearch-core/src/types/connector.ts @@ -0,0 +1,83 @@ +import type { InsightsClient } from './insights'; +import type { InstantSearch } from './instantsearch'; +import type { Hit } from './results'; +import type { UnknownWidgetParams, Widget, WidgetDescription } from './widget'; +import type { SearchResults } from 'algoliasearch-helper'; + +/** + * The base renderer options. All render functions receive + * the options below plus the specific options per connector. + */ +export type RendererOptions = { + /** + * The original widget params. Useful as you may + * need them while using the render function. + */ + widgetParams: TWidgetParams; + + /** + * The current instant search instance. + */ + instantSearchInstance: InstantSearch; + + /** + * The original search results. + */ + results?: SearchResults; + + /** + * The mutable list of hits. The may change depending + * of the given transform items function. + */ + hits?: Hit[]; + + /** + * The current insights client, if any. + */ + insights?: InsightsClient; +}; + +/** + * The render function. + */ +export type Renderer = ( + /** + * The base render options plus the specific options of the widget. + */ + renderState: TRenderState & RendererOptions, + + /** + * If is the first run. + */ + isFirstRender: boolean +) => void; + +/** + * The called function when unmounting a widget. + */ +export type Unmounter = () => void; + +/** + * The connector handles the business logic and exposes + * a simplified API to the rendering function. + */ +export type Connector< + TWidgetDescription extends WidgetDescription, + TConnectorParams extends UnknownWidgetParams +> = ( + /** + * The render function. + */ + renderFn: Renderer< + TWidgetDescription['renderState'], + TConnectorParams & TWidgetParams + >, + /** + * The called function when unmounting a widget. + */ + unmountFn?: Unmounter +) => (widgetParams: TConnectorParams & TWidgetParams) => Widget< + TWidgetDescription & { + widgetParams: typeof widgetParams; + } +>; diff --git a/packages/instantsearch-core/src/types/index.ts b/packages/instantsearch-core/src/types/index.ts new file mode 100644 index 00000000000..9de821df921 --- /dev/null +++ b/packages/instantsearch-core/src/types/index.ts @@ -0,0 +1,13 @@ +export * from './algoliasearch'; +export * from './connector'; +export * from './insights'; +export * from './instantsearch'; +export * from './middleware'; +export * from './recommend'; +export * from './render-state'; +export * from './results'; +export * from './router'; +export * from './ui-state'; +export * from './utils'; +export * from './widget'; +export * from './widget-factory'; diff --git a/packages/instantsearch-core/src/types/insights.ts b/packages/instantsearch-core/src/types/insights.ts new file mode 100644 index 00000000000..424bdf124f5 --- /dev/null +++ b/packages/instantsearch-core/src/types/insights.ts @@ -0,0 +1,63 @@ +import type { Hit } from './results'; +import type { + InsightsMethodMap as _InsightsMethodMap, + InsightsClient as _InsightsClient, +} from 'search-insights'; + +export type { + Init as InsightsInit, + AddAlgoliaAgent as InsightsAddAlgoliaAgent, + SetUserToken as InsightsSetUserToken, + GetUserToken as InsightsGetUserToken, + OnUserTokenChange as InsightsOnUserTokenChange, +} from 'search-insights'; + +export type InsightsMethodMap = _InsightsMethodMap; +export type InsightsClientMethod = keyof InsightsMethodMap; + +/** + * Method allowed by the insights middleware. + */ +export type InsightsMethod = + | 'clickedObjectIDsAfterSearch' + | 'clickedObjectIDs' + | 'clickedFilters' + | 'convertedObjectIDsAfterSearch' + | 'convertedObjectIDs' + | 'convertedFilters' + | 'viewedObjectIDs' + | 'viewedFilters'; + +/** + * The event sent to the insights middleware. + */ +export type InsightsEvent = { + insightsMethod?: TMethod; + payload: InsightsMethodMap[TMethod][0][0]; + widgetType: string; + eventType: string; // 'view' | 'click' | 'conversion', but we're not restricting. + eventModifier?: string; // 'internal', but we're not restricting. + hits?: Hit[]; + attribute?: string; +}; + +export type InsightsClientPayload = { + eventName: string; + queryID: string; + index: string; + objectIDs: string[]; + positions?: number[]; +}; + +type QueueItemMap = { + [MethodName in keyof InsightsMethodMap]: [ + methodName: MethodName, + ...args: InsightsMethodMap[MethodName][0][0] + ]; +}; + +export type QueueItem = QueueItemMap[keyof QueueItemMap]; + +export type InsightsClient = _InsightsClient & { + queue?: QueueItem[]; +}; diff --git a/packages/instantsearch-core/src/types/instantsearch.ts b/packages/instantsearch-core/src/types/instantsearch.ts new file mode 100644 index 00000000000..9cf175aaa40 --- /dev/null +++ b/packages/instantsearch-core/src/types/instantsearch.ts @@ -0,0 +1,120 @@ +import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; +import type { CompositionClient, SearchClient } from './algoliasearch'; +import type { InsightsClient as AlgoliaInsightsClient } from './insights'; +import type { + InsightsEvent, + InsightsProps, +} from '../middlewares/createInsightsMiddleware'; +import type { Middleware, MiddlewareDefinition } from './middleware'; +import type { RenderState } from './render-state'; +import type { InitialResults } from './results'; +import type { RouterProps } from '../middlewares/createRouterMiddleware'; +import type { UiState } from './ui-state'; +import type { CreateURL, IndexWidget, Widget } from './widget'; + +// This purposely breaks TypeScript's type inference to ensure it's not used +// as it's used for a default parameter for example. +type NoInfer = T extends infer S ? S : never; + +export type InstantSearchOptions< + TUiState extends UiState = UiState, + TRouteState = TUiState +> = { + indexName?: string; + compositionID?: string; + searchClient: SearchClient | CompositionClient; + numberLocale?: string; + /** @deprecated use onStateChange instead */ + searchFunction?: (helper: AlgoliaSearchHelper) => void; + onStateChange?: (params: { + uiState: TUiState; + setUiState: ( + uiState: TUiState | ((previousUiState: TUiState) => TUiState) + ) => void; + }) => void; + initialUiState?: NoInfer; + stalledSearchDelay?: number; + routing?: RouterProps | boolean; + insights?: InsightsProps | boolean; + /** @deprecated use the `insights` option instead */ + insightsClient?: AlgoliaInsightsClient; + future?: { + preserveSharedStateOnUnmount?: boolean; + persistHierarchicalRootCount?: boolean; + }; +}; + +export type InstantSearchStatus = 'idle' | 'loading' | 'stalled' | 'error'; + +export const INSTANTSEARCH_FUTURE_DEFAULTS: Required< + InstantSearchOptions['future'] +> = { + preserveSharedStateOnUnmount: false, + persistHierarchicalRootCount: false, +}; + +export interface InstantSearch< + TUiState extends UiState = UiState, + _TRouteState = TUiState +> { + client: InstantSearchOptions['searchClient']; + indexName: string; + compositionID?: string; + insightsClient: AlgoliaInsightsClient | null; + onStateChange: InstantSearchOptions['onStateChange'] | null; + future: NonNullable['future']>; + helper: AlgoliaSearchHelper | null; + mainHelper: AlgoliaSearchHelper | null; + mainIndex: IndexWidget; + started: boolean; + _hasSearchWidget: boolean; + _hasRecommendWidget: boolean; + templatesConfig: Record; + renderState: RenderState; + status: InstantSearchStatus; + error: Error | null | undefined; + _initialUiState: TUiState; + _initialResults: InitialResults | null; + _searchStalledTimer: ReturnType | null; + _manuallyResetScheduleSearch?: boolean; + _resetScheduleSearch?: () => void; + _isSearchStalled: boolean; + _stalledSearchDelay: number; + _searchFunction?: InstantSearchOptions['searchFunction']; + _insights: InstantSearchOptions['insights']; + middleware: Array<{ + creator: Middleware; + instance: MiddlewareDefinition; + }>; + sendEventToInsights: (event: InsightsEvent) => void; + addWidgets(widgets: Array): this; + removeWidgets(widgets: Array): this; + addWidget(widget: Widget | IndexWidget): this; + removeWidget(widget: Widget | IndexWidget): this; + start(): void; + dispose(): void; + scheduleSearch(): void; + scheduleRender(): void; + scheduleStalledRender(): void; + setUiState( + uiState: TUiState | ((previousUiState: TUiState) => TUiState), + callOnStateChange?: boolean + ): void; + getUiState(): TUiState; + createURL(nextState?: TUiState): string; + refresh(): void; + use(...middleware: Array>): this; + EXPERIMENTAL_use(...middleware: Array>): this; + unuse(...middlewareToUnuse: Array>): this; + onInternalStateChange(): void; + _createURL: CreateURL; + on(event: string, handler: (...args: any[]) => void): this; + once(event: string, handler: (...args: any[]) => void): this; + emit(event: string, ...args: any[]): boolean; + addListener(event: string, handler: (...args: any[]) => void): this; + removeListener(event: string, handler: (...args: any[]) => void): this; + removeAllListeners(event?: string): this; + setMaxListeners(n: number): this; + listeners(event: string): Function[]; + listenerCount(event: string): number; +} diff --git a/packages/instantsearch-core/src/types/middleware.ts b/packages/instantsearch-core/src/types/middleware.ts new file mode 100644 index 00000000000..965dce8783a --- /dev/null +++ b/packages/instantsearch-core/src/types/middleware.ts @@ -0,0 +1,42 @@ +import type { InstantSearch } from './instantsearch'; +import type { UiState } from './ui-state'; +import type { AtLeastOne } from './utils'; + +export type MiddlewareDefinition = { + /** + * string to identify the middleware + */ + $$type: string; + /** + * @internal indicator for the default middleware + */ + $$internal: boolean; + /** + * Change handler called on every UiState change + */ + onStateChange: (options: { uiState: TUiState }) => void; + /** + * Called when the middleware is added to InstantSearch + */ + subscribe: () => void; + /** + * Called when InstantSearch is started + */ + started: () => void; + /** + * Called when the middleware is removed from InstantSearch + */ + unsubscribe: () => void; +}; + +export type MiddlewareOptions = { + instantSearchInstance: InstantSearch; +}; + +export type InternalMiddleware = ( + options: MiddlewareOptions +) => MiddlewareDefinition; + +export type Middleware = ( + options: MiddlewareOptions +) => AtLeastOne>; diff --git a/packages/instantsearch-core/src/types/recommend.ts b/packages/instantsearch-core/src/types/recommend.ts new file mode 100644 index 00000000000..c37fa958e26 --- /dev/null +++ b/packages/instantsearch-core/src/types/recommend.ts @@ -0,0 +1,12 @@ +/** + * A trending facet value returned by the Recommend API. + * NOT a Hit — no objectID, __position, or __queryID. + */ +export type TrendingFacetItem = { + /** The facet attribute name (e.g., "brand"). */ + facetName: string; + /** The facet value (e.g., "Nike"). */ + facetValue: string; + /** Trending score from the Recommend API (0-100). */ + _score: number; +}; diff --git a/packages/instantsearch-core/src/types/render-state.ts b/packages/instantsearch-core/src/types/render-state.ts new file mode 100644 index 00000000000..18cabd39d44 --- /dev/null +++ b/packages/instantsearch-core/src/types/render-state.ts @@ -0,0 +1,73 @@ +import type { AnswersWidgetDescription } from '../connectors/answers/connectAnswers'; +import type { AutocompleteWidgetDescription } from '../connectors/autocomplete/connectAutocomplete'; +import type { BreadcrumbWidgetDescription } from '../connectors/breadcrumb/connectBreadcrumb'; +import type { ChatWidgetDescription } from '../connectors/chat/connectChat'; +import type { ClearRefinementsWidgetDescription } from '../connectors/clear-refinements/connectClearRefinements'; +import type { ConfigureWidgetDescription } from '../connectors/configure/connectConfigure'; +import type { CurrentRefinementsWidgetDescription } from '../connectors/current-refinements/connectCurrentRefinements'; +import type { FeedsWidgetDescription } from '../connectors/feeds/connectFeeds'; +import type { GeoSearchWidgetDescription } from '../connectors/geo-search/connectGeoSearch'; +import type { HierarchicalMenuWidgetDescription } from '../connectors/hierarchical-menu/connectHierarchicalMenu'; +import type { HitsPerPageWidgetDescription } from '../connectors/hits-per-page/connectHitsPerPage'; +import type { HitsWidgetDescription } from '../connectors/hits/connectHits'; +import type { InfiniteHitsWidgetDescription } from '../connectors/infinite-hits/connectInfiniteHits'; +import type { MenuWidgetDescription } from '../connectors/menu/connectMenu'; +import type { NumericMenuWidgetDescription } from '../connectors/numeric-menu/connectNumericMenu'; +import type { PaginationWidgetDescription } from '../connectors/pagination/connectPagination'; +import type { PoweredByWidgetDescription } from '../connectors/powered-by/connectPoweredBy'; +import type { QueryRulesWidgetDescription } from '../connectors/query-rules/connectQueryRules'; +import type { RangeWidgetDescription } from '../connectors/range/connectRange'; +import type { RatingMenuWidgetDescription } from '../connectors/rating-menu/connectRatingMenu'; +import type { RefinementListWidgetDescription } from '../connectors/refinement-list/connectRefinementList'; +import type { RelevantSortWidgetDescription } from '../connectors/relevant-sort/connectRelevantSort'; +import type { SearchBoxWidgetDescription } from '../connectors/search-box/connectSearchBox'; +import type { SortByWidgetDescription } from '../connectors/sort-by/connectSortBy'; +import type { StatsWidgetDescription } from '../connectors/stats/connectStats'; +import type { ToggleRefinementWidgetDescription } from '../connectors/toggle-refinement/connectToggleRefinement'; +import type { VoiceSearchWidgetDescription } from '../connectors/voice-search/connectVoiceSearch'; +import type { AnalyticsWidgetDescription } from '../widgets/analytics/analytics'; +import type { PlacesWidgetDescription } from '../widgets/places/places'; + +type ConnectorRenderStates = AnswersWidgetDescription['indexRenderState'] & + AutocompleteWidgetDescription['indexRenderState'] & + BreadcrumbWidgetDescription['indexRenderState'] & + ChatWidgetDescription['indexRenderState'] & + ClearRefinementsWidgetDescription['indexRenderState'] & + ConfigureWidgetDescription['indexRenderState'] & + CurrentRefinementsWidgetDescription['indexRenderState'] & + FeedsWidgetDescription['indexRenderState'] & + GeoSearchWidgetDescription['indexRenderState'] & + HierarchicalMenuWidgetDescription['indexRenderState'] & + HitsWidgetDescription['indexRenderState'] & + HitsPerPageWidgetDescription['indexRenderState'] & + InfiniteHitsWidgetDescription['indexRenderState'] & + MenuWidgetDescription['indexRenderState'] & + NumericMenuWidgetDescription['indexRenderState'] & + PaginationWidgetDescription['indexRenderState'] & + PoweredByWidgetDescription['indexRenderState'] & + QueryRulesWidgetDescription['indexRenderState'] & + RangeWidgetDescription['indexRenderState'] & + RatingMenuWidgetDescription['indexRenderState'] & + RefinementListWidgetDescription['indexRenderState'] & + RelevantSortWidgetDescription['indexRenderState'] & + SearchBoxWidgetDescription['indexRenderState'] & + SortByWidgetDescription['indexRenderState'] & + StatsWidgetDescription['indexRenderState'] & + ToggleRefinementWidgetDescription['indexRenderState'] & + VoiceSearchWidgetDescription['indexRenderState']; + +type WidgetRenderStates = AnalyticsWidgetDescription['indexRenderState'] & + PlacesWidgetDescription['indexRenderState']; + +export type IndexRenderState = Partial< + ConnectorRenderStates & WidgetRenderStates +>; + +export type RenderState = { + [indexId: string]: IndexRenderState; +}; + +export type WidgetRenderState = + TWidgetRenderState & { + widgetParams: TWidgetParams; + }; diff --git a/packages/instantsearch-core/src/types/results.ts b/packages/instantsearch-core/src/types/results.ts new file mode 100644 index 00000000000..e8850505408 --- /dev/null +++ b/packages/instantsearch-core/src/types/results.ts @@ -0,0 +1,118 @@ +import type { SearchOptions } from './algoliasearch'; +import type { + PlainSearchParameters, + RecommendParametersOptions, + RecommendResults, + SearchForFacetValues, + SearchResults, +} from 'algoliasearch-helper'; + +export type HitAttributeHighlightResult = { + value: string; + matchLevel: 'none' | 'partial' | 'full'; + matchedWords: string[]; + fullyHighlighted?: boolean; +}; + +export type HitHighlightResult = { + [attribute: string]: + | HitAttributeHighlightResult + | HitAttributeHighlightResult[] + | HitHighlightResult[] + | HitHighlightResult; +}; + +export type HitAttributeSnippetResult = Pick< + HitAttributeHighlightResult, + 'value' | 'matchLevel' +>; + +export type HitSnippetResult = { + [attribute: string]: + | HitAttributeSnippetResult[] + | HitSnippetResult[] + | HitAttributeSnippetResult + | HitSnippetResult; +}; + +export type GeoLoc = { + lat: number; + lng: number; +}; + +export type AlgoliaHit = Record> = + { + objectID: string; + _highlightResult?: HitHighlightResult; + _snippetResult?: HitSnippetResult; + _rankingInfo?: { + promoted: boolean; + nbTypos: number; + firstMatchedWord: number; + proximityDistance?: number; + geoDistance: number; + geoPrecision?: number; + nbExactWords: number; + words: number; + filters: number; + userScore: number; + matchedGeoLocation?: { + lat: number; + lng: number; + distance: number; + }; + }; + _distinctSeqID?: number; + _geoloc?: GeoLoc; + } & THit; + +export type BaseHit = Record; + +export type Hit = Record> = { + __position: number; + __queryID?: string; +} & AlgoliaHit; + +export type GeoHit = BaseHit> = Hit & + Required>; + +/** + * @deprecated use Hit[] directly instead + */ +export type Hits = Hit[]; + +export type EscapedHits = THit[] & { __escaped: boolean }; + +export type FacetHit = SearchForFacetValues.Hit; + +export type FacetRefinement = { + value: string; + type: 'conjunctive' | 'disjunctive' | 'exclude'; +}; + +export type NumericRefinement = { + value: number[]; + type: 'numeric'; + operator: string; +}; + +export type Refinement = FacetRefinement | NumericRefinement; + +export type CompositionFeedResult = NonNullable< + SearchResults['_rawResults'] +>[number] & { + feedID: string; +}; + +type InitialResult = { + state?: PlainSearchParameters; + results?: SearchResults['_rawResults']; + compositionFeedsResults?: CompositionFeedResult[]; + recommendResults?: { + params: NonNullable; + results: RecommendResults['_rawResults']; + }; + requestParams?: SearchOptions[]; +}; + +export type InitialResults = Record; diff --git a/packages/instantsearch-core/src/types/router.ts b/packages/instantsearch-core/src/types/router.ts new file mode 100644 index 00000000000..326b47e0b77 --- /dev/null +++ b/packages/instantsearch-core/src/types/router.ts @@ -0,0 +1,75 @@ +import type { UiState } from './ui-state'; + +/** + * The router is the part that saves and reads the object from the storage. + * Usually this is the URL. + */ +export type Router = { + /** + * onUpdate Sets an event listener that is triggered when the storage is updated. + * The function should accept a callback to trigger when the update happens. + * In the case of the history / URL in a browser, the callback will be called + * by `onPopState`. + */ + onUpdate: (callback: (route: TRouteState) => void) => void; + + /** + * Reads the storage and gets a route object. It does not take parameters, + * and should return an object + */ + read: () => TRouteState; + + /** + * Pushes a route object into a storage. Takes the UI state mapped by the state + * mapping configured in the mapping + */ + write: (route: TRouteState) => void; + + /** + * Transforms a route object into a URL. It receives an object and should + * return a string. It may return an empty string. + */ + createURL: (state: TRouteState) => string; + + /** + * Called when InstantSearch is disposed. Used to remove subscriptions. + */ + dispose: () => void; + + /** + * Called when InstantSearch is started. + */ + start?: () => void; + + /** + * Identifier for this router. Used to differentiate between routers. + */ + $$type?: string; +}; + +/** + * The state mapping is a way to customize the structure before sending it to the router. + * It can transform and filter out the properties. To work correctly, the following + * should be valid for any UiState: + * `UiState = routeToState(stateToRoute(UiState))`. + */ +export type StateMapping = { + /** + * Transforms a UI state representation into a route object. + * It receives an object that contains the UI state of all the widgets in the page. + * It should return an object of any form as long as this form can be read by + * the `routeToState` function. + */ + stateToRoute: (uiState: TUiState) => TRouteState; + /** + * Transforms route object into a UI state representation. + * It receives an object that contains the UI state stored by the router. + * The format is the output of `stateToRoute`. + */ + routeToState: (routeState: TRouteState) => TUiState; + + /** + * Identifier for this stateMapping. Used to differentiate between stateMappings. + */ + $$type?: string; +}; diff --git a/packages/instantsearch-core/src/types/ui-state.ts b/packages/instantsearch-core/src/types/ui-state.ts new file mode 100644 index 00000000000..13f0d029eac --- /dev/null +++ b/packages/instantsearch-core/src/types/ui-state.ts @@ -0,0 +1,44 @@ +import type { AutocompleteWidgetDescription } from '../connectors/autocomplete/connectAutocomplete'; +import type { ConfigureWidgetDescription } from '../connectors/configure/connectConfigure'; +import type { GeoSearchWidgetDescription } from '../connectors/geo-search/connectGeoSearch'; +import type { HierarchicalMenuWidgetDescription } from '../connectors/hierarchical-menu/connectHierarchicalMenu'; +import type { HitsPerPageWidgetDescription } from '../connectors/hits-per-page/connectHitsPerPage'; +import type { InfiniteHitsWidgetDescription } from '../connectors/infinite-hits/connectInfiniteHits'; +import type { MenuWidgetDescription } from '../connectors/menu/connectMenu'; +import type { NumericMenuWidgetDescription } from '../connectors/numeric-menu/connectNumericMenu'; +import type { PaginationWidgetDescription } from '../connectors/pagination/connectPagination'; +import type { RangeWidgetDescription } from '../connectors/range/connectRange'; +import type { RatingMenuWidgetDescription } from '../connectors/rating-menu/connectRatingMenu'; +import type { RefinementListWidgetDescription } from '../connectors/refinement-list/connectRefinementList'; +import type { RelevantSortWidgetDescription } from '../connectors/relevant-sort/connectRelevantSort'; +import type { SearchBoxWidgetDescription } from '../connectors/search-box/connectSearchBox'; +import type { SortByWidgetDescription } from '../connectors/sort-by/connectSortBy'; +import type { ToggleRefinementWidgetDescription } from '../connectors/toggle-refinement/connectToggleRefinement'; +import type { VoiceSearchWidgetDescription } from '../connectors/voice-search/connectVoiceSearch'; +import type { PlacesWidgetDescription } from '../widgets/places/places'; + +type ConnectorUiStates = AutocompleteWidgetDescription['indexUiState'] & + ConfigureWidgetDescription['indexUiState'] & + GeoSearchWidgetDescription['indexUiState'] & + HierarchicalMenuWidgetDescription['indexUiState'] & + HitsPerPageWidgetDescription['indexUiState'] & + InfiniteHitsWidgetDescription['indexUiState'] & + MenuWidgetDescription['indexUiState'] & + NumericMenuWidgetDescription['indexUiState'] & + PaginationWidgetDescription['indexUiState'] & + RangeWidgetDescription['indexUiState'] & + RatingMenuWidgetDescription['indexUiState'] & + RefinementListWidgetDescription['indexUiState'] & + RelevantSortWidgetDescription['indexUiState'] & + SearchBoxWidgetDescription['indexUiState'] & + SortByWidgetDescription['indexUiState'] & + ToggleRefinementWidgetDescription['indexUiState'] & + VoiceSearchWidgetDescription['indexUiState']; + +type WidgetUiStates = PlacesWidgetDescription['indexUiState']; + +export type IndexUiState = Partial; + +export type UiState = { + [indexId: string]: IndexUiState; +}; diff --git a/packages/instantsearch-core/src/types/utils.ts b/packages/instantsearch-core/src/types/utils.ts new file mode 100644 index 00000000000..7a2b73f97ff --- /dev/null +++ b/packages/instantsearch-core/src/types/utils.ts @@ -0,0 +1,26 @@ +export type HighlightedParts = { + value: string; + isHighlighted: boolean; +}; + +// https://stackoverflow.com/questions/48230773/how-to-create-a-partial-like-that-requires-a-single-property-to-be-set/48244432#48244432 +export type AtLeastOne< + TTarget, + TMapped = { [Key in keyof TTarget]: Pick } +> = Partial & TMapped[keyof TMapped]; + +// removes intermediary composed types in IntelliSense +export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; + +// Make certain keys in an object required +export type RequiredKeys = Expand< + Required> & Omit +>; + +export type Awaited = T extends PromiseLike ? Awaited : T; + +/** + * Make certain keys of an object optional. + */ +export type PartialKeys = Omit & + Partial>; diff --git a/packages/instantsearch-core/src/types/widget-factory.ts b/packages/instantsearch-core/src/types/widget-factory.ts new file mode 100644 index 00000000000..f55c13f7aee --- /dev/null +++ b/packages/instantsearch-core/src/types/widget-factory.ts @@ -0,0 +1,21 @@ +import type { UnknownWidgetParams, Widget, WidgetDescription } from './widget'; + +/** + * The function that creates a new widget. + */ +export type WidgetFactory< + TWidgetDescription extends WidgetDescription, + TConnectorParams extends UnknownWidgetParams, + TWidgetParams extends UnknownWidgetParams +> = ( + /** + * The params of the widget. + */ + widgetParams: TWidgetParams & TConnectorParams +) => Widget< + TWidgetDescription & { + widgetParams: TConnectorParams; + } +>; + +export type UnknownWidgetFactory = WidgetFactory<{ $$type: string }, any, any>; diff --git a/packages/instantsearch-core/src/types/widget.ts b/packages/instantsearch-core/src/types/widget.ts new file mode 100644 index 00000000000..8271e5397f9 --- /dev/null +++ b/packages/instantsearch-core/src/types/widget.ts @@ -0,0 +1,388 @@ +import type { IndexWidget } from '../widgets'; +import type { RecommendResponse } from './algoliasearch'; +import type { InstantSearch } from './instantsearch'; +import type { IndexRenderState, WidgetRenderState } from './render-state'; +import type { IndexUiState, UiState } from './ui-state'; +import type { Expand, RequiredKeys } from './utils'; +import type { + AlgoliaSearchHelper as Helper, + SearchParameters, + SearchResults, + RecommendParameters, +} from 'algoliasearch-helper'; + +export type ScopedResult = { + indexId: string; + results: SearchResults | null; + helper: Helper; +}; + +type SharedRenderOptions = { + instantSearchInstance: InstantSearch; + parent: IndexWidget; + templatesConfig: Record; + scopedResults: ScopedResult[]; + state: SearchParameters; + renderState: IndexRenderState; + helper: Helper; + /** @deprecated use `status` instead */ + searchMetadata: { + /** @deprecated use `status === "stalled"` instead */ + isSearchStalled: boolean; + }; + status: InstantSearch['status']; + error: InstantSearch['error']; + createURL: ( + nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) + ) => string; +}; + +export type InitOptions = SharedRenderOptions & { + uiState: UiState; + results?: undefined; +}; + +export type ShouldRenderOptions = { instantSearchInstance: InstantSearch }; + +export type RenderOptions = SharedRenderOptions & { + results: SearchResults | null; +}; + +export type DisposeOptions = { + helper: Helper; + state: SearchParameters; + recommendState: RecommendParameters; + parent: IndexWidget; +}; + +export const indexWidgetTypes = ['ais.index', 'ais.feedContainer'] as const; +export type IndexWidgetType = (typeof indexWidgetTypes)[number]; + +// @MAJOR: Remove these exported types if we don't need them +export type BuiltinTypes = + | 'ais.analytics' + | 'ais.answers' + | 'ais.autocomplete' + | 'ais.breadcrumb' + | 'ais.clearRefinements' + | 'ais.chat' + | 'ais.configure' + | 'ais.configureRelatedItems' + | 'ais.currentRefinements' + | 'ais.dynamicWidgets' + | 'ais.feedContainer' + | 'ais.feeds' + | 'ais.frequentlyBoughtTogether' + | 'ais.geoSearch' + | 'ais.hierarchicalMenu' + | 'ais.hits' + | 'ais.hitsPerPage' + | 'ais.index' + | 'ais.infiniteHits' + | 'ais.lookingSimilar' + | 'ais.menu' + | 'ais.numericMenu' + | 'ais.pagination' + | 'ais.places' + | 'ais.poweredBy' + | 'ais.queryRules' + // @TODO: remove individual types for rangeSlider & rangeInput once updating checkIndexUiState + | 'ais.range' + | 'ais.rangeSlider' + | 'ais.rangeInput' + | 'ais.ratingMenu' + | 'ais.refinementList' + | 'ais.relatedProducts' + | 'ais.searchBox' + | 'ais.relevantSort' + | 'ais.sortBy' + | 'ais.stats' + | 'ais.toggleRefinement' + | 'ais.trendingFacets' + | 'ais.trendingItems' + | 'ais.voiceSearch'; + +export type BuiltinWidgetTypes = + | 'ais.analytics' + | 'ais.answers' + | 'ais.autocomplete' + | 'ais.breadcrumb' + | 'ais.chat' + | 'ais.clearRefinements' + | 'ais.configure' + | 'ais.configureRelatedItems' + | 'ais.currentRefinements' + | 'ais.dynamicWidgets' + | 'ais.feedContainer' + | 'ais.feeds' + | 'ais.frequentlyBoughtTogether' + | 'ais.geoSearch' + | 'ais.hierarchicalMenu' + | 'ais.hits' + | 'ais.hitsPerPage' + | 'ais.index' + | 'ais.infiniteHits' + | 'ais.lookingSimilar' + | 'ais.menu' + | 'ais.menuSelect' + | 'ais.numericMenu' + | 'ais.pagination' + | 'ais.places' + | 'ais.poweredBy' + | 'ais.queryRuleCustomData' + | 'ais.queryRuleContext' + | 'ais.rangeInput' + | 'ais.rangeSlider' + | 'ais.ratingMenu' + | 'ais.refinementList' + | 'ais.relatedProducts' + | 'ais.searchBox' + | 'ais.relevantSort' + | 'ais.sortBy' + | 'ais.stats' + | 'ais.toggleRefinement' + | 'ais.trendingFacets' + | 'ais.trendingItems' + | 'ais.voiceSearch'; + +export type UnknownWidgetParams = NonNullable; + +export type WidgetParams = { + widgetParams?: UnknownWidgetParams; +}; + +export type WidgetDescription = { + $$type: string; + $$widgetType?: string; + renderState?: Record; + indexRenderState?: Record; + indexUiState?: Record; +}; + +type SearchWidget = { + dependsOn?: 'search'; + getWidgetParameters?: ( + state: SearchParameters, + widgetParametersOptions: { + uiState: Expand< + Partial + >; + } + ) => SearchParameters; +}; + +type RecommendRenderOptions = SharedRenderOptions & { + results: RecommendResponse; +}; + +type RecommendWidget< + TWidgetDescription extends WidgetDescription & WidgetParams +> = { + dependsOn: 'recommend'; + $$id?: number; + getWidgetParameters: ( + state: RecommendParameters, + widgetParametersOptions: { + uiState: Expand< + Partial + >; + } + ) => RecommendParameters; + getRenderState: ( + renderState: Expand< + IndexRenderState & Partial + >, + renderOptions: InitOptions | RecommendRenderOptions + ) => IndexRenderState & TWidgetDescription['indexRenderState']; + getWidgetRenderState: ( + renderOptions: InitOptions | RecommendRenderOptions + ) => Expand< + WidgetRenderState< + TWidgetDescription['renderState'], + TWidgetDescription['widgetParams'] + > + >; +}; + +type Parent = { + /** + * This gets dynamically added by the `index` widget. + * If the widget has gone through `addWidget`, it will have a parent. + */ + parent?: IndexWidget; +}; + +type RequiredWidgetLifeCycle = { + /** + * Identifier for connectors and widgets. + */ + $$type: TWidgetDescription['$$type']; + + /** + * Called once before the first search. + */ + init?: (options: InitOptions) => void; + /** + * Whether `render` should be called + */ + shouldRender?: (options: ShouldRenderOptions) => boolean; + /** + * Called after each search response has been received. + */ + render?: (options: RenderOptions) => void; + /** + * Called when this widget is unmounted. Used to remove refinements set by + * during this widget's initialization and life time. + */ + dispose?: ( + options: DisposeOptions + ) => SearchParameters | RecommendParameters | void; +}; + +type RequiredWidgetType = { + /** + * Identifier for widgets. + */ + $$widgetType: TWidgetDescription['$$widgetType']; +}; + +type WidgetType = + TWidgetDescription extends RequiredKeys + ? RequiredWidgetType + : { + /** + * Identifier for widgets. + */ + $$widgetType?: string; + }; + +type RequiredUiStateLifeCycle = { + /** + * This function is required for a widget to be taken in account for routing. + * It will derive a uiState for this widget based on the existing uiState and + * the search parameters applied. + * + * @param uiState - Current state. + * @param widgetStateOptions - Extra information to calculate uiState. + */ + getWidgetUiState: ( + uiState: Expand>, + widgetUiStateOptions: { + searchParameters: SearchParameters; + helper: Helper; + } + ) => Partial; + + /** + * This function is required for a widget to be taken in account for routing. + * It will derive a uiState for this widget based on the existing uiState and + * the search parameters applied. + * + * @deprecated Use `getWidgetUiState` instead. + * @param uiState - Current state. + * @param widgetStateOptions - Extra information to calculate uiState. + */ + getWidgetState?: RequiredUiStateLifeCycle['getWidgetUiState']; + + /** + * This function is required for a widget to behave correctly when a URL is + * loaded via e.g. Routing. It receives the current UiState and applied search + * parameters, and is expected to return a new search parameters. + * + * @param state - Applied search parameters. + * @param widgetSearchParametersOptions - Extra information to calculate next searchParameters. + */ + getWidgetSearchParameters: ( + state: SearchParameters, + widgetSearchParametersOptions: { + uiState: Expand< + Partial + >; + } + ) => SearchParameters; +}; + +type UiStateLifeCycle = + TWidgetDescription extends RequiredKeys + ? RequiredUiStateLifeCycle + : Partial>; + +type RequiredRenderStateLifeCycle< + TWidgetDescription extends WidgetDescription & WidgetParams +> = { + /** + * Returns the render state of the current widget to pass to the render function. + */ + getWidgetRenderState: ( + renderOptions: InitOptions | RenderOptions + ) => Expand< + WidgetRenderState< + TWidgetDescription['renderState'], + TWidgetDescription['widgetParams'] + > + >; + /** + * Returns IndexRenderState of the current index component tree + * to build the render state of the whole app. + */ + getRenderState: ( + renderState: Expand< + IndexRenderState & Partial + >, + renderOptions: InitOptions | RenderOptions + ) => IndexRenderState & TWidgetDescription['indexRenderState']; +}; + +type RenderStateLifeCycle< + TWidgetDescription extends WidgetDescription & WidgetParams +> = TWidgetDescription extends RequiredKeys< + WidgetDescription, + 'renderState' | 'indexRenderState' +> & + WidgetParams + ? RequiredRenderStateLifeCycle + : Partial>; + +export type Widget< + TWidgetDescription extends WidgetDescription & WidgetParams = { + $$type: string; + } +> = Expand< + Parent & + RequiredWidgetLifeCycle & + WidgetType & + UiStateLifeCycle & + RenderStateLifeCycle +> & + (SearchWidget | RecommendWidget); + +export type { IndexWidget } from '../widgets'; + +export type TransformItemsMetadata = { + results: SearchResults | undefined | null; +}; + +/** + * Transforms the given items. + */ +export type TransformItems = ( + items: TItem[], + metadata: TMetadata +) => TItem[]; + +type SortByDirection = + | TCriterion + | `${TCriterion}:asc` + | `${TCriterion}:desc`; + +/** + * Transforms the given items. + */ +export type SortBy = + | ((a: TItem, b: TItem) => number) + | Array>; + +/** + * Creates the URL for the given value. + */ +export type CreateURL = (value: TValue) => string; diff --git a/packages/instantsearch-core/src/version.ts b/packages/instantsearch-core/src/version.ts new file mode 100644 index 00000000000..2f6ce8b5e7d --- /dev/null +++ b/packages/instantsearch-core/src/version.ts @@ -0,0 +1 @@ +export default '0.1.0'; diff --git a/packages/instantsearch-core/src/widgets/analytics/analytics.ts b/packages/instantsearch-core/src/widgets/analytics/analytics.ts new file mode 100644 index 00000000000..4a45e32ba97 --- /dev/null +++ b/packages/instantsearch-core/src/widgets/analytics/analytics.ts @@ -0,0 +1,25 @@ +import type { WidgetRenderState } from '../../types'; +import type { SearchParameters, SearchResults } from 'algoliasearch-helper'; + +export type AnalyticsWidgetParamsPushFunction = ( + formattedParameters: string, + state: SearchParameters, + results: SearchResults +) => void; + +export type AnalyticsWidgetParams = { + pushFunction: AnalyticsWidgetParamsPushFunction; + delay?: number; + triggerOnUIInteraction?: boolean; + pushInitialSearch?: boolean; + pushPagination?: boolean; +}; + +export type AnalyticsWidgetDescription = { + $$type: 'ais.analytics'; + $$widgetType: 'ais.analytics'; + renderState: Record; + indexRenderState: { + analytics: WidgetRenderState, AnalyticsWidgetParams>; + }; +}; diff --git a/packages/instantsearch-core/src/widgets/index.ts b/packages/instantsearch-core/src/widgets/index.ts new file mode 100644 index 00000000000..4d05a82c47e --- /dev/null +++ b/packages/instantsearch-core/src/widgets/index.ts @@ -0,0 +1,2 @@ +export { default as index } from './index/index'; +export type * from './index/index'; diff --git a/packages/instantsearch-core/src/widgets/index/index.ts b/packages/instantsearch-core/src/widgets/index/index.ts new file mode 100644 index 00000000000..b02a1ce3720 --- /dev/null +++ b/packages/instantsearch-core/src/widgets/index/index.ts @@ -0,0 +1,1083 @@ +import algoliasearchHelper from 'algoliasearch-helper'; + +import { + checkIndexUiState, + createDocumentationMessageGenerator, + resolveSearchParameters, + mergeSearchParameters, + warning, + isIndexWidget, + createInitArgs, + createRenderArgs, + storeRenderState, + defer, +} from '../../lib/utils'; +import { addWidgetId } from '../../lib/utils/addWidgetId'; + +import type { + InstantSearch, + UiState, + IndexUiState, + Widget, + ScopedResult, + RenderOptions, + RecommendResponse, + SearchClient, + IndexWidgetType, +} from '../../types'; +import type { + AlgoliaSearchHelper as Helper, + DerivedHelper, + SearchParameters, + SearchResults, + AlgoliaSearchHelper, + RecommendParameters, +} from 'algoliasearch-helper'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'index-widget', +}); + +export type IndexWidgetParams = + | { + /** + * The index or composition id to target. + */ + indexName: string; + /** + * Id to use for the index if there are multiple indices with the same name. + * This will be used to create the URL and the render state. + */ + indexId?: string; + /** + * If `true`, the index will not be merged with the main helper's state. + * This means that the index will not be part of the main search request. + * + * @default false + */ + EXPERIMENTAL_isolated?: false; + } + | { + /** + * If `true`, the index will not be merged with the main helper's state. + * This means that the index will not be part of the main search request. + * + * This option is EXPERIMENTAL, and implementation details may change in the future. + * Things that could change are: + * - which widgets get rendered when a change happens + * - whether the index searches automatically + * - whether the index is included in the URL / UiState + * - whether the index is included in server-side rendering + * + * @default false + */ + EXPERIMENTAL_isolated: true; + /** + * The index or composition id to target. + */ + indexName?: string; + /** + * Id to use for the index if there are multiple indices with the same name. + * This will be used to create the URL and the render state. + */ + indexId?: string; + }; + +export type IndexInitOptions = { + instantSearchInstance: InstantSearch; + parent: IndexWidget | null; + uiState: UiState; +}; + +export type IndexRenderOptions = { + instantSearchInstance: InstantSearch; +}; + +type WidgetSearchParametersOptions = Parameters< + NonNullable +>[1]; +type LocalWidgetSearchParametersOptions = WidgetSearchParametersOptions & { + initialSearchParameters: SearchParameters; +}; +type LocalWidgetRecommendParametersOptions = WidgetSearchParametersOptions & { + initialRecommendParameters: RecommendParameters; +}; + +export type IndexWidgetDescription = { + $$type: IndexWidgetType; + $$widgetType: IndexWidgetType; +}; + +export type IndexWidget = Omit< + Widget, + 'getWidgetUiState' | 'getWidgetState' +> & { + getIndexName: () => string; + getIndexId: () => string; + getHelper: () => Helper | null; + getResults: () => SearchResults | null; + getResultsForWidget: ( + widget: IndexWidget | Widget + ) => SearchResults | RecommendResponse | null; + getPreviousState: () => SearchParameters | null; + getScopedResults: () => ScopedResult[]; + getParent: () => IndexWidget | null; + getWidgets: () => Array; + createURL: ( + nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) + ) => string; + + addWidgets: ( + widgets: Array> + ) => IndexWidget; + removeWidgets: ( + widgets: Array + ) => IndexWidget; + + init: (options: IndexInitOptions) => void; + render: (options: IndexRenderOptions) => void; + dispose: () => void; + /** + * @deprecated + */ + getWidgetState: (uiState: UiState) => UiState; + getWidgetUiState: ( + uiState: TSpecificUiState + ) => TSpecificUiState; + getWidgetSearchParameters: ( + searchParameters: SearchParameters, + searchParametersOptions: { uiState: IndexUiState } + ) => SearchParameters; + /** + * Set this index' UI state back to the state defined by the widgets. + * Can only be called after `init`. + */ + refreshUiState: () => void; + /** + * Set this index' UI state and search. This is the equivalent of calling + * a spread `setUiState` on the InstantSearch instance. + * Can only be called after `init`. + */ + setIndexUiState: ( + indexUiState: + | TUiState[string] + | ((previousIndexUiState: TUiState[string]) => TUiState[string]) + ) => void; + /** + * This index is isolated, meaning it will not be merged with the main + * helper's state. + * @private + */ + _isolated: boolean; + /** + * Schedules a search for this index only. + * @private + */ + scheduleLocalSearch: () => void; +}; + +/** + * This is the same content as helper._change / setState, but allowing for extra + * UiState to be synchronized. + * see: https://github.com/algolia/algoliasearch-helper-js/blob/6b835ffd07742f2d6b314022cce6848f5cfecd4a/src/algoliasearch.helper.js#L1311-L1324 + */ +function privateHelperSetState( + helper: AlgoliaSearchHelper, + { + state, + recommendState, + isPageReset, + _uiState, + }: { + state: SearchParameters; + recommendState: RecommendParameters; + isPageReset?: boolean; + _uiState?: IndexUiState; + } +) { + if (state !== helper.state) { + helper.state = state; + + helper.emit('change', { + state: helper.state, + results: helper.lastResults, + isPageReset, + _uiState, + }); + } + + if (recommendState !== helper.recommendState) { + helper.recommendState = recommendState; + + // eslint-disable-next-line no-warning-comments + // TODO: emit "change" event when events for Recommend are implemented + } +} + +type WidgetUiStateOptions = Parameters< + NonNullable +>[1]; + +function getLocalWidgetsUiState( + widgets: Array, + widgetStateOptions: WidgetUiStateOptions, + initialUiState: IndexUiState = {} +) { + return widgets.reduce((uiState, widget) => { + if (isIndexWidget(widget)) { + return uiState; + } + + if (!widget.getWidgetUiState && !widget.getWidgetState) { + return uiState; + } + + if (widget.getWidgetUiState) { + return widget.getWidgetUiState(uiState, widgetStateOptions); + } + + return widget.getWidgetState!(uiState, widgetStateOptions); + }, initialUiState); +} + +function getLocalWidgetsSearchParameters( + widgets: Array, + widgetSearchParametersOptions: LocalWidgetSearchParametersOptions +): SearchParameters { + const { initialSearchParameters, ...rest } = widgetSearchParametersOptions; + + return widgets.reduce((state, widget) => { + if (!widget.getWidgetSearchParameters || isIndexWidget(widget)) { + return state; + } + + if (widget.dependsOn === 'search' && widget.getWidgetParameters) { + return widget.getWidgetParameters(state, rest); + } + + return widget.getWidgetSearchParameters(state, rest); + }, initialSearchParameters); +} + +function getLocalWidgetsRecommendParameters( + widgets: Array, + widgetRecommendParametersOptions: LocalWidgetRecommendParametersOptions +): RecommendParameters { + const { initialRecommendParameters, ...rest } = + widgetRecommendParametersOptions; + + return widgets.reduce((state, widget) => { + if ( + !isIndexWidget(widget) && + widget.dependsOn === 'recommend' && + widget.getWidgetParameters + ) { + return widget.getWidgetParameters(state, rest); + } + return state; + }, initialRecommendParameters); +} + +function resetPageFromWidgets(widgets: Array): void { + const indexWidgets = widgets.filter(isIndexWidget); + + if (indexWidgets.length === 0) { + return; + } + + indexWidgets.forEach((widget) => { + const widgetHelper = widget.getHelper()!; + + privateHelperSetState(widgetHelper, { + state: widgetHelper.state.resetPage(), + recommendState: widgetHelper.recommendState, + isPageReset: true, + }); + + resetPageFromWidgets(widget.getWidgets()); + }); +} + +function resolveScopedResultsFromWidgets( + widgets: Array +): ScopedResult[] { + const indexWidgets = widgets.filter(isIndexWidget); + + return indexWidgets.reduce((scopedResults, current) => { + return scopedResults.concat( + { + indexId: current.getIndexId(), + results: current.getResults()!, + helper: current.getHelper()!, + }, + ...resolveScopedResultsFromWidgets(current.getWidgets()) + ); + }, []); +} + +const index = (widgetParams: IndexWidgetParams): IndexWidget => { + if ( + widgetParams === undefined || + (widgetParams.indexName === undefined && + !widgetParams.EXPERIMENTAL_isolated) + ) { + throw new Error(withUsage('The `indexName` option is required.')); + } + + // When isolated=true, we use an empty string as the default indexName. + // This is intentional: isolated indices do not require a real index name. + const { + indexName = '', + indexId = indexName, + EXPERIMENTAL_isolated: isolated = false, + } = widgetParams; + + let localWidgets: Array = []; + let localUiState: IndexUiState = {}; + let localInstantSearchInstance: InstantSearch | null = null; + let localParent: IndexWidget | null = null; + let helper: Helper | null = null; + let derivedHelper: DerivedHelper | null = null; + let lastValidSearchParameters: SearchParameters | null = null; + let hasRecommendWidget: boolean = false; + let hasSearchWidget: boolean = false; + + return { + $$type: 'ais.index', + $$widgetType: 'ais.index', + + _isolated: isolated, + + getIndexName() { + return indexName; + }, + + getIndexId() { + return indexId; + }, + + getHelper() { + return helper; + }, + + getResults() { + if (!derivedHelper?.lastResults) return null; + + // To make the UI optimistic, we patch the state to display to the current + // one instead of the one associated with the latest results. + // This means user-driven UI changes (e.g., checked checkbox) are reflected + // immediately instead of waiting for Algolia to respond, regardless of + // the status of the network request. + derivedHelper.lastResults._state = helper!.state; + + return derivedHelper.lastResults; + }, + + getResultsForWidget(widget) { + if ( + widget.dependsOn !== 'recommend' || + isIndexWidget(widget) || + widget.$$id === undefined + ) { + return this.getResults(); + } + + if (!helper?.lastRecommendResults) { + return null; + } + + return helper.lastRecommendResults[widget.$$id]; + }, + + getPreviousState() { + return lastValidSearchParameters; + }, + + getScopedResults() { + const widgetParent = this.getParent(); + let widgetSiblings; + + if (widgetParent) { + widgetSiblings = widgetParent.getWidgets(); + } else if (indexName.length === 0) { + // The widget is the root but has no index name: + // we resolve results from its children index widgets + widgetSiblings = this.getWidgets(); + } else { + // The widget is the root and has an index name: + // we consider itself as the only sibling + widgetSiblings = [this]; + } + + return resolveScopedResultsFromWidgets(widgetSiblings); + }, + + getParent() { + return isolated ? null : localParent; + }, + + createURL( + nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) + ) { + if (typeof nextState === 'function') { + return localInstantSearchInstance!._createURL({ + [indexId]: nextState(localUiState), + }); + } + return localInstantSearchInstance!._createURL({ + [indexId]: getLocalWidgetsUiState(localWidgets, { + searchParameters: nextState, + helper: helper!, + }), + }); + }, + + scheduleLocalSearch: defer(() => { + if (isolated) { + helper?.search(); + } + }), + + getWidgets() { + return localWidgets; + }, + + addWidgets(widgets) { + if (!Array.isArray(widgets)) { + throw new Error( + withUsage('The `addWidgets` method expects an array of widgets.') + ); + } + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); + + if ( + flatWidgets.some( + (widget) => + typeof widget.init !== 'function' && + typeof widget.render !== 'function' + ) + ) { + throw new Error( + withUsage( + 'The widget definition expects a `render` and/or an `init` method.' + ) + ); + } + + flatWidgets.forEach((widget) => { + widget.parent = this; + if (isIndexWidget(widget)) { + return; + } + + if (localInstantSearchInstance && widget.dependsOn === 'recommend') { + localInstantSearchInstance._hasRecommendWidget = true; + } else if (localInstantSearchInstance) { + localInstantSearchInstance._hasSearchWidget = true; + } else if (widget.dependsOn === 'recommend') { + hasRecommendWidget = true; + } else { + hasSearchWidget = true; + } + + addWidgetId(widget); + }); + + localWidgets = localWidgets.concat(flatWidgets); + if (localInstantSearchInstance && Boolean(flatWidgets.length)) { + privateHelperSetState(helper!, { + state: getLocalWidgetsSearchParameters(localWidgets, { + uiState: localUiState, + initialSearchParameters: helper!.state, + }), + recommendState: getLocalWidgetsRecommendParameters(localWidgets, { + uiState: localUiState, + initialRecommendParameters: helper!.recommendState, + }), + _uiState: localUiState, + }); + + // 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`. + flatWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + localInstantSearchInstance!.renderState[this.getIndexId()] || {}, + createInitArgs( + localInstantSearchInstance!, + this, + localInstantSearchInstance!._initialUiState + ) + ); + + storeRenderState({ + renderState, + instantSearchInstance: localInstantSearchInstance!, + parent: this, + }); + } + }); + + flatWidgets.forEach((widget) => { + if (widget.init) { + widget.init( + createInitArgs( + localInstantSearchInstance!, + this, + localInstantSearchInstance!._initialUiState + ) + ); + } + }); + + if (isolated) { + this.scheduleLocalSearch(); + } else { + localInstantSearchInstance.scheduleSearch(); + } + } + + return this; + }, + + removeWidgets(widgets) { + if (!Array.isArray(widgets)) { + throw new Error( + withUsage('The `removeWidgets` method expects an array of widgets.') + ); + } + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); + + if (flatWidgets.some((widget) => typeof widget.dispose !== 'function')) { + throw new Error( + withUsage('The widget definition expects a `dispose` method.') + ); + } + + localWidgets = localWidgets.filter( + (widget) => flatWidgets.indexOf(widget) === -1 + ); + + localWidgets.forEach((widget) => { + widget.parent = undefined; + if (isIndexWidget(widget)) { + return; + } + + if (localInstantSearchInstance && widget.dependsOn === 'recommend') { + localInstantSearchInstance._hasRecommendWidget = true; + } else if (localInstantSearchInstance) { + localInstantSearchInstance._hasSearchWidget = true; + } else if (widget.dependsOn === 'recommend') { + hasRecommendWidget = true; + } else { + hasSearchWidget = true; + } + }); + + if (localInstantSearchInstance && Boolean(flatWidgets.length)) { + const { cleanedSearchState, cleanedRecommendState } = + flatWidgets.reduce( + (states, widget) => { + // the `dispose` method exists at this point we already assert it + const next = widget.dispose!({ + helper: helper!, + state: states.cleanedSearchState, + recommendState: states.cleanedRecommendState, + parent: this, + }); + + if (next instanceof algoliasearchHelper.RecommendParameters) { + states.cleanedRecommendState = next; + } else if (next) { + states.cleanedSearchState = next; + } + + return states; + }, + { + cleanedSearchState: helper!.state, + cleanedRecommendState: helper!.recommendState, + } + ); + + const newState = localInstantSearchInstance.future + .preserveSharedStateOnUnmount + ? getLocalWidgetsSearchParameters(localWidgets, { + uiState: localUiState, + initialSearchParameters: new algoliasearchHelper.SearchParameters( + { + index: this.getIndexName(), + } + ), + }) + : getLocalWidgetsSearchParameters(localWidgets, { + uiState: getLocalWidgetsUiState(localWidgets, { + searchParameters: cleanedSearchState, + helper: helper!, + }), + initialSearchParameters: cleanedSearchState, + }); + + localUiState = getLocalWidgetsUiState(localWidgets, { + searchParameters: newState, + helper: helper!, + }); + + helper!.setState(newState); + helper!.recommendState = cleanedRecommendState; + + if (localWidgets.length) { + if (isolated) { + this.scheduleLocalSearch(); + } else { + localInstantSearchInstance.scheduleSearch(); + } + } + } + + return this; + }, + + init({ instantSearchInstance, parent, uiState }: IndexInitOptions) { + if (helper !== null) { + // helper is already initialized, therefore we do not need to set up + // any listeners + return; + } + + localInstantSearchInstance = instantSearchInstance; + localParent = parent; + localUiState = uiState[indexId] || {}; + + // The `mainHelper` is already defined at this point. The instance is created + // inside InstantSearch at the `start` method, which occurs before the `init` + // step. + const mainHelper = instantSearchInstance.mainHelper!; + const parameters = getLocalWidgetsSearchParameters(localWidgets, { + uiState: localUiState, + initialSearchParameters: new algoliasearchHelper.SearchParameters({ + index: indexName, + }), + }); + const recommendParameters = getLocalWidgetsRecommendParameters( + localWidgets, + { + uiState: localUiState, + initialRecommendParameters: + new algoliasearchHelper.RecommendParameters(), + } + ); + + // This Helper is only used for state management we do not care about the + // `searchClient`. Only the "main" Helper created at the `InstantSearch` + // level is aware of the client. + helper = algoliasearchHelper( + mainHelper.getClient(), + parameters.index, + parameters + ); + helper.recommendState = recommendParameters; + + // We forward the call to `search` to the "main" instance of the Helper + // which is responsible for managing the queries (it's the only one that is + // aware of the `searchClient`). + helper.search = () => { + if (isolated) { + instantSearchInstance.status = 'loading'; + this.render({ instantSearchInstance }); + return instantSearchInstance.compositionID + ? helper!.searchWithComposition() + : helper!.searchOnlyWithDerivedHelpers(); + } + + if (instantSearchInstance.onStateChange) { + instantSearchInstance.onStateChange({ + uiState: instantSearchInstance.mainIndex.getWidgetUiState({}), + setUiState: (nextState) => + instantSearchInstance.setUiState(nextState, false), + }); + + // We don't trigger a search when controlled because it becomes the + // responsibility of `setUiState`. + return mainHelper; + } + + return mainHelper.search(); + }; + + helper.searchWithoutTriggeringOnStateChange = () => { + return mainHelper.search(); + }; + + // We use the same pattern for the `searchForFacetValues`. + helper.searchForFacetValues = ( + facetName, + facetValue, + maxFacetHits, + userState + ) => { + const state = mergeSearchParameters( + mainHelper.state, + ...resolveSearchParameters(this) + ).setQueryParameters(userState!); + + return mainHelper.searchForFacetValues( + facetName, + facetValue, + maxFacetHits, + state + ); + }; + + const isolatedHelper = indexName + ? helper + : algoliasearchHelper({} as SearchClient, '__empty_index__', {}); + const derivingHelper = isolated + ? isolatedHelper + : nearestIsolatedHelper(parent, mainHelper); + + derivedHelper = derivingHelper.derive( + () => + mergeSearchParameters( + mainHelper.state, + ...resolveSearchParameters(this) + ), + () => this.getHelper()!.recommendState + ); + + const indexInitialResults = + instantSearchInstance._initialResults?.[this.getIndexId()]; + + if (indexInitialResults?.results) { + // We restore the shape of the results provided to the instance to respect + // the helper's structure. + const results = new algoliasearchHelper.SearchResults( + new algoliasearchHelper.SearchParameters(indexInitialResults.state), + indexInitialResults.results + ); + + derivedHelper.lastResults = results; + helper.lastResults = results; + } + + if (indexInitialResults?.recommendResults) { + const recommendResults = new algoliasearchHelper.RecommendResults( + new algoliasearchHelper.RecommendParameters({ + params: indexInitialResults.recommendResults.params, + }), + indexInitialResults.recommendResults.results + ); + derivedHelper.lastRecommendResults = recommendResults; + helper.lastRecommendResults = recommendResults; + } + + // Subscribe to the Helper state changes for the page before widgets + // are initialized. This behavior mimics the original one of the Helper. + // It makes sense to replicate it at the `init` step. We have another + // listener on `change` below, once `init` is done. + helper.on('change', ({ isPageReset }) => { + if (isPageReset) { + resetPageFromWidgets(localWidgets); + } + }); + + derivedHelper.on('search', () => { + // The index does not manage the "staleness" of the search. This is the + // responsibility of the main instance. It does not make sense to manage + // it at the index level because it's either: all of them or none of them + // that are stalled. The queries are performed into a single network request. + instantSearchInstance.scheduleStalledRender(); + + if (__DEV__) { + checkIndexUiState({ index: this, indexUiState: localUiState }); + } + }); + + derivedHelper.on('result', ({ results }) => { + // The index does not render the results it schedules a new render + // to let all the other indices emit their own results. It allows us to + // run the render process in one pass. + instantSearchInstance.scheduleRender(); + + // the derived helper is the one which actually searches, but the helper + // which is exposed e.g. via instance.helper, doesn't search, and thus + // does not have access to lastResults, which it used to in pre-federated + // search behavior. + helper!.lastResults = results; + lastValidSearchParameters = results?._state; + }); + + // eslint-disable-next-line no-warning-comments + // TODO: listen to "result" event when events for Recommend are implemented + derivedHelper.on('recommend:result', ({ recommend }) => { + // The index does not render the results it schedules a new render + // to let all the other indices emit their own results. It allows us to + // run the render process in one pass. + instantSearchInstance.scheduleRender(); + + // the derived helper is the one which actually searches, but the helper + // which is exposed e.g. via instance.helper, doesn't search, and thus + // does not have access to lastRecommendResults. + helper!.lastRecommendResults = recommend.results; + }); + + // 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`. + localWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[this.getIndexId()] || {}, + createInitArgs(instantSearchInstance, this, uiState) + ); + + storeRenderState({ + renderState, + instantSearchInstance, + parent: this, + }); + } + }); + + localWidgets.forEach((widget) => { + warning( + // if it has NO getWidgetState or if it has getWidgetUiState, we don't warn + // aka we warn if there's _only_ getWidgetState + !widget.getWidgetState || Boolean(widget.getWidgetUiState), + 'The `getWidgetState` method is renamed `getWidgetUiState` and will no longer exist under that name in InstantSearch.js 5.x. Please use `getWidgetUiState` instead.' + ); + + if (widget.init) { + widget.init(createInitArgs(instantSearchInstance, this, uiState)); + } + }); + + // Subscribe to the Helper state changes for the `uiState` once widgets + // are initialized. Until the first render, state changes are part of the + // configuration step. This is mainly for backward compatibility with custom + // widgets. When the subscription happens before the `init` step, the (static) + // configuration of the widget is pushed in the URL. That's what we want to avoid. + // https://github.com/algolia/instantsearch/pull/994/commits/4a672ae3fd78809e213de0368549ef12e9dc9454 + helper.on('change', (event) => { + const { state } = event; + + const _uiState = (event as any)._uiState; + + localUiState = getLocalWidgetsUiState( + localWidgets, + { + searchParameters: state, + helper: helper!, + }, + _uiState || {} + ); + + // We don't trigger an internal change when controlled because it + // becomes the responsibility of `setUiState`. + if (!instantSearchInstance.onStateChange) { + instantSearchInstance.onInternalStateChange(); + } + }); + + if (indexInitialResults) { + // If there are initial results, we're not notified of the next results + // because we don't trigger an initial search. We therefore need to directly + // schedule a render that will render the results injected on the helper. + instantSearchInstance.scheduleRender(); + } + + if (hasRecommendWidget) { + instantSearchInstance._hasRecommendWidget = true; + } + if (hasSearchWidget) { + instantSearchInstance._hasSearchWidget = true; + } + }, + + render({ instantSearchInstance }: IndexRenderOptions) { + // we can't attach a listener to the error event of search, as the error + // then would no longer be thrown for global handlers. + if ( + instantSearchInstance.status === 'error' && + !instantSearchInstance.mainHelper!.hasPendingRequests() && + lastValidSearchParameters + ) { + helper!.setState(lastValidSearchParameters); + } + + // We only render index widgets if there are no results. + // This makes sure `render` is never called with `results` being `null`. + // If it's an isolated index without an index name, we render all widgets, + // as there are no results to display for the isolated index itself. + let widgetsToRender = + this.getResults() || + derivedHelper?.lastRecommendResults || + (isolated && !indexName) + ? localWidgets + : localWidgets.filter((widget) => widget.shouldRender); + + widgetsToRender = widgetsToRender.filter((widget) => { + if (!widget.shouldRender) { + return true; + } + + return widget.shouldRender({ instantSearchInstance }); + }); + + widgetsToRender.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[this.getIndexId()] || {}, + createRenderArgs( + instantSearchInstance, + this, + widget + ) as RenderOptions + ); + + storeRenderState({ + renderState, + instantSearchInstance, + parent: this, + }); + } + }); + + widgetsToRender.forEach((widget) => { + // At this point, all the variables used below are set. Both `helper` + // and `derivedHelper` have been created at the `init` step. The attribute + // `lastResults` might be `null` though. It's possible that a stalled render + // happens before the result e.g with a dynamically added index the request might + // be delayed. The render is triggered for the complete tree but some parts do + // not have results yet. + + if (widget.render) { + widget.render( + createRenderArgs( + instantSearchInstance, + this, + widget + ) as RenderOptions + ); + } + }); + }, + + dispose() { + localWidgets.forEach((widget) => { + if (widget.dispose && helper) { + // The dispose function is always called once the instance is started + // (it's an effect of `removeWidgets`). The index is initialized and + // the Helper is available. We don't care about the return value of + // `dispose` because the index is removed. We can't call `removeWidgets` + // because we want to keep the widgets on the instance, to allow idempotent + // operations on `add` & `remove`. + widget.dispose({ + helper, + state: helper.state, + recommendState: helper.recommendState, + parent: this, + }); + } + }); + + localInstantSearchInstance = null; + localParent = null; + helper?.removeAllListeners(); + helper = null; + + derivedHelper?.detach(); + derivedHelper = null; + }, + + getWidgetUiState(uiState: TUiState) { + return localWidgets + .filter(isIndexWidget) + .filter((w) => !w._isolated) + .reduce( + (previousUiState, innerIndex) => + innerIndex.getWidgetUiState(previousUiState), + { + ...uiState, + [indexId]: { + ...uiState[indexId], + ...localUiState, + }, + } + ); + }, + + getWidgetState(uiState: UiState) { + warning( + false, + 'The `getWidgetState` method is renamed `getWidgetUiState` and will no longer exist under that name in InstantSearch.js 5.x. Please use `getWidgetUiState` instead.' + ); + + return this.getWidgetUiState(uiState); + }, + + getWidgetSearchParameters(searchParameters, { uiState }) { + return getLocalWidgetsSearchParameters(localWidgets, { + uiState, + initialSearchParameters: searchParameters, + }); + }, + + shouldRender() { + return true; + }, + + refreshUiState() { + localUiState = getLocalWidgetsUiState( + localWidgets, + { + searchParameters: this.getHelper()!.state, + helper: this.getHelper()!, + }, + localUiState + ); + }, + + setIndexUiState( + indexUiState: + | TIndexUiState + | ((previousIndexUiState: TIndexUiState) => TIndexUiState) + ) { + const nextIndexUiState = + typeof indexUiState === 'function' + ? indexUiState(localUiState as TIndexUiState) + : indexUiState; + + localInstantSearchInstance!.setUiState((state) => ({ + ...state, + [indexId]: nextIndexUiState, + })); + }, + }; +}; + +export default index; + +/** + * Walk up the parent chain to find the closest isolated index, or fall back to mainHelper + */ +function nearestIsolatedHelper( + current: IndexWidget | null, + mainHelper: Helper +): Helper { + while (current) { + if (current._isolated) { + return current.getHelper()!; + } + current = current.getParent(); + } + return mainHelper; +} diff --git a/packages/instantsearch-core/src/widgets/places/places.ts b/packages/instantsearch-core/src/widgets/places/places.ts new file mode 100644 index 00000000000..1085c9686bc --- /dev/null +++ b/packages/instantsearch-core/src/widgets/places/places.ts @@ -0,0 +1,18 @@ +import type { WidgetRenderState } from '../../types'; + +export type PlacesWidgetParams = any; + +export type PlacesWidgetDescription = { + $$type: 'ais.places'; + $$widgetType: 'ais.places'; + renderState: Record; + indexRenderState: { + places: WidgetRenderState, PlacesWidgetParams>; + }; + indexUiState: { + places: { + query: string; + position: string; + }; + }; +}; diff --git a/packages/instantsearch-core/tsconfig.declaration.json b/packages/instantsearch-core/tsconfig.declaration.json new file mode 100644 index 00000000000..1e0c6449f8d --- /dev/null +++ b/packages/instantsearch-core/tsconfig.declaration.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.declaration" +} diff --git a/packages/instantsearch.js/package.json b/packages/instantsearch.js/package.json index 7e3d36f0ff9..00da76b400d 100644 --- a/packages/instantsearch.js/package.json +++ b/packages/instantsearch.js/package.json @@ -41,7 +41,8 @@ "react": ">= 0.14.0", "search-insights": "^2.17.2", "zod": "^3.25.76 || ^4", - "zod-to-json-schema": "3.24.6" + "zod-to-json-schema": "3.24.6", + "instantsearch-core": "0.1.0" }, "peerDependencies": { "algoliasearch": ">= 3.1 < 6" diff --git a/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts b/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts index 5af906bc766..eade8d296d1 100644 --- a/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts +++ b/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts @@ -1,318 +1,2 @@ -import { - addAbsolutePosition, - addQueryID, - escapeHits, - TAG_PLACEHOLDER, - checkRendering, - createDocumentationMessageGenerator, - createSendEventForHits, - noop, - warning, -} from '../../lib/utils'; - -import type { SendEventForHits } from '../../lib/utils'; -import type { Hit, Connector, WidgetRenderState } from '../../types'; -import type { SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'autocomplete', - connector: true, -}); - -export type TransformItemsIndicesConfig = { - indexName: string; - indexId: string; - hits: Hit[]; - results: SearchResults; -}; - -export type AutocompleteConnectorParams = { - /** - * Escapes HTML entities from hits string values. - * - * @default `true` - */ - escapeHTML?: boolean; - /** - * Transforms the items of all indices. - */ - transformItems?: ( - indices: TransformItemsIndicesConfig[] - ) => TransformItemsIndicesConfig[]; - /** - * Enable usage of future Autocomplete behavior. - */ - future?: { - /** - * When set to true, `currentRefinement` is `undefined` when no query has - * been set (instead of an empty string). This lets consumers distinguish - * between "initial/submitted state" and "user explicitly cleared the input". - * - * @default `false` - */ - undefinedEmptyQuery?: boolean; - }; -}; - -export type AutocompleteRenderState = { - /** - * The current value of the query. - * When `future.undefinedEmptyQuery` is `true`, this is `undefined` when no - * query has been set yet (e.g. on init or after submit). - */ - currentRefinement: string | undefined; - - /** - * The indices this widget has access to. - */ - indices: Array<{ - /** - * The name of the index - */ - indexName: string; - - /** - * The id of the index - */ - indexId: string; - - /** - * The resolved hits from the index matching the query. - */ - hits: Hit[]; - - /** - * The full results object from the Algolia API. - */ - results: SearchResults; - - /** - * Send event to insights middleware - */ - sendEvent: SendEventForHits; - }>; - - /** - * Searches into the indices with the provided query. - */ - refine: (query: string) => void; -}; - -export type AutocompleteWidgetDescription = { - $$type: 'ais.autocomplete'; - renderState: AutocompleteRenderState; - indexRenderState: { - autocomplete: WidgetRenderState< - AutocompleteRenderState, - AutocompleteConnectorParams - >; - }; - indexUiState: { query: string }; -}; - -export type AutocompleteConnector = Connector< - AutocompleteWidgetDescription, - AutocompleteConnectorParams ->; - -const connectAutocomplete: AutocompleteConnector = function connectAutocomplete( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - // @MAJOR: this can default to false - escapeHTML = true, - transformItems = ((indices) => indices) as NonNullable< - AutocompleteConnectorParams['transformItems'] - >, - future: { undefinedEmptyQuery = false } = {}, - } = widgetParams || {}; - - warning( - !(widgetParams as any).indices, - ` -The option \`indices\` has been removed from the Autocomplete connector. - -The indices to target are now inferred from the widgets tree. -${ - Array.isArray((widgetParams as any).indices) - ? ` -An alternative would be: - -const autocomplete = connectAutocomplete(renderer); - -search.addWidgets([ - ${(widgetParams as any).indices - .map(({ value }: { value: string }) => `index({ indexName: '${value}' }),`) - .join('\n ')} - autocomplete() -]); -` - : '' -} - ` - ); - - type ConnectorState = { - refine?: (query: string) => void; - }; - - const connectorState: ConnectorState = {}; - - return { - $$type: 'ais.autocomplete', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - const renderState = this.getWidgetRenderState(renderOptions); - - renderState.indices.forEach(({ sendEvent, hits }) => { - sendEvent('view:internal', hits); - }); - - renderFn( - { - ...renderState, - instantSearchInstance, - }, - false - ); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - autocomplete: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ - helper, - state, - scopedResults, - instantSearchInstance, - }) { - if (!connectorState.refine) { - connectorState.refine = (query: string) => { - helper.setQuery(query).search(); - }; - } - - const sendEventMap: Record = {}; - const indices = scopedResults.map((scopedResult) => { - // We need to escape the hits because highlighting - // exposes HTML tags to the end-user. - if (scopedResult.results) { - scopedResult.results.hits = escapeHTML - ? escapeHits(scopedResult.results.hits) - : scopedResult.results.hits; - } - - sendEventMap[scopedResult.indexId] = createSendEventForHits({ - instantSearchInstance, - helper: scopedResult.helper, - widgetType: this.$$type, - }); - - const hits = scopedResult.results - ? addQueryID( - addAbsolutePosition( - scopedResult.results.hits, - scopedResult.results.page, - scopedResult.results.hitsPerPage - ), - scopedResult.results.queryID - ) - : []; - - return { - indexId: scopedResult.indexId, - indexName: scopedResult.results?.index || '', - hits, - results: scopedResult.results || ({} as unknown as SearchResults), - }; - }); - - return { - currentRefinement: undefinedEmptyQuery - ? state.query - : state.query || '', - indices: transformItems(indices).map((transformedIndex) => ({ - ...transformedIndex, - sendEvent: sendEventMap[transformedIndex.indexId], - })), - refine: connectorState.refine, - widgetParams, - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - const query = undefinedEmptyQuery - ? searchParameters.query - : searchParameters.query || ''; - - if (!query || query === '' || (uiState && uiState.query === query)) { - return uiState; - } - - return { - ...uiState, - query, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const parameters = { - query: undefinedEmptyQuery ? uiState.query : uiState.query || '', - }; - - if (!escapeHTML) { - return searchParameters.setQueryParameters(parameters); - } - - return searchParameters.setQueryParameters({ - ...parameters, - ...TAG_PLACEHOLDER, - }); - }, - - dispose({ state }) { - unmountFn(); - - const stateWithoutQuery = state.setQueryParameter('query', undefined); - - if (!escapeHTML) { - return stateWithoutQuery; - } - - return stateWithoutQuery.setQueryParameters( - Object.keys(TAG_PLACEHOLDER).reduce( - (acc, key) => ({ - ...acc, - [key]: undefined, - }), - {} - ) - ); - }, - }; - }; -}; - -export default connectAutocomplete; +export { connectAutocomplete as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/breadcrumb/connectBreadcrumb.ts b/packages/instantsearch.js/src/connectors/breadcrumb/connectBreadcrumb.ts index 843215f994e..d8727387d7d 100644 --- a/packages/instantsearch.js/src/connectors/breadcrumb/connectBreadcrumb.ts +++ b/packages/instantsearch.js/src/connectors/breadcrumb/connectBreadcrumb.ts @@ -1,359 +1,2 @@ -import { - checkRendering, - warning, - createDocumentationMessageGenerator, - isEqual, - noop, -} from '../../lib/utils'; - -import type { - Connector, - TransformItems, - CreateURL, - WidgetRenderState, - IndexUiState, -} from '../../types'; -import type { SearchParameters, SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'breadcrumb', - connector: true, -}); - -export type BreadcrumbConnectorParamsItem = { - /** - * Label of the category or subcategory. - */ - label: string; - - /** - * Value of breadcrumb item. - */ - value: string | null; -}; - -export type BreadcrumbConnectorParams = { - /** - * Attributes to use to generate the hierarchy of the breadcrumb. - */ - attributes: string[]; - - /** - * Prefix path to use if the first level is not the root level. - */ - rootPath?: string; - - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; - - /** - * The level separator used in the records. - * - * @default '>' - */ - separator?: string; -}; - -export type BreadcrumbRenderState = { - /** - * Creates the URL for a single item name in the list. - */ - createURL: CreateURL; - - /** - * Array of objects defining the different values and labels. - */ - items: BreadcrumbConnectorParamsItem[]; - - /** - * Sets the path of the hierarchical filter and triggers a new search. - */ - refine: (value: BreadcrumbConnectorParamsItem['value']) => void; - - /** - * True if refinement can be applied. - */ - canRefine: boolean; -}; - -export type BreadcrumbWidgetDescription = { - $$type: 'ais.breadcrumb'; - renderState: BreadcrumbRenderState; - indexRenderState: { - breadcrumb: { - [rootAttribute: string]: WidgetRenderState< - BreadcrumbRenderState, - BreadcrumbConnectorParams - >; - }; - }; -}; - -export type BreadcrumbConnector = Connector< - BreadcrumbWidgetDescription, - BreadcrumbConnectorParams ->; - -const connectBreadcrumb: BreadcrumbConnector = function connectBreadcrumb( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - type ConnectorState = { - refine?: BreadcrumbRenderState['refine']; - createURL?: BreadcrumbRenderState['createURL']; - }; - - const connectorState: ConnectorState = {}; - - return (widgetParams) => { - const { - attributes, - separator = ' > ', - rootPath = null, - transformItems = ((items) => items) as NonNullable< - BreadcrumbConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if (!attributes || !Array.isArray(attributes) || attributes.length === 0) { - throw new Error( - withUsage('The `attributes` option expects an array of strings.') - ); - } - - const [hierarchicalFacetName] = attributes; - - function getRefinedState( - state: SearchParameters, - facetValue: BreadcrumbConnectorParamsItem['value'] - ) { - if (!facetValue) { - const breadcrumb = state.getHierarchicalFacetBreadcrumb( - hierarchicalFacetName - ); - if (breadcrumb.length === 0) { - return state; - } else { - return state - .resetPage() - .toggleFacetRefinement(hierarchicalFacetName, breadcrumb[0]); - } - } - return state - .resetPage() - .toggleFacetRefinement(hierarchicalFacetName, facetValue); - } - - return { - $$type: 'ais.breadcrumb', - - init(initOptions) { - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - }, - - dispose() { - unmountFn(); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - breadcrumb: { - ...renderState.breadcrumb, - [hierarchicalFacetName]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState({ helper, createURL, results, state }) { - function getItems() { - // The hierarchicalFacets condition is required for flavors - // that render immediately with empty results, without relying - // on init() (like React InstantSearch). - if (!results || state.hierarchicalFacets.length === 0) { - return []; - } - - const facetValues = results.getFacetValues(hierarchicalFacetName, {}); - const facetItems = - facetValues && !Array.isArray(facetValues) && facetValues.data - ? facetValues.data - : []; - const items = transformItems( - shiftItemsValues(prepareItems(facetItems)), - { - results, - } - ); - - return items; - } - - const items = getItems(); - - if (!connectorState.createURL) { - connectorState.createURL = (facetValue) => { - return createURL((uiState) => - this.getWidgetUiState!(uiState, { - searchParameters: getRefinedState(helper.state, facetValue), - helper, - }) - ); - }; - } - - if (!connectorState.refine) { - connectorState.refine = (facetValue) => { - helper.setState(getRefinedState(helper.state, facetValue)).search(); - }; - } - - return { - canRefine: items.length > 0, - createURL: connectorState.createURL, - items, - refine: connectorState.refine, - widgetParams, - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - const path = searchParameters.getHierarchicalFacetBreadcrumb( - hierarchicalFacetName - ); - - return removeEmptyRefinementsFromUiState( - { - ...uiState, - hierarchicalMenu: { - ...uiState.hierarchicalMenu, - [hierarchicalFacetName]: path, - }, - }, - hierarchicalFacetName - ); - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const values = - uiState.hierarchicalMenu && - uiState.hierarchicalMenu[hierarchicalFacetName]; - - if ( - searchParameters.isConjunctiveFacet(hierarchicalFacetName) || - searchParameters.isDisjunctiveFacet(hierarchicalFacetName) - ) { - warning( - false, - `HierarchicalMenu: Attribute "${hierarchicalFacetName}" is already used by another widget applying conjunctive or disjunctive faceting. -As this is not supported, please make sure to remove this other widget or this HierarchicalMenu widget will not work at all.` - ); - - return searchParameters; - } - - if (searchParameters.isHierarchicalFacet(hierarchicalFacetName)) { - const facet = searchParameters.getHierarchicalFacetByName( - hierarchicalFacetName - ); - - warning( - isEqual(facet.attributes, attributes) && - facet.separator === separator && - facet.rootPath === rootPath, - 'Using Breadcrumb and HierarchicalMenu on the same facet with different options overrides the configuration of the HierarchicalMenu.' - ); - } - - const withFacetConfiguration = searchParameters - .removeHierarchicalFacet(hierarchicalFacetName) - .addHierarchicalFacet({ - name: hierarchicalFacetName, - attributes, - separator, - rootPath, - }); - - if (!values) { - return withFacetConfiguration.setQueryParameters({ - hierarchicalFacetsRefinements: { - ...withFacetConfiguration.hierarchicalFacetsRefinements, - [hierarchicalFacetName]: [], - }, - }); - } - - return withFacetConfiguration.addHierarchicalFacetRefinement( - hierarchicalFacetName, - values.join(separator) - ); - }, - }; - }; -}; - -function prepareItems(data: SearchResults.HierarchicalFacet[]) { - return data.reduce((result, currentItem) => { - if (currentItem.isRefined) { - result.push({ - label: currentItem.name, - value: currentItem.escapedValue, - }); - if (Array.isArray(currentItem.data)) { - result = result.concat(prepareItems(currentItem.data)); - } - } - return result; - }, []); -} - -function shiftItemsValues(array: BreadcrumbConnectorParamsItem[]) { - return array.map((x, idx) => ({ - label: x.label, - value: idx + 1 === array.length ? null : array[idx + 1].value, - })); -} - -function removeEmptyRefinementsFromUiState( - indexUiState: IndexUiState, - attribute: string -): IndexUiState { - if (!indexUiState.hierarchicalMenu) { - return indexUiState; - } - - if ( - !indexUiState.hierarchicalMenu[attribute] || - !indexUiState.hierarchicalMenu[attribute].length - ) { - delete indexUiState.hierarchicalMenu[attribute]; - } - - if (Object.keys(indexUiState.hierarchicalMenu).length === 0) { - delete indexUiState.hierarchicalMenu; - } - - return indexUiState; -} - -export default connectBreadcrumb; +export { connectBreadcrumb as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 7b3c8b0055c..f54ad79f714 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -17,9 +17,11 @@ import type { UIMessage, ChatTransport } from '../../../lib/ai-lite'; import type { InstantSearch, IndexWidget } from '../../../types'; import type { ChatConnectorParams } from '../connectChat'; -jest.mock('../../../lib/utils/sendChatMessageFeedback', () => ({ - sendChatMessageFeedback: jest.fn(() => Promise.resolve(new Response('{}'))), -})); +const fetchMock = jest.fn(() => Promise.resolve(new Response('{}'))); +Object.defineProperty(globalThis, 'fetch', { + value: fetchMock, + writable: true, +}); describe('connectChat', () => { const getInitializedWidget = (widgetParams: ChatConnectorParams = {}) => { @@ -417,10 +419,7 @@ describe('connectChat', () => { }); it('prevents double voting on the same message', () => { - const { sendChatMessageFeedback: mockedFn } = jest.requireMock( - '../../../lib/utils/sendChatMessageFeedback' - ); - mockedFn.mockClear(); + fetchMock.mockClear(); const { getRenderState } = getInitializedWidget({ agentId: 'agentId', @@ -431,7 +430,7 @@ describe('connectChat', () => { renderState.sendChatMessageFeedback!('msg-1', 1); renderState.sendChatMessageFeedback!('msg-1', 0); - expect(mockedFn).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 2129acaa5ca..e03979b8caa 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -1,766 +1,2 @@ -import { - DefaultChatTransport, - lastAssistantMessageIsCompleteWithToolCalls, -} from '../../lib/ai-lite'; -import { Chat, SearchIndexToolType } from '../../lib/chat'; -import { - checkRendering, - clearRefinements, - createDocumentationMessageGenerator, - createSendEventForHits, - getAlgoliaAgent, - getAppIdAndApiKey, - getRefinements, - noop, - sendChatMessageFeedback, - uniq, - warning, -} from '../../lib/utils'; -import { flat } from '../../lib/utils/flat'; - -import type { - AbstractChat, - ChatInit as ChatInitAi, - UIMessage, -} from '../../lib/chat'; -import type { SendEventForHits } from '../../lib/utils'; -import type { - Connector, - Renderer, - Unmounter, - UnknownWidgetParams, - InstantSearch, - IndexUiState, - IndexWidget, - WidgetRenderState, - IndexRenderState, -} from '../../types'; -import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; -import type { - AddToolResultWithOutput, - UserClientSideTool, - ClientSideTools, - ClientSideTool, -} from 'instantsearch-ui-components'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'chat', - connector: true, -}); - -export type ChatRenderState = { - indexUiState: IndexUiState; - input: string; - open: boolean; - /** - * Sends an event to the Insights middleware. - */ - sendEvent: SendEventForHits; - setIndexUiState: IndexWidget['setIndexUiState']; - setInput: (input: string) => void; - setOpen: (open: boolean) => void; - /** - * Opens the chat (if needed) and focuses the prompt input. - */ - focusInput: () => void; - /** - * Updates the `messages` state locally. This is useful when you want to - * edit the messages on the client, and then trigger the `reload` method - * manually to regenerate the AI response. - */ - setMessages: ( - messages: TUiMessage[] | ((m: TUiMessage[]) => TUiMessage[]) - ) => void; - /** - * Whether the chat is in the process of clearing messages. - */ - isClearing: boolean; - /** - * Clear all messages. - */ - clearMessages: () => void; - /** - * Callback to be called when the clear transition ends. - */ - onClearTransitionEnd: () => void; - /** - * Tools configuration with addToolResult bound, ready to be used by the UI. - */ - tools: ClientSideTools; - /** - * Suggestions received from the AI model. - */ - suggestions?: string[]; - /** - * Sends feedback (thumbs up/down) for an assistant message. - * Only available when using `agentId` and `feedback` is true. - * Returns `undefined` otherwise. - */ - sendChatMessageFeedback?: (messageId: string, vote: 0 | 1) => void; - /** - * Map of message IDs to their feedback state. - * 'sending' means the request is in flight, 0/1 means the vote was recorded. - */ - feedbackState: Record; -} & Pick< - AbstractChat, - | 'addToolResult' - | 'clearError' - | 'error' - | 'id' - | 'messages' - | 'regenerate' - | 'resumeStream' - | 'sendMessage' - | 'status' - | 'stop' ->; - -export type ChatInitWithoutTransport = Omit< - ChatInitAi, - 'transport' ->; - -export type ChatTransport = { - transport?: ConstructorParameters[0]; -} & ( - | { - agentId: string; - /** - * Whether to enable feedback (thumbs up/down) on assistant messages. - */ - feedback?: boolean; - } - | { agentId?: undefined; feedback?: never } -); - -export type ApplyFiltersParams = { - query?: string; - facetFilters?: string[][]; -}; - -export type ChatInit = - ChatInitWithoutTransport & ChatTransport; - -export type ChatConnectorParams = ( - | { chat: Chat } - | ChatInit -) & { - /** - * Whether to resume an ongoing chat generation stream. - */ - resume?: boolean; - /** - * Configuration for client-side tools. - */ - tools?: Record>; - /** - * Identifier of this type of chat widget. This is used for the key in renderState. - * @default 'chat' - */ - type?: string; - /** - * Additional context to send with each user message (e.g. current page info). - * This context is included in the message parts sent to the API but is not - * displayed in the chat UI. - * Can be a static object or a function that returns the context at send time. - */ - context?: Record | (() => Record); - /** - * A message to send automatically when the chat is initialized. - * - * This message is only sent when the chat has no existing messages yet. If - * messages were restored or otherwise already exist when the widget starts, - * this message is not sent. - * - * When `resume` is enabled, this message is not sent. - */ - initialUserMessage?: string; - /** - * Messages to pre-populate the chat with when it is initialized. - * - * These messages are set without triggering an AI response. They are only - * applied when the chat has no existing messages yet. If messages were - * restored or otherwise already exist when the widget starts, these messages - * are not applied. - * - * When `resume` is enabled, these messages are not applied. - * - * `initialUserMessage` is sent after `initialMessages` are applied, so an - * assistant welcome followed by a user prompt works. - */ - initialMessages?: TUiMessage[]; -}; - -export type ChatWidgetDescription = { - $$type: 'ais.chat'; - renderState: ChatRenderState; - indexRenderState: { - // In IndexRenderState, the key is always 'chat', but in the widgetParams you can customize it with the `type` parameter - chat: WidgetRenderState< - ChatRenderState, - ChatConnectorParams - >; - }; -}; - -export type ChatConnector = Connector< - ChatWidgetDescription, - ChatConnectorParams ->; - -function getAttributesToClear({ - results, - helper, -}: { - results: SearchResults; - helper: AlgoliaSearchHelper; -}) { - return uniq( - getRefinements(results, helper.state, true).map( - (refinement) => refinement.attribute - ) - ); -} - -function updateStateFromSearchToolInput( - params: ApplyFiltersParams, - helper: AlgoliaSearchHelper -) { - // clear all filters first - const attributesToClear = getAttributesToClear({ - results: helper.lastResults!, - helper, - }); - - helper.setState( - clearRefinements({ - helper, - attributesToClear, - }) - ); - - if (params.facetFilters) { - const attributes = flat(params.facetFilters).map((filter) => { - const [attribute, value] = filter.split(':'); - - return { attribute, value }; - }); - - attributes.forEach(({ attribute, value }) => { - if ( - !helper.state.isConjunctiveFacet(attribute) && - !helper.state.isHierarchicalFacet(attribute) && - !helper.state.isDisjunctiveFacet(attribute) - ) { - const s = helper.state.addDisjunctiveFacet(attribute); - helper.setState(s); - helper.toggleFacetRefinement(attribute, value); - } else { - const attr = - helper.state.hierarchicalFacets.find( - (facet) => facet.name === attribute - )?.name || attribute; - - helper.toggleFacetRefinement(attr, value); - } - }); - } - - if (params.query) { - helper.setQuery(params.query); - } - - helper.search(); - - return helper.state; -} - -export default (function connectChat( - renderFn: Renderer, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return ( - widgetParams: TWidgetParams & ChatConnectorParams - ) => { - warning(false, 'Chat is not yet stable and will change in the future.'); - - const { - resume = false, - tools = {}, - type = 'chat', - context, - initialUserMessage, - initialMessages, - ...options - } = widgetParams || {}; - - let _chatInstance: Chat; - let input = ''; - let open = false; - let isClearing = false; - let sendEvent: SendEventForHits; - let setInput: ChatRenderState['setInput']; - let setOpen: ChatRenderState['setOpen']; - let focusInput: ChatRenderState['focusInput']; - let setIsClearing: (value: boolean) => void; - let setFeedbackState: (messageId: string, state: 'sending' | 0 | 1) => void; - - const agentId = 'agentId' in options ? options.agentId : undefined; - let feedbackState: ChatRenderState['feedbackState'] = {}; - let _sendChatMessageFeedback: ChatRenderState['sendChatMessageFeedback']; - let feedbackAbortController: AbortController | undefined; - - // Extract suggestions from the last assistant message's data-suggestions part - const getSuggestionsFromMessages = (messages: TUiMessage[]) => { - // Find the last assistant message (iterate from end) - const lastAssistantMessage = [...messages] - .reverse() - .find((message) => message.role === 'assistant' && message.parts); - - if (!lastAssistantMessage?.parts) { - return undefined; - } - - // Find the data-suggestions part - const suggestionsPart = lastAssistantMessage.parts.find( - ( - part - ): part is { - type: `data-${string}`; - data: { suggestions: string[] }; - } => - 'type' in part && - part.type === 'data-suggestions' && - 'data' in part && - Array.isArray( - (part as { data?: { suggestions?: unknown } }).data?.suggestions - ) - ); - - return suggestionsPart?.data.suggestions; - }; - - const setMessages = ( - messagesParam: TUiMessage[] | ((m: TUiMessage[]) => TUiMessage[]) - ) => { - if (typeof messagesParam === 'function') { - messagesParam = messagesParam(_chatInstance.messages); - } - _chatInstance.messages = messagesParam; - }; - - const clearMessages = () => { - if (!_chatInstance.messages || _chatInstance.messages.length === 0) { - return; - } - const status = _chatInstance.status; - if (status === 'submitted' || status === 'streaming') { - _chatInstance.stop(); - } - setIsClearing(true); - }; - - const onClearTransitionEnd = () => { - setMessages([]); - _chatInstance.clearError(); - feedbackState = {}; - setIsClearing(false); - }; - - const makeChatInstance = (instantSearchInstance: InstantSearch) => { - let transport; - const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); - - // Filter out custom data parts (like data-suggestions) that the backend doesn't accept - const filterDataParts = (messages: UIMessage[]): UIMessage[] => - messages.map((message) => ({ - ...message, - parts: message.parts?.filter( - (part) => !('type' in part && part.type.startsWith('data-')) - ), - })); - - if ('transport' in options && options.transport) { - const originalPrepare = options.transport.prepareSendMessagesRequest; - transport = new DefaultChatTransport({ - ...options.transport, - prepareSendMessagesRequest: (params) => { - // Call the original prepareSendMessagesRequest if it exists, - // otherwise construct the default body - const preparedOrPromise = originalPrepare - ? originalPrepare(params) - : { body: { ...params } }; - // Then filter out data-* parts - const applyFilter = (prepared: { body: object }) => ({ - ...prepared, - body: { - ...prepared.body, - messages: filterDataParts( - (prepared.body as { messages: UIMessage[] }).messages - ), - }, - }); - - // Handle both sync and async cases - if (preparedOrPromise && 'then' in preparedOrPromise) { - return preparedOrPromise.then(applyFilter); - } - return applyFilter(preparedOrPromise); - }, - }); - } - if ('agentId' in options && options.agentId) { - if (!appId || !apiKey) { - throw new Error( - withUsage( - 'Could not extract Algolia credentials from the search client.' - ) - ); - } - - const baseApi = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`; - transport = new DefaultChatTransport({ - api: baseApi, - headers: { - 'x-algolia-application-id': appId, - 'x-algolia-api-Key': apiKey, - 'x-algolia-agent': getAlgoliaAgent(instantSearchInstance.client), - }, - prepareSendMessagesRequest: ({ messages, trigger, ...rest }) => { - return { - // Bypass cache when regenerating to ensure fresh responses - api: - trigger === 'regenerate-message' - ? `${baseApi}&cache=false` - : baseApi, - body: { - ...rest, - messages: filterDataParts(messages), - }, - }; - }, - }); - } - if (!transport) { - throw new Error( - withUsage('You need to provide either an `agentId` or a `transport`.') - ); - } - - if ('chat' in options) { - return options.chat; - } - - return new Chat({ - ...options, - transport, - sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, - shouldRepairToolInput(toolName) { - let tool = tools[toolName]; - if (!tool && toolName.startsWith(`${SearchIndexToolType}_`)) { - tool = tools[SearchIndexToolType]; - } - if (!tool) return true; - return Boolean(tool.streamInput); - }, - onToolCall({ toolCall }) { - let tool = tools[toolCall.toolName]; - - // Compatibility shim with Algolia MCP Server search tool - if ( - !tool && - toolCall.toolName.startsWith(`${SearchIndexToolType}_`) - ) { - tool = tools[SearchIndexToolType]; - } - - if (!tool) { - if (__DEV__) { - throw new Error( - `No tool implementation found for "${toolCall.toolName}". Please provide a tool implementation in the \`tools\` prop.` - ); - } - - return _chatInstance.addToolResult({ - output: `No tool implemented for "${toolCall.toolName}".`, - tool: toolCall.toolName, - toolCallId: toolCall.toolCallId, - }); - } - - if (tool.onToolCall) { - const addToolResult: AddToolResultWithOutput = ({ output }) => - _chatInstance.addToolResult({ - output, - tool: toolCall.toolName, - toolCallId: toolCall.toolCallId, - }); - - return tool.onToolCall({ - ...toolCall, - addToolResult, - }); - } - - return Promise.resolve(); - }, - } as ChatInitAi & { agentId?: string }); - }; - - return { - $$type: 'ais.chat', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - _chatInstance = makeChatInstance(instantSearchInstance); - - const render = () => { - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - false - ); - }; - - setOpen = (o) => { - open = o; - render(); - }; - - focusInput = () => { - setOpen(true); - }; - - setInput = (i) => { - input = i; - render(); - }; - - setIsClearing = (value) => { - isClearing = value; - render(); - }; - - setFeedbackState = (messageId, state) => { - feedbackState = { ...feedbackState, [messageId]: state }; - render(); - }; - - const feedback = - 'feedback' in options ? options.feedback : undefined; - if (agentId && feedback) { - const [appId, apiKey] = getAppIdAndApiKey( - initOptions.instantSearchInstance.client - ); - - if (!appId || !apiKey) { - throw new Error( - withUsage( - 'Could not extract Algolia credentials from the search client.' - ) - ); - } - - feedbackAbortController = new AbortController(); - _sendChatMessageFeedback = (messageId: string, vote: 0 | 1) => { - if (feedbackState[messageId] !== undefined) { - return; - } - setFeedbackState(messageId, 'sending'); - sendChatMessageFeedback({ - agentId, - vote, - messageId, - appId, - apiKey, - }).finally(() => { - setFeedbackState(messageId, vote); - }); - }; - } - - const hasExistingMessages = _chatInstance.messages.length > 0; - - // Set initialMessages before registering callbacks to avoid - // triggering re-renders during init - if (initialMessages?.length && !resume && !hasExistingMessages) { - _chatInstance.messages = initialMessages; - } - - _chatInstance['~registerErrorCallback'](render); - _chatInstance['~registerMessagesCallback'](render); - _chatInstance['~registerStatusCallback'](render); - - if (resume) { - _chatInstance.resumeStream(); - } - - if (initialUserMessage && !resume && !hasExistingMessages) { - _chatInstance.sendMessage({ text: initialUserMessage }); - } - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - }, - - getRenderState( - renderState, - renderOptions - // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition - ): IndexRenderState & ChatWidgetDescription['indexRenderState'] { - return { - ...renderState, - // Type is casted to 'chat' here, because in the IndexRenderState the key is always 'chat' - [type as 'chat']: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState(renderOptions) { - const { instantSearchInstance, parent, helper } = renderOptions; - if (!_chatInstance) { - this.init!({ ...renderOptions, uiState: {}, results: undefined }); - } - - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance: renderOptions.instantSearchInstance, - helper: renderOptions.helper, - widgetType: this.$$type, - }); - } - - function applyFilters(params: ApplyFiltersParams) { - return updateStateFromSearchToolInput(params, helper); - } - - const toolsWithAddToolResult: ClientSideTools = {}; - Object.entries(tools).forEach(([key, tool]) => { - const toolWithAddToolResult: ClientSideTool = { - ...tool, - addToolResult: _chatInstance.addToolResult, - applyFilters, - sendEvent, - }; - toolsWithAddToolResult[key] = toolWithAddToolResult; - }); - - const sendMessageWithContext: typeof _chatInstance.sendMessage = ( - message, - ...rest - ) => { - if (!context || !message) { - return _chatInstance.sendMessage(message, ...rest); - } - - const resolvedContext = - typeof context === 'function' ? context() : context; - - let serializedContext: string; - try { - serializedContext = JSON.stringify(resolvedContext); - } catch { - warning( - false, - 'Could not serialize chat context. The message will be sent without context.' - ); - return _chatInstance.sendMessage(message, ...rest); - } - - const contextTextPart = { - type: 'text' as const, - text: ''.concat(serializedContext).concat(''), - }; - - if ('parts' in message && message.parts) { - return _chatInstance.sendMessage({ - ...message, - parts: [contextTextPart, ...message.parts], - text: undefined, - files: undefined, - }, ...rest); - } - - const textContent = - 'text' in message && message.text ? message.text : ''; - - return _chatInstance.sendMessage({ - parts: [ - contextTextPart, - { type: 'text' as const, text: textContent }, - ], - metadata: message.metadata, - messageId: message.messageId, - files: undefined, - text: undefined, - }, ...rest); - }; - - return { - indexUiState: instantSearchInstance.getUiState()[parent.getIndexId()], - input, - open, - sendEvent, - setIndexUiState: parent.setIndexUiState.bind(parent), - setInput, - setOpen, - focusInput, - setMessages, - suggestions: getSuggestionsFromMessages(_chatInstance.messages), - isClearing, - clearMessages, - onClearTransitionEnd, - tools: toolsWithAddToolResult, - sendChatMessageFeedback: _sendChatMessageFeedback, - feedbackState, - widgetParams, - - // Chat instance render state - addToolResult: _chatInstance.addToolResult, - clearError: _chatInstance.clearError, - error: _chatInstance.error, - id: _chatInstance.id, - messages: _chatInstance.messages, - regenerate: _chatInstance.regenerate, - resumeStream: _chatInstance.resumeStream, - sendMessage: sendMessageWithContext, - status: _chatInstance.status, - stop: _chatInstance.stop, - }; - }, - - dispose() { - feedbackAbortController?.abort(); - unmountFn(); - }, - - shouldRender() { - return true; - }, - - get chatInstance() { - return _chatInstance; - }, - }; - }; -} satisfies ChatConnector); +export { connectChat as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/clear-refinements/connectClearRefinements.ts b/packages/instantsearch.js/src/connectors/clear-refinements/connectClearRefinements.ts index b5539cbfc94..cc15704a501 100644 --- a/packages/instantsearch.js/src/connectors/clear-refinements/connectClearRefinements.ts +++ b/packages/instantsearch.js/src/connectors/clear-refinements/connectClearRefinements.ts @@ -1,272 +1,2 @@ -import { - checkRendering, - clearRefinements, - getRefinements, - createDocumentationMessageGenerator, - noop, - uniq, - mergeSearchParameters, -} from '../../lib/utils'; - -import type { - TransformItems, - CreateURL, - Connector, - WidgetRenderState, - ScopedResult, -} from '../../types'; -import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'clear-refinements', - connector: true, -}); - -export type ClearRefinementsConnectorParams = { - /** - * The attributes to include in the refinements to clear (all by default). Cannot be used with `excludedAttributes`. - */ - includedAttributes?: string[]; - - /** - * The attributes to exclude from the refinements to clear. Cannot be used with `includedAttributes`. - */ - excludedAttributes?: string[]; - - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; -}; - -export type ClearRefinementsRenderState = { - /** - * Triggers the clear of all the currently refined values. - */ - refine: () => void; - - /** - * Indicates if search state is refined. - * @deprecated prefer reading canRefine - */ - hasRefinements: boolean; - - /** - * Indicates if search state can be refined. - */ - canRefine: boolean; - - /** - * Creates a url for the next state when refinements are cleared. - */ - createURL: CreateURL; -}; - -export type ClearRefinementsWidgetDescription = { - $$type: 'ais.clearRefinements'; - renderState: ClearRefinementsRenderState; - indexRenderState: { - clearRefinements: WidgetRenderState< - ClearRefinementsRenderState, - ClearRefinementsConnectorParams - >; - }; -}; - -export type ClearRefinementsConnector = Connector< - ClearRefinementsWidgetDescription, - ClearRefinementsConnectorParams ->; - -type AttributesToClear = { - helper: AlgoliaSearchHelper; - items: string[]; -}; - -const connectClearRefinements: ClearRefinementsConnector = - function connectClearRefinements(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - includedAttributes = [], - excludedAttributes = ['query'], - transformItems = ((items) => items) as NonNullable< - ClearRefinementsConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if ( - widgetParams && - widgetParams.includedAttributes && - widgetParams.excludedAttributes - ) { - throw new Error( - withUsage( - 'The options `includedAttributes` and `excludedAttributes` cannot be used together.' - ) - ); - } - - type ConnectorState = { - refine: () => void; - createURL: () => string; - attributesToClear: AttributesToClear[]; - }; - - const connectorState: ConnectorState = { - refine: noop, - createURL: () => '', - attributesToClear: [], - }; - - const cachedRefine = () => connectorState.refine(); - const cachedCreateURL = () => connectorState.createURL(); - - return { - $$type: 'ais.clearRefinements', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose() { - unmountFn(); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - clearRefinements: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ createURL, scopedResults, results }) { - connectorState.attributesToClear = scopedResults.reduce< - Array> - >((attributesToClear, scopedResult) => { - return attributesToClear.concat( - getAttributesToClear({ - scopedResult, - includedAttributes, - excludedAttributes, - transformItems, - results, - }) - ); - }, []); - - connectorState.refine = () => { - connectorState.attributesToClear.forEach( - ({ helper: indexHelper, items }) => { - indexHelper - .setState( - clearRefinements({ - helper: indexHelper, - attributesToClear: items, - }) - ) - .search(); - } - ); - }; - - connectorState.createURL = () => { - return createURL( - mergeSearchParameters( - ...connectorState.attributesToClear.map( - ({ helper: indexHelper, items }) => { - return clearRefinements({ - helper: indexHelper, - attributesToClear: items, - }); - } - ) - ) - ); - }; - - const canRefine = connectorState.attributesToClear.some( - (attributeToClear) => attributeToClear.items.length > 0 - ); - - return { - canRefine, - hasRefinements: canRefine, - refine: cachedRefine, - createURL: cachedCreateURL, - widgetParams, - }; - }, - }; - }; - }; - -function getAttributesToClear({ - scopedResult, - includedAttributes, - excludedAttributes, - transformItems, - results, -}: { - scopedResult: ScopedResult; - includedAttributes: string[]; - excludedAttributes: string[]; - transformItems: TransformItems; - results: SearchResults | undefined | null; -}): AttributesToClear { - const includesQuery = - includedAttributes.indexOf('query') !== -1 || - excludedAttributes.indexOf('query') === -1; - - return { - helper: scopedResult.helper, - items: transformItems( - uniq( - getRefinements( - scopedResult.results, - scopedResult.helper.state, - includesQuery - ) - .map((refinement) => refinement.attribute) - .filter( - (attribute) => - // If the array is empty (default case), we keep all the attributes - includedAttributes.length === 0 || - // Otherwise, only add the specified attributes - includedAttributes.indexOf(attribute) !== -1 - ) - .filter( - (attribute) => - // If the query is included, we ignore the default `excludedAttributes = ['query']` - (attribute === 'query' && includesQuery) || - // Otherwise, ignore the excluded attributes - excludedAttributes.indexOf(attribute) === -1 - ) - ), - { results } - ), - }; -} - -export default connectClearRefinements; +export { connectClearRefinements as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/configure/connectConfigure.ts b/packages/instantsearch.js/src/connectors/configure/connectConfigure.ts index 95500753a55..185585b843f 100644 --- a/packages/instantsearch.js/src/connectors/configure/connectConfigure.ts +++ b/packages/instantsearch.js/src/connectors/configure/connectConfigure.ts @@ -1,203 +1,2 @@ -import algoliasearchHelper from 'algoliasearch-helper'; - -import { - createDocumentationMessageGenerator, - isPlainObject, - mergeSearchParameters, - noop, -} from '../../lib/utils'; - -import type { Connector, WidgetRenderState } from '../../types'; -import type { - SearchParameters, - PlainSearchParameters, - AlgoliaSearchHelper, -} from 'algoliasearch-helper'; - -/** - * Refine the given search parameters. - */ -type Refine = (searchParameters: PlainSearchParameters) => void; - -export type ConfigureConnectorParams = { - /** - * A list of [search parameters](https://www.algolia.com/doc/api-reference/search-api-parameters/) - * to enable when the widget mounts. - */ - searchParameters: PlainSearchParameters; -}; - -export type ConfigureRenderState = { - /** - * Refine the given search parameters. - */ - refine: Refine; -}; - -const withUsage = createDocumentationMessageGenerator({ - name: 'configure', - connector: true, -}); - -function getInitialSearchParameters( - state: SearchParameters, - widgetParams: ConfigureConnectorParams -): SearchParameters { - // We leverage the helper internals to remove the `widgetParams` from - // the state. The function `setQueryParameters` omits the values that - // are `undefined` on the next state. - return state.setQueryParameters( - Object.keys(widgetParams.searchParameters).reduce( - (acc, key) => ({ - ...acc, - [key]: undefined, - }), - {} - ) - ); -} - -export type ConfigureWidgetDescription = { - $$type: 'ais.configure'; - renderState: ConfigureRenderState; - indexRenderState: { - configure: WidgetRenderState< - ConfigureRenderState, - ConfigureConnectorParams - >; - }; - indexUiState: { - configure: PlainSearchParameters; - }; -}; - -export type ConfigureConnector = Connector< - ConfigureWidgetDescription, - ConfigureConnectorParams ->; - -const connectConfigure: ConfigureConnector = function connectConfigure( - renderFn = noop, - unmountFn = noop -) { - return (widgetParams) => { - if (!widgetParams || !isPlainObject(widgetParams.searchParameters)) { - throw new Error( - withUsage('The `searchParameters` option expects an object.') - ); - } - - type ConnectorState = { - refine?: Refine; - }; - - const connectorState: ConnectorState = {}; - - function refine(helper: AlgoliaSearchHelper): Refine { - return (searchParameters: PlainSearchParameters) => { - // Merge new `searchParameters` with the ones set from other widgets - const actualState = getInitialSearchParameters( - helper.state, - widgetParams - ); - const nextSearchParameters = mergeSearchParameters( - actualState, - new algoliasearchHelper.SearchParameters(searchParameters) - ); - - // Update original `widgetParams.searchParameters` to the new refined one - widgetParams.searchParameters = searchParameters; - - // Trigger a search with the resolved search parameters - helper.setState(nextSearchParameters).search(); - }; - } - - return { - $$type: 'ais.configure', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return getInitialSearchParameters(state, widgetParams); - }, - - getRenderState(renderState, renderOptions) { - const widgetRenderState = this.getWidgetRenderState(renderOptions); - return { - ...renderState, - configure: { - ...widgetRenderState, - widgetParams: { - ...widgetRenderState.widgetParams, - searchParameters: mergeSearchParameters( - new algoliasearchHelper.SearchParameters( - renderState.configure?.widgetParams.searchParameters - ), - new algoliasearchHelper.SearchParameters( - widgetRenderState.widgetParams.searchParameters - ) - ).getQueryParams(), - }, - }, - }; - }, - - getWidgetRenderState({ helper }) { - if (!connectorState.refine) { - connectorState.refine = refine(helper); - } - - return { - refine: connectorState.refine, - widgetParams, - }; - }, - - getWidgetSearchParameters(state, { uiState }) { - return mergeSearchParameters( - state, - new algoliasearchHelper.SearchParameters({ - ...uiState.configure, - ...widgetParams.searchParameters, - }) - ); - }, - - getWidgetUiState(uiState) { - return { - ...uiState, - configure: { - ...uiState.configure, - ...widgetParams.searchParameters, - }, - }; - }, - }; - }; -}; - -export default connectConfigure; +export { connectConfigure as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts b/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts index df541aaed43..8b0ba2c0458 100644 --- a/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts +++ b/packages/instantsearch.js/src/connectors/current-refinements/connectCurrentRefinements.ts @@ -1,430 +1,2 @@ -import { - getRefinements, - checkRendering, - createDocumentationMessageGenerator, - noop, - warning, -} from '../../lib/utils'; - -import type { - Refinement, - FacetRefinement, - NumericRefinement, -} from '../../lib/utils'; -import type { - Connector, - TransformItems, - CreateURL, - WidgetRenderState, -} from '../../types'; -import type { - AlgoliaSearchHelper, - SearchParameters, - SearchResults, -} from 'algoliasearch-helper'; - -export type CurrentRefinementsConnectorParamsRefinement = { - /** - * The attribute on which the refinement is applied. - */ - attribute: string; - - /** - * The type of the refinement. - */ - type: - | 'facet' - | 'exclude' - | 'disjunctive' - | 'hierarchical' - | 'numeric' - | 'query' - | 'tag'; - - /** - * The raw value of the refinement. - */ - value: string | number; - - /** - * The label of the refinement to display. - */ - label: string; - - /** - * The value of the operator (only if applicable). - */ - operator?: string; - - /** - * The number of found items (only if applicable). - */ - count?: number; - - /** - * Whether the count is exhaustive (only if applicable). - */ - exhaustive?: boolean; -}; - -export type CurrentRefinementsConnectorParamsItem = { - /** - * The index name. - */ - indexName: string; - - /** - * The index id as provided to the index widget. - */ - indexId: string; - - /** - * The attribute on which the refinement is applied. - */ - attribute: string; - - /** - * The textual representation of this attribute. - */ - label: string; - - /** - * Currently applied refinements. - */ - refinements: CurrentRefinementsConnectorParamsRefinement[]; - - /** - * Removes the given refinement and triggers a new search. - */ - refine: (refinement: CurrentRefinementsConnectorParamsRefinement) => void; -}; - -export type CurrentRefinementsConnectorParams = { - /** - * The attributes to include in the widget (all by default). - * Cannot be used with `excludedAttributes`. - * - * @default `[]` - */ - includedAttributes?: string[]; - - /** - * The attributes to exclude from the widget. - * Cannot be used with `includedAttributes`. - * - * @default `['query']` - */ - excludedAttributes?: string[]; - - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; -}; - -export type CurrentRefinementsRenderState = { - /** - * All the currently refined items, grouped by attribute. - */ - items: CurrentRefinementsConnectorParamsItem[]; - - /** - * Indicates if search state can be refined. - */ - canRefine: boolean; - - /** - * Removes the given refinement and triggers a new search. - */ - refine: (refinement: CurrentRefinementsConnectorParamsRefinement) => void; - - /** - * Generates a URL for the next state. - */ - createURL: CreateURL; -}; - -const withUsage = createDocumentationMessageGenerator({ - name: 'current-refinements', - connector: true, -}); - -export type CurrentRefinementsWidgetDescription = { - $$type: 'ais.currentRefinements'; - renderState: CurrentRefinementsRenderState; - indexRenderState: { - currentRefinements: WidgetRenderState< - CurrentRefinementsRenderState, - CurrentRefinementsConnectorParams - >; - }; -}; - -export type CurrentRefinementsConnector = Connector< - CurrentRefinementsWidgetDescription, - CurrentRefinementsConnectorParams ->; - -const connectCurrentRefinements: CurrentRefinementsConnector = - function connectCurrentRefinements(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - if ( - (widgetParams || {}).includedAttributes && - (widgetParams || {}).excludedAttributes - ) { - throw new Error( - withUsage( - 'The options `includedAttributes` and `excludedAttributes` cannot be used together.' - ) - ); - } - - const { - includedAttributes, - excludedAttributes = ['query'], - transformItems = ((items) => items) as NonNullable< - CurrentRefinementsConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - return { - $$type: 'ais.currentRefinements', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose() { - unmountFn(); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - currentRefinements: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ results, scopedResults, createURL, helper }) { - function getItems() { - if (!results) { - return transformItems( - getRefinementsItems({ - results: null, - helper, - indexId: helper.state.index, - includedAttributes, - excludedAttributes, - }), - { results } - ); - } - - return scopedResults.reduce< - CurrentRefinementsConnectorParamsItem[] - >((accResults, scopedResult) => { - return accResults.concat( - transformItems( - getRefinementsItems({ - results: scopedResult.results, - helper: scopedResult.helper, - indexId: scopedResult.indexId, - includedAttributes, - excludedAttributes, - }), - { results } - ) - ); - }, []); - } - - const items = getItems(); - - return { - items, - canRefine: items.length > 0, - refine: (refinement) => clearRefinement(helper, refinement), - createURL: (refinement) => - createURL(clearRefinementFromState(helper.state, refinement)), - widgetParams, - }; - }, - }; - }; - }; - -function getRefinementsItems({ - results, - helper, - indexId, - includedAttributes, - excludedAttributes, -}: { - results: SearchResults | null; - helper: AlgoliaSearchHelper; - indexId: string; - includedAttributes: CurrentRefinementsConnectorParams['includedAttributes']; - excludedAttributes: CurrentRefinementsConnectorParams['excludedAttributes']; -}): CurrentRefinementsConnectorParamsItem[] { - const includesQuery = - (includedAttributes || []).indexOf('query') !== -1 || - (excludedAttributes || []).indexOf('query') === -1; - - const filterFunction = includedAttributes - ? (item: CurrentRefinementsConnectorParamsRefinement) => - includedAttributes.indexOf(item.attribute) !== -1 - : (item: CurrentRefinementsConnectorParamsRefinement) => - excludedAttributes!.indexOf(item.attribute) === -1; - - const items = getRefinements(results, helper.state, includesQuery) - .map(normalizeRefinement) - .filter(filterFunction); - - return items.reduce( - (allItems, currentItem) => [ - ...allItems.filter((item) => item.attribute !== currentItem.attribute), - { - indexName: helper.state.index, - indexId, - attribute: currentItem.attribute, - label: currentItem.attribute, - refinements: items - .filter((result) => result.attribute === currentItem.attribute) - // We want to keep the order of refinements except the numeric ones. - .sort((a, b) => - a.type === 'numeric' ? (a.value as number) - (b.value as number) : 0 - ), - refine: (refinement) => clearRefinement(helper, refinement), - }, - ], - [] - ); -} - -function clearRefinementFromState( - state: SearchParameters, - refinement: CurrentRefinementsConnectorParamsRefinement -): SearchParameters { - state = state.resetPage(); - switch (refinement.type) { - case 'facet': - return state.removeFacetRefinement( - refinement.attribute, - String(refinement.value) - ); - case 'disjunctive': - return state.removeDisjunctiveFacetRefinement( - refinement.attribute, - String(refinement.value) - ); - case 'hierarchical': - return state.removeHierarchicalFacetRefinement(refinement.attribute); - case 'exclude': - return state.removeExcludeRefinement( - refinement.attribute, - String(refinement.value) - ); - case 'numeric': - return state.removeNumericRefinement( - refinement.attribute, - refinement.operator, - String(refinement.value) - ); - case 'tag': - return state.removeTagRefinement(String(refinement.value)); - case 'query': - return state.setQueryParameter('query', ''); - default: - warning( - false, - `The refinement type "${refinement.type}" does not exist and cannot be cleared from the current refinements.` - ); - return state; - } -} - -function clearRefinement( - helper: AlgoliaSearchHelper, - refinement: CurrentRefinementsConnectorParamsRefinement -): void { - helper.setState(clearRefinementFromState(helper.state, refinement)).search(); -} - -function getOperatorSymbol(operator: SearchParameters.Operator): string { - switch (operator) { - case '>=': - return '≥'; - case '<=': - return '≤'; - default: - return operator; - } -} - -function normalizeRefinement( - refinement: Refinement -): CurrentRefinementsConnectorParamsRefinement { - const value = getValue(refinement); - const label = (refinement as NumericRefinement).operator - ? `${getOperatorSymbol( - (refinement as NumericRefinement).operator as SearchParameters.Operator - )} ${refinement.name}` - : refinement.name; - - const normalizedRefinement: CurrentRefinementsConnectorParamsRefinement = { - attribute: refinement.attribute, - type: refinement.type, - value, - label, - }; - - if ((refinement as NumericRefinement).operator !== undefined) { - normalizedRefinement.operator = (refinement as NumericRefinement).operator; - } - if ((refinement as FacetRefinement).count !== undefined) { - normalizedRefinement.count = (refinement as FacetRefinement).count; - } - if ((refinement as FacetRefinement).exhaustive !== undefined) { - normalizedRefinement.exhaustive = ( - refinement as FacetRefinement - ).exhaustive; - } - - return normalizedRefinement; -} - -function getValue(refinement: Refinement) { - if (refinement.type === 'numeric') { - return Number(refinement.name); - } - - if ('escapedValue' in refinement) { - return refinement.escapedValue; - } - - return refinement.name; -} - -export default connectCurrentRefinements; +export { connectCurrentRefinements as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/dynamic-widgets/connectDynamicWidgets.ts b/packages/instantsearch.js/src/connectors/dynamic-widgets/connectDynamicWidgets.ts index 81d239ddf5b..d2171fa7b36 100644 --- a/packages/instantsearch.js/src/connectors/dynamic-widgets/connectDynamicWidgets.ts +++ b/packages/instantsearch.js/src/connectors/dynamic-widgets/connectDynamicWidgets.ts @@ -1,262 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - getWidgetAttribute, - noop, - warning, -} from '../../lib/utils'; - -import type { - Connector, - TransformItems, - TransformItemsMetadata, - Widget, -} from '../../types'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'dynamic-widgets', - connector: true, -}); - -export type DynamicWidgetsRenderState = { - attributesToRender: string[]; -}; - -export type DynamicWidgetsConnectorParams = { - /** - * An array of widgets, displayed in the order defined by `facetOrdering`. - */ - widgets: Widget[]; - - /** - * Function to return a fallback widget when an attribute isn't found in - * `widgets`. - */ - fallbackWidget?: (args: { - /** The attribute name to create a widget for. */ - attribute: string; - }) => Widget; - - /** - * Function to transform the items to render. - * The function also exposes the full search response. - */ - transformItems?: TransformItems< - string, - Omit & { - results: NonNullable; - } - >; - - /** - * To prevent unneeded extra network requests when widgets mount or unmount, - * we request all facet values by default. If you want to only request the - * facet values that are needed, you can set this option to the list of - * attributes you want to display. - * - * If `facets` is set to `['*']`, we request all facet values. - * - * Any facets that are requested due to the `facetOrdering` result are always - * requested by the widget that mounted itself. - * - * Setting `facets` to a value other than `['*']` will only prevent extra - * requests if all potential facets are listed. - * - * @default ['*'] - */ - facets?: ['*'] | string[]; - - /** - * If you have more than 20 facet values pinned, you need to increase the - * maxValuesPerFacet to at least that value. - * - * @default 20 - */ - maxValuesPerFacet?: number; -}; - -export type DynamicWidgetsWidgetDescription = { - $$type: 'ais.dynamicWidgets'; - renderState: DynamicWidgetsRenderState; - indexRenderState: { - dynamicWidgets: DynamicWidgetsRenderState; - }; -}; - -export type DynamicWidgetsConnector = Connector< - DynamicWidgetsWidgetDescription, - DynamicWidgetsConnectorParams ->; - -const MAX_WILDCARD_FACETS = 20; - -const connectDynamicWidgets: DynamicWidgetsConnector = - function connectDynamicWidgets(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - widgets, - maxValuesPerFacet = 20, - facets = ['*'], - transformItems = (items) => items, - fallbackWidget, - } = widgetParams; - - if ( - !( - widgets && - Array.isArray(widgets) && - widgets.every((widget) => typeof widget === 'object') - ) - ) { - throw new Error( - withUsage('The `widgets` option expects an array of widgets.') - ); - } - - if (!Array.isArray(facets)) { - throw new Error( - withUsage( - `The \`facets\` option only accepts an array of facets, you passed ${JSON.stringify( - facets - )}` - ) - ); - } - - const localWidgets: Map = - new Map(); - - return { - $$type: 'ais.dynamicWidgets', - init(initOptions) { - widgets.forEach((widget) => { - const attribute = getWidgetAttribute(widget, initOptions); - localWidgets.set(attribute, { widget, isMounted: false }); - }); - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - true - ); - }, - render(renderOptions) { - const { parent } = renderOptions; - const renderState = this.getWidgetRenderState(renderOptions); - - const widgetsToUnmount: Widget[] = []; - const widgetsToMount: Widget[] = []; - - if (fallbackWidget) { - renderState.attributesToRender.forEach((attribute) => { - if (!localWidgets.has(attribute)) { - const widget = fallbackWidget({ attribute }); - localWidgets.set(attribute, { widget, isMounted: false }); - } - }); - } - - localWidgets.forEach(({ widget, isMounted }, attribute) => { - const shouldMount = - renderState.attributesToRender.indexOf(attribute) > -1; - - if (!isMounted && shouldMount) { - widgetsToMount.push(widget); - localWidgets.set(attribute, { - widget, - isMounted: true, - }); - } else if (isMounted && !shouldMount) { - widgetsToUnmount.push(widget); - localWidgets.set(attribute, { - widget, - isMounted: false, - }); - } - }); - - parent.addWidgets(widgetsToMount); - // make sure this only happens after the regular render, otherwise it - // happens too quick, since render is "deferred" for the next microtask, - // so this needs to be a whole task later - setTimeout(() => parent.removeWidgets(widgetsToUnmount), 0); - - renderFn( - { - ...renderState, - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - }, - dispose({ parent }) { - const toRemove: Widget[] = []; - localWidgets.forEach(({ widget, isMounted }) => { - if (isMounted) { - toRemove.push(widget); - } - }); - parent.removeWidgets(toRemove); - - unmountFn(); - }, - getWidgetSearchParameters(state) { - return facets.reduce( - (acc, curr) => acc.addFacet(curr), - state.setQueryParameters({ - maxValuesPerFacet: Math.max( - maxValuesPerFacet || 0, - state.maxValuesPerFacet || 0 - ), - }) - ); - }, - getRenderState(renderState, renderOptions) { - return { - ...renderState, - dynamicWidgets: this.getWidgetRenderState(renderOptions), - }; - }, - getWidgetRenderState({ results, state }) { - if (!results) { - return { attributesToRender: [], widgetParams }; - } - - const attributesToRender = transformItems( - results.renderingContent?.facetOrdering?.facets?.order ?? [], - { results } - ); - - if (!Array.isArray(attributesToRender)) { - throw new Error( - withUsage( - 'The `transformItems` option expects a function that returns an Array.' - ) - ); - } - - warning( - maxValuesPerFacet >= (state.maxValuesPerFacet || 0), - `The maxValuesPerFacet set by dynamic widgets (${maxValuesPerFacet}) is smaller than one of the limits set by a widget (${state.maxValuesPerFacet}). This causes a mismatch in query parameters and thus an extra network request when that widget is mounted.` - ); - - warning( - attributesToRender.length <= MAX_WILDCARD_FACETS || - widgetParams.facets !== undefined, - `More than ${MAX_WILDCARD_FACETS} facets are requested to be displayed without explicitly setting which facets to retrieve. This could have a performance impact. Set "facets" to [] to do two smaller network requests, or explicitly to ['*'] to avoid this warning.` - ); - - return { - attributesToRender, - widgetParams, - }; - }, - }; - }; - }; - -export default connectDynamicWidgets; +export { connectDynamicWidgets as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/feeds/FeedContainer.ts b/packages/instantsearch.js/src/connectors/feeds/FeedContainer.ts index 828ef23e69d..f63d274b31c 100644 --- a/packages/instantsearch.js/src/connectors/feeds/FeedContainer.ts +++ b/packages/instantsearch.js/src/connectors/feeds/FeedContainer.ts @@ -1,310 +1,2 @@ -import algoliasearchHelper from 'algoliasearch-helper'; - -import { - createInitArgs, - createRenderArgs, - storeRenderState, -} from '../../lib/utils'; - -import type { - InstantSearch, - UiState, - IndexUiState, - Widget, - IndexWidget, - DisposeOptions, - RenderOptions, -} from '../../types'; -import type { SearchParameters } from 'algoliasearch-helper'; - -export function createFeedContainer( - feedID: string, - parentIndex: IndexWidget, - instantSearchInstance: InstantSearch -): IndexWidget { - let localWidgets: Array = []; - let initialized = false; - - const container: IndexWidget = { - $$type: 'ais.feedContainer', - $$widgetType: 'ais.feedContainer', - _isolated: true, - - getIndexName: () => parentIndex.getIndexName(), - getIndexId: () => feedID, - getHelper: () => parentIndex.getHelper(), - - getResults() { - const parentResults = parentIndex.getResults(); - if (!parentResults) return null; - if (!parentResults.feeds) { - // Single-feed backward compat: no feeds array means the parent result - // itself is the only feed. - if (feedID === '') { - parentResults._state = parentIndex.getHelper()!.state; - return parentResults; - } - return null; - } - const feed = parentResults.feeds.find((f) => f.feedID === feedID); - if (!feed) return null; - // Optimistic state patching — same as index widget (index.ts:365-370) - feed._state = parentIndex.getHelper()!.state; - return feed; - }, - - getResultsForWidget() { - return this.getResults(); - }, - - getParent: () => parentIndex, - getWidgets: () => localWidgets, - getScopedResults: () => parentIndex.getScopedResults(), - getPreviousState: () => null, - createURL: ( - nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) - ) => parentIndex.createURL(nextState), - scheduleLocalSearch: () => parentIndex.scheduleLocalSearch(), - - addWidgets(widgets) { - const flatWidgets = widgets.reduce>( - (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), - [] - ); - flatWidgets.forEach((widget) => { - widget.parent = container; - }); - localWidgets = localWidgets.concat(flatWidgets); - - if (initialized) { - flatWidgets.forEach((widget) => { - if (widget.getRenderState) { - const renderState = widget.getRenderState( - instantSearchInstance.renderState[container.getIndexId()] || {}, - createInitArgs( - instantSearchInstance, - container, - instantSearchInstance._initialUiState - ) - ); - storeRenderState({ - renderState, - instantSearchInstance, - parent: container, - }); - } - }); - - flatWidgets.forEach((widget) => { - if (widget.init) { - widget.init( - createInitArgs( - instantSearchInstance, - container, - instantSearchInstance._initialUiState - ) - ); - } - }); - - // Merge children's search params (e.g. disjunctiveFacets) into the - // parent's helper state so they're included in the composition request. - // uiState is {} because URL-derived refinements are already on the - // parent state; children only need to declare structural params. - const parentHelper = parentIndex.getHelper()!; - const withChildParams = container.getWidgetSearchParameters( - parentHelper.state, - { uiState: {} } - ); - if (withChildParams !== parentHelper.state) { - parentHelper.setState(withChildParams); - } - } - - return container; - }, - - removeWidgets(widgets) { - const flatWidgets = widgets.reduce>( - (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), - [] - ); - const helper = parentIndex.getHelper(); - - if (!helper) { - localWidgets = localWidgets.filter((w) => !flatWidgets.includes(w)); - return container; - } - - // Chain through children's dispose so widgets clean up the - // SearchParameters they declared (e.g. RefinementList removes its - // disjunctiveFacet) instead of leaving them stale on the parent helper. - let cleanedState: SearchParameters = helper.state; - - flatWidgets.forEach((widget) => { - if (widget.dispose) { - const next = widget.dispose({ - helper, - state: cleanedState, - recommendState: helper.recommendState, - parent: container, - }); - - if (next instanceof algoliasearchHelper.RecommendParameters) { - // ignore — FeedContainer doesn't manage recommend state - } else if (next) { - cleanedState = next; - } - } - }); - - localWidgets = localWidgets.filter((w) => !flatWidgets.includes(w)); - - if (cleanedState !== helper.state) { - helper.setState(cleanedState); - } - - return container; - }, - - init() { - initialized = true; - - localWidgets.forEach((widget) => { - if (widget.getRenderState) { - const renderState = widget.getRenderState( - instantSearchInstance.renderState[container.getIndexId()] || {}, - createInitArgs( - instantSearchInstance, - container, - instantSearchInstance._initialUiState - ) - ); - storeRenderState({ - renderState, - instantSearchInstance, - parent: container, - }); - } - }); - - localWidgets.forEach((widget) => { - if (widget.init) { - widget.init( - createInitArgs( - instantSearchInstance, - container, - instantSearchInstance._initialUiState - ) - ); - } - }); - }, - - render() { - localWidgets.forEach((widget) => { - if (widget.getRenderState) { - const renderState = widget.getRenderState( - instantSearchInstance.renderState[container.getIndexId()] || {}, - createRenderArgs( - instantSearchInstance, - container, - widget - ) as RenderOptions - ); - storeRenderState({ - renderState, - instantSearchInstance, - parent: container, - }); - } - }); - - localWidgets.forEach((widget) => { - if (widget.render) { - widget.render( - createRenderArgs( - instantSearchInstance, - container, - widget - ) as RenderOptions - ); - } - }); - }, - - dispose(disposeOptions?: DisposeOptions) { - const helper = parentIndex.getHelper(); - - // Chain through children's dispose to return a cleaned state - // (e.g. RefinementList.dispose removes its disjunctiveFacet declaration). - // This mirrors how the index widget's removeWidgets chains dispose calls. - let cleanedState = disposeOptions?.state ?? helper?.state; - - localWidgets.forEach((widget) => { - if (widget.dispose && helper) { - const next = widget.dispose({ - helper, - state: cleanedState!, - recommendState: helper.recommendState, - parent: container, - }); - - if (next instanceof algoliasearchHelper.RecommendParameters) { - // ignore — FeedContainer doesn't manage recommend state - } else if (next) { - cleanedState = next; - } - } - }); - - localWidgets = []; - initialized = false; - return cleanedState; - }, - - getWidgetState(uiState: UiState) { - return this.getWidgetUiState(uiState); - }, - - getWidgetUiState( - uiState: TUiState - ): TUiState { - const helper = parentIndex.getHelper()!; - const widgetUiStateOptions = { - searchParameters: helper.state, - helper, - }; - return localWidgets.reduce( - (state, widget) => - widget.getWidgetUiState - ? (widget.getWidgetUiState(state, widgetUiStateOptions) as TUiState) - : state, - uiState - ); - }, - - getWidgetSearchParameters( - searchParameters: SearchParameters, - { uiState }: { uiState: IndexUiState } - ) { - return localWidgets.reduce( - (params, widget) => - widget.getWidgetSearchParameters - ? widget.getWidgetSearchParameters(params, { uiState }) - : params, - searchParameters - ); - }, - - refreshUiState() { - // no-op: FeedContainer doesn't own UI state - }, - - setIndexUiState() { - // no-op: FeedContainer delegates to parent - }, - }; - - return container; -} +export { createFeedContainer } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/feeds/connectFeeds.ts b/packages/instantsearch.js/src/connectors/feeds/connectFeeds.ts index ac849e989b2..a0df164767f 100644 --- a/packages/instantsearch.js/src/connectors/feeds/connectFeeds.ts +++ b/packages/instantsearch.js/src/connectors/feeds/connectFeeds.ts @@ -1,210 +1,2 @@ -import algoliasearchHelper from 'algoliasearch-helper'; - -import { - checkRendering, - createDocumentationMessageGenerator, - noop, -} from '../../lib/utils'; - -import type { - CompositionFeedResult, - Connector, - IndexWidget, - InstantSearch, -} from '../../types'; - -function toFeedSearchResults( - state: algoliasearchHelper.SearchResults['_state'], - raw: CompositionFeedResult -): algoliasearchHelper.SearchResults & { feedID: string } { - return Object.assign(new algoliasearchHelper.SearchResults(state, [raw]), { - feedID: raw.feedID, - }); -} - -/** - * Rebuild `lastResults.feeds` from `_initialResults.compositionFeedsResults` - * because the index-widget hydration only restores `lastResults` (the merged - * view), not the per-feed breakdown that the Feeds connector needs. - */ -function hydrateFeedsFromInitialResultsIfNeeded( - instantSearchInstance: InstantSearch, - parent: IndexWidget -) { - const initial = instantSearchInstance._initialResults?.[parent.getIndexId()]; - const compositionFeedsResults = initial?.compositionFeedsResults || []; - if (compositionFeedsResults.length === 0) { - return; - } - - const lastResults = parent.getHelper()?.lastResults; - if (!lastResults) { - return; - } - - if (lastResults.feeds && lastResults.feeds.length > 0) { - return; - } - - lastResults.feeds = compositionFeedsResults.map((raw) => - toFeedSearchResults(lastResults._state, raw) - ); -} - -const withUsage = createDocumentationMessageGenerator({ - name: 'feeds', - connector: true, -}); - -export type FeedsRenderState = { - feedIDs: string[]; -}; - -export type FeedsConnectorParams = { - /** - * Whether feeds are isolated from the global search scope. - * Currently only `false` is supported (future-proofing for per-feed search parameters). - */ - isolated: false; - - /** - * Optional: transform/reorder/filter feed IDs before rendering. - */ - transformFeeds?: (feeds: string[]) => string[]; -}; - -export type FeedsWidgetDescription = { - $$type: 'ais.feeds'; - renderState: FeedsRenderState; - indexRenderState: { - feeds: FeedsRenderState; - }; -}; - -export type FeedsConnector = Connector< - FeedsWidgetDescription, - FeedsConnectorParams ->; - -const connectFeeds: FeedsConnector = function connectFeeds( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { isolated, transformFeeds = (feeds) => feeds } = widgetParams; - - if (isolated !== false) { - throw new Error( - withUsage('The `isolated` option currently only supports `false`.') - ); - } - - return { - $$type: 'ais.feeds', - $$widgetType: 'ais.feeds', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - if (!instantSearchInstance.compositionID) { - throw new Error( - withUsage( - 'The `feeds` widget requires a composition-based InstantSearch instance (compositionID must be set).' - ) - ); - } - - hydrateFeedsFromInitialResultsIfNeeded( - instantSearchInstance, - initOptions.parent - ); - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose() { - unmountFn(); - }, - - getWidgetSearchParameters(state) { - return state; - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - feeds: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ results }) { - if (!results) { - return { feedIDs: [], widgetParams }; - } - - if ( - Array.isArray(results.feeds) && - results.feeds.length > 0 && - !results.feeds.every( - (feed) => feed instanceof algoliasearchHelper.SearchResults - ) - ) { - results.feeds = results.feeds.map((feed) => - feed instanceof algoliasearchHelper.SearchResults - ? feed - : toFeedSearchResults( - results._state, - feed as CompositionFeedResult - ) - ); - } - - let feedIDs = results.feeds - ? results.feeds.map((f: { feedID: string }) => f.feedID) - : ['']; - - feedIDs = transformFeeds(feedIDs); - - if (!Array.isArray(feedIDs)) { - throw new Error( - withUsage( - 'The `transformFeeds` option expects a function that returns an Array.' - ) - ); - } - - if (!feedIDs.every((feedID: string) => typeof feedID === 'string')) { - throw new Error( - withUsage( - 'The `transformFeeds` option expects a function that returns an array of feed IDs (strings).' - ) - ); - } - - return { feedIDs, widgetParams }; - }, - }; - }; -}; - -export default connectFeeds; +export { connectFeeds as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/filter-suggestions/connectFilterSuggestions.ts b/packages/instantsearch.js/src/connectors/filter-suggestions/connectFilterSuggestions.ts index e5820f9de67..55a801c0980 100644 --- a/packages/instantsearch.js/src/connectors/filter-suggestions/connectFilterSuggestions.ts +++ b/packages/instantsearch.js/src/connectors/filter-suggestions/connectFilterSuggestions.ts @@ -1,449 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - getAlgoliaAgent, - getAppIdAndApiKey, - getRefinements, - noop, -} from '../../lib/utils'; - -import type { - Connector, - InitOptions, - RenderOptions, - TransformItems, - WidgetRenderState, -} from '../../types'; -import type { SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'filter-suggestions', - connector: true, -}); - -export type Suggestion = { - /** - * The facet attribute name. - */ - attribute: string; - /** - * The facet value to filter by. - */ - value: string; - /** - * Human-readable display label. - */ - label: string; - /** - * Number of records matching this filter. - */ - count: number; -}; - -export type FilterSuggestionsTransport = { - /** - * The custom API endpoint URL. - */ - api: string; - /** - * Custom headers to send with the request. - */ - headers?: Record; - /** - * Function to prepare the request body before sending. - * Receives the default body and returns the modified request options. - */ - prepareSendMessagesRequest?: (body: Record) => { - body: Record; - }; -}; - -export type FilterSuggestionsRenderState = { - /** - * The list of suggested filters. - */ - suggestions: Suggestion[]; - /** - * Whether suggestions are currently being fetched. - */ - isLoading: boolean; - /** - * Applies a filter for the given attribute and value. - */ - refine: (attribute: string, value: string) => void; -}; - -export type FilterSuggestionsConnectorParams = { - /** - * The ID of the agent configured in the Algolia dashboard. - * Required unless a custom `transport` is provided. - */ - agentId?: string; - /** - * Limit to specific facet attributes. - */ - attributes?: string[]; - /** - * Maximum number of suggestions to return. - * @default 3 - */ - maxSuggestions?: number; - /** - * Debounce delay in milliseconds before fetching suggestions. - * @default 300 - */ - debounceMs?: number; - /** - * Number of hits to send for context. - * @default 5 - */ - hitsToSample?: number; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; - /** - * Custom transport configuration for the API requests. - * When provided, allows using a custom endpoint, headers, and request body. - */ - transport?: FilterSuggestionsTransport; -}; - -export type FilterSuggestionsWidgetDescription = { - $$type: 'ais.filterSuggestions'; - renderState: FilterSuggestionsRenderState; - indexRenderState: { - filterSuggestions: WidgetRenderState< - FilterSuggestionsRenderState, - FilterSuggestionsConnectorParams - >; - }; -}; - -export type FilterSuggestionsConnector = Connector< - FilterSuggestionsWidgetDescription, - FilterSuggestionsConnectorParams ->; - -const connectFilterSuggestions: FilterSuggestionsConnector = - function connectFilterSuggestions(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - agentId, - attributes, - maxSuggestions = 3, - debounceMs = 300, - hitsToSample = 5, - transformItems = ((items) => items) as NonNullable< - FilterSuggestionsConnectorParams['transformItems'] - >, - transport, - } = widgetParams; - - if (!agentId && !transport) { - throw new Error( - withUsage( - 'The `agentId` option is required unless a custom `transport` is provided.' - ) - ); - } - - let endpoint: string; - let headers: Record; - let suggestions: Suggestion[] = []; - let isLoading = false; - let debounceTimer: ReturnType | undefined; - let lastStateSignature: string | null = null; // null means never fetched - let refine: FilterSuggestionsRenderState['refine']; - let searchHelper: InitOptions['helper'] | null = null; - let latestRenderOptions: RenderOptions | null = null; - - // Create a signature of the current search state (query + refinements) - const getStateSignature = (results: SearchResults): string => { - const query = results.query || ''; - const refinements = searchHelper - ? JSON.stringify(searchHelper.state.facetsRefinements) + - JSON.stringify(searchHelper.state.disjunctiveFacetsRefinements) + - JSON.stringify(searchHelper.state.hierarchicalFacetsRefinements) - : ''; - return `${query}|${refinements}`; - }; - - const getWidgetRenderState = ( - renderOptions: InitOptions | RenderOptions - ) => { - const results = - 'results' in renderOptions ? renderOptions.results : undefined; - const transformedSuggestions = transformItems(suggestions, { results }); - - return { - suggestions: transformedSuggestions, - isLoading, - refine, - widgetParams, - }; - }; - - // Minimum duration to show skeleton to avoid flash when results are cached - const MIN_SKELETON_DURATION_MS = 300; - - const fetchSuggestions = ( - results: SearchResults, - renderOptions: RenderOptions - ) => { - if (!results?.hits?.length) { - suggestions = []; - isLoading = false; - renderFn( - { - ...getWidgetRenderState(renderOptions), - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - return; - } - - const loadingStartTime = Date.now(); - isLoading = true; - renderFn( - { - ...getWidgetRenderState(renderOptions), - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - - // Get facets from raw results (results.facets is processed differently) - const rawResults = results._rawResults as Array<{ - facets?: Record>; - }>; - const rawFacets = rawResults?.[0]?.facets || {}; - - const facetsToSend = attributes - ? Object.fromEntries( - Object.entries(rawFacets).filter(([key]) => - attributes.includes(key) - ) - ) - : rawFacets; - - // Collect current refinements to exclude from suggestions - const currentRefinements = searchHelper - ? getRefinements(results, searchHelper.state).map((refinement) => ({ - attribute: refinement.attribute, - value: refinement.name, - })) - : []; - - const messageText = JSON.stringify({ - query: results.query, - facets: facetsToSend, - hitsSample: results.hits.slice(0, hitsToSample), - currentRefinements, - maxSuggestions, - }); - - const payload: Record = { - messages: [ - { - id: `sr-${Date.now()}`, - createdAt: new Date().toISOString(), - role: 'user', - parts: [ - { - type: 'text', - text: messageText, - }, - ], - }, - ], - }; - - // Apply custom body transformation if provided - const finalPayload = transport?.prepareSendMessagesRequest - ? transport.prepareSendMessagesRequest(payload).body - : payload; - - fetch(endpoint, { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify(finalPayload), - }) - .then((response) => { - if (!response.ok) { - throw new Error(`HTTP error ${response.status}`); - } - return response.json(); - }) - .then((data) => { - const parsedSuggestions = JSON.parse(data.parts[1].text); - - const validSuggestions = ( - Array.isArray(parsedSuggestions) ? parsedSuggestions : [] - ) - .filter((suggestion) => { - if ( - !suggestion?.attribute || - !suggestion?.value || - !suggestion?.label - ) { - return false; - } - // If attributes filter is specified, only allow suggestions for those attributes - if (attributes && !attributes.includes(suggestion.attribute)) { - return false; - } - return true; - }) - .slice(0, maxSuggestions); - - suggestions = validSuggestions; - }) - .catch(() => { - suggestions = []; - }) - .finally(() => { - const elapsed = Date.now() - loadingStartTime; - const remainingDelay = Math.max( - 0, - MIN_SKELETON_DURATION_MS - elapsed - ); - - const finishLoading = () => { - isLoading = false; - renderFn( - { - ...getWidgetRenderState(renderOptions), - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - }; - - if (remainingDelay > 0) { - setTimeout(finishLoading, remainingDelay); - } else { - finishLoading(); - } - }); - }; - - return { - $$type: 'ais.filterSuggestions', - - init(initOptions) { - const { instantSearchInstance, helper } = initOptions; - searchHelper = helper; - - if (transport) { - // Use custom transport configuration - endpoint = transport.api; - headers = transport.headers || {}; - } else { - // Use default Algolia agent endpoint - const [appId, apiKey] = getAppIdAndApiKey( - instantSearchInstance.client - ); - - if (!appId || !apiKey) { - throw new Error( - withUsage( - 'Could not extract Algolia credentials from the search client.' - ) - ); - } - - endpoint = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5&stream=false`; - headers = { - 'x-algolia-application-id': appId, - 'x-algolia-api-key': apiKey, - 'x-algolia-agent': getAlgoliaAgent(instantSearchInstance.client), - }; - } - - refine = (attribute: string, value: string) => { - // Check if the attribute belongs to a hierarchical facet - // by finding a hierarchical facet that includes this attribute - const attr = - helper.state.hierarchicalFacets.find((facet) => - facet.attributes.includes(attribute) - )?.name || attribute; - - helper.toggleFacetRefinement(attr, value); - helper.search(); - }; - - renderFn( - { - ...getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { results, instantSearchInstance } = renderOptions; - - // Always store the latest render options - latestRenderOptions = renderOptions; - - if (!results) { - renderFn( - { - ...getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - return; - } - - // Debounce: only fetch if search state changed (query or refinements) and after delay - const stateSignature = getStateSignature(results); - if (stateSignature !== lastStateSignature) { - lastStateSignature = stateSignature; - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - // Use the latest render options when the timeout fires - if (latestRenderOptions?.results) { - fetchSuggestions( - latestRenderOptions.results, - latestRenderOptions - ); - } - }, debounceMs); - } - - renderFn( - { - ...getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose() { - clearTimeout(debounceTimer); - unmountFn(); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - filterSuggestions: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState(renderOptions) { - return getWidgetRenderState(renderOptions); - }, - }; - }; - }; - -export default connectFilterSuggestions; +export { connectFilterSuggestions as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; 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 1f13787ece0..69f151a2fde 100644 --- a/packages/instantsearch.js/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts +++ b/packages/instantsearch.js/src/connectors/frequently-bought-together/connectFrequentlyBoughtTogether.ts @@ -1,243 +1,2 @@ -import { - createDocumentationMessageGenerator, - checkRendering, - noop, - escapeHits, - TAG_PLACEHOLDER, - createSendEventForHits, - addAbsolutePosition, - addQueryID, -} from '../../lib/utils'; - -import type { SendEventForHits } from '../../lib/utils'; -import type { - Connector, - TransformItems, - BaseHit, - Renderer, - Unmounter, - UnknownWidgetParams, - RecommendResponse, - Hit, - AlgoliaHit, -} from '../../types'; -import type { PlainSearchParameters } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'frequently-bought-together', - connector: true, -}); - -export type FrequentlyBoughtTogetherRenderState< - THit extends NonNullable = BaseHit -> = { - /** - * The matched recommendations from Algolia API. - */ - items: Array>; - - /** - * Sends an event to the Insights middleware. - */ - sendEvent: SendEventForHits; -}; - -export type FrequentlyBoughtTogetherConnectorParams< - THit extends NonNullable = BaseHit -> = { - /** - * The objectIDs of the items to get the frequently bought together items for. - */ - objectIDs: string[]; - - /** - * Threshold for the recommendations confidence score (between 0 and 100). Only recommendations with a greater score are returned. - */ - threshold?: number; - - /** - * List of search parameters to send. - */ - fallbackParameters?: Omit< - PlainSearchParameters, - 'page' | 'hitsPerPage' | 'offset' | 'length' - >; - - /** - * The maximum number of recommendations to return. - */ - limit?: number; - - /** - * Parameters to pass to the request. - */ - queryParameters?: Omit< - PlainSearchParameters, - 'page' | 'hitsPerPage' | 'offset' | 'length' - >; - - /** - * Whether to escape HTML tags from items string values. - * - * @default true - */ - escapeHTML?: boolean; - - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems< - Hit, - { results: RecommendResponse> } - >; -}; - -export type FrequentlyBoughtTogetherWidgetDescription< - THit extends NonNullable = BaseHit -> = { - $$type: 'ais.frequentlyBoughtTogether'; - renderState: FrequentlyBoughtTogetherRenderState; -}; - -export type FrequentlyBoughtTogetherConnector< - THit extends NonNullable = BaseHit -> = Connector< - FrequentlyBoughtTogetherWidgetDescription, - FrequentlyBoughtTogetherConnectorParams ->; - -export default (function connectFrequentlyBoughtTogether< - TWidgetParams extends UnknownWidgetParams ->( - renderFn: Renderer< - FrequentlyBoughtTogetherRenderState, - TWidgetParams & FrequentlyBoughtTogetherConnectorParams - >, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return = BaseHit>( - widgetParams: TWidgetParams & FrequentlyBoughtTogetherConnectorParams - ) => { - const { - // @MAJOR: this can default to false - escapeHTML = true, - transformItems = ((items) => items) as NonNullable< - FrequentlyBoughtTogetherConnectorParams['transformItems'] - >, - objectIDs, - limit, - threshold, - fallbackParameters, - queryParameters, - } = widgetParams || {}; - - if (!objectIDs || objectIDs.length === 0) { - throw new Error(withUsage('The `objectIDs` option is required.')); - } - - let sendEvent: SendEventForHits; - - return { - dependsOn: 'recommend', - $$type: 'ais.frequentlyBoughtTogether', - - 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, helper, instantSearchInstance }) { - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - helper, - widgetType: this.$$type, - }); - } - if (results === null || results === undefined) { - return { items: [], widgetParams, sendEvent }; - } - - if (escapeHTML && results.hits.length > 0) { - results.hits = escapeHits(results.hits); - } - - const itemsWithAbsolutePosition = addAbsolutePosition( - results.hits, - 0, - 1 - ); - - const itemsWithAbsolutePositionAndQueryID = addQueryID( - itemsWithAbsolutePosition, - results.queryID - ); - - const transformedItems = transformItems( - itemsWithAbsolutePositionAndQueryID, - { - results: results as RecommendResponse>, - } - ); - - return { - items: transformedItems, - widgetParams, - sendEvent, - }; - }, - - dispose({ recommendState }) { - unmountFn(); - return recommendState.removeParams(this.$$id!); - }, - - getWidgetParameters(state) { - return objectIDs.reduce( - (acc, objectID) => - acc.addFrequentlyBoughtTogether({ - objectID, - maxRecommendations: limit, - threshold, - // @ts-expect-error until @algolia/recommend types are updated - fallbackParameters: fallbackParameters - ? { - ...fallbackParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - } - : undefined, - queryParameters: { - ...queryParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - }, - $$id: this.$$id!, - }), - state.removeParams(this.$$id!) - ); - }, - }; - }; -} satisfies FrequentlyBoughtTogetherConnector); +export { connectFrequentlyBoughtTogether as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/geo-search/connectGeoSearch.ts b/packages/instantsearch.js/src/connectors/geo-search/connectGeoSearch.ts index 3cbf1506951..9964c55f0fa 100644 --- a/packages/instantsearch.js/src/connectors/geo-search/connectGeoSearch.ts +++ b/packages/instantsearch.js/src/connectors/geo-search/connectGeoSearch.ts @@ -1,427 +1,2 @@ -import { - checkRendering, - aroundLatLngToPosition, - insideBoundingBoxToBoundingBox, - createDocumentationMessageGenerator, - createSendEventForHits, - noop, -} from '../../lib/utils'; - -import type { SendEventForHits } from '../../lib/utils'; -import type { - BaseHit, - Connector, - GeoHit, - GeoLoc, - IndexRenderState, - InitOptions, - Renderer, - RenderOptions, - TransformItems, - UnknownWidgetParams, - Unmounter, - WidgetRenderState, -} from '../../types'; -import type { - AlgoliaSearchHelper, - SearchParameters, -} from 'algoliasearch-helper'; - -export type { GeoHit } from '../../types'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'geo-search', - connector: true, -}); - -// in this connector, we assume insideBoundingBox is only a string, -// even though in the helper it's defined as number[][] alone. -// This can be done, since the connector assumes "control" of the parameter -function getBoundingBoxAsString(state: SearchParameters) { - return (state.insideBoundingBox as unknown as string) || ''; -} -function setBoundingBoxAsString(state: SearchParameters, value: string) { - return state.setQueryParameter( - 'insideBoundingBox', - value as unknown as number[][] - ); -} - -type Bounds = { - /** - * The top right corner of the map view. - */ - northEast: GeoLoc; - /** - * The bottom left corner of the map view. - */ - southWest: GeoLoc; -}; - -export type GeoSearchRenderState = BaseHit> = { - /** - * Reset the current bounding box refinement. - */ - clearMapRefinement: () => void; - /** - * The current bounding box of the search. - */ - currentRefinement?: Bounds; - /** - * Return true if the map has move since the last refinement. - */ - hasMapMoveSinceLastRefine: () => boolean; - /** - * Return true if the current refinement is set with the map bounds. - */ - isRefinedWithMap: () => boolean; - /** - * Return true if the user is able to refine on map move. - */ - isRefineOnMapMove: () => boolean; - /** - * The matched hits from Algolia API. - */ - items: Array>; - /** - * The current position of the search. - */ - position?: GeoLoc; - /** - * Sets a bounding box to filter the results from the given map bounds. - */ - refine: (bounds: Bounds) => void; - /** - * Send event to insights middleware - */ - sendEvent: SendEventForHits; - /** - * Set the fact that the map has moved since the last refinement, should be - * called on each map move. The call to the function triggers a new rendering - * only when the value change. - */ - setMapMoveSinceLastRefine: () => void; - /** - * Toggle the fact that the user is able to refine on map move. - */ - toggleRefineOnMapMove: () => void; -}; - -export type GeoSearchConnectorParams = { - /** - * If true, refine will be triggered as you move the map. - * @default true - */ - enableRefineOnMapMove?: boolean; - /** - * Function to transform the items passed to the templates. - * @default items => items - */ - transformItems?: TransformItems>; -}; - -const $$type = 'ais.geoSearch'; - -export type GeoSearchWidgetDescription = { - $$type: 'ais.geoSearch'; - renderState: GeoSearchRenderState; - indexRenderState: { - geoSearch: WidgetRenderState< - GeoSearchRenderState, - GeoSearchConnectorParams - >; - }; - indexUiState: { - geoSearch: { - /** - * The rectangular area in geo coordinates. - * The rectangle is defined by two diagonally opposite points, - * hence by 4 floats separated by commas. - * - * @example '47.3165,4.9665,47.3424,5.0201' - */ - boundingBox: string; - }; - }; -}; - -export type GeoSearchConnector = Connector< - GeoSearchWidgetDescription, - GeoSearchConnectorParams ->; - -/** - * The **GeoSearch** connector provides the logic to build a widget that will display the results on a map. It also provides a way to search for results based on their position. The connector provides functions to manage the search experience (search on map interaction or control the interaction for example). - * - * @requirements - * - * Note that the GeoSearch connector uses the [geosearch](https://www.algolia.com/doc/guides/searching/geo-search) capabilities of Algolia. Your hits **must** have a `_geoloc` attribute in order to be passed to the rendering function. - * - * Currently, the feature is not compatible with multiple values in the _geoloc attribute. - */ -export default (function connectGeoSearch< - TWidgetParams extends UnknownWidgetParams ->( - renderFn: Renderer< - GeoSearchRenderState, - TWidgetParams & GeoSearchConnectorParams - >, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return ( - widgetParams: TWidgetParams & GeoSearchConnectorParams - ) => { - const { - enableRefineOnMapMove = true, - transformItems = ((items) => items) as NonNullable< - GeoSearchConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - const widgetState = { - isRefineOnMapMove: enableRefineOnMapMove, - // @MAJOR hasMapMoveSinceLastRefine -> hasMapMovedSinceLastRefine - hasMapMoveSinceLastRefine: false, - lastRefinePosition: '', - lastRefineBoundingBox: '', - internalToggleRefineOnMapMove: noop, - internalSetMapMoveSinceLastRefine: noop, - }; - - const getPositionFromState = (state: SearchParameters) => - state.aroundLatLng - ? aroundLatLngToPosition(state.aroundLatLng) - : undefined; - - const getCurrentRefinementFromState = (state: SearchParameters) => - state.insideBoundingBox && - insideBoundingBoxToBoundingBox(state.insideBoundingBox); - - const refine = - (helper: AlgoliaSearchHelper) => - ({ northEast: ne, southWest: sw }: Bounds) => { - const boundingBox = [ne.lat, ne.lng, sw.lat, sw.lng].join(); - - helper - .setState( - setBoundingBoxAsString(helper.state, boundingBox).resetPage() - ) - .search(); - - widgetState.hasMapMoveSinceLastRefine = false; - widgetState.lastRefineBoundingBox = boundingBox; - }; - - const clearMapRefinement = (helper: AlgoliaSearchHelper) => () => { - helper.setQueryParameter('insideBoundingBox', undefined).search(); - }; - - const isRefinedWithMap = (state: SearchParameters) => () => - Boolean(state.insideBoundingBox); - - const toggleRefineOnMapMove = () => - widgetState.internalToggleRefineOnMapMove(); - const createInternalToggleRefinementOnMapMove = - ( - renderOptions: TRenderOptions, - // false positive eslint because of generics - // eslint-disable-next-line no-shadow - render: (renderOptions: TRenderOptions) => void - ) => - () => { - widgetState.isRefineOnMapMove = !widgetState.isRefineOnMapMove; - - render(renderOptions); - }; - - const isRefineOnMapMove = () => widgetState.isRefineOnMapMove; - - const setMapMoveSinceLastRefine = () => - widgetState.internalSetMapMoveSinceLastRefine(); - const createInternalSetMapMoveSinceLastRefine = - ( - renderOptions: TRenderOptions, - // false positive eslint because of generics - // eslint-disable-next-line no-shadow - render: (renderOptions: TRenderOptions) => void - ) => - () => { - const shouldTriggerRender = - widgetState.hasMapMoveSinceLastRefine !== true; - - widgetState.hasMapMoveSinceLastRefine = true; - - if (shouldTriggerRender) { - render(renderOptions); - } - }; - - const hasMapMoveSinceLastRefine = () => - widgetState.hasMapMoveSinceLastRefine; - - let sendEvent: SendEventForHits; - - return { - $$type, - - init(initArgs) { - const { instantSearchInstance } = initArgs; - const isFirstRendering = true; - - widgetState.internalToggleRefineOnMapMove = - createInternalToggleRefinementOnMapMove(initArgs, noop); - - widgetState.internalSetMapMoveSinceLastRefine = - createInternalSetMapMoveSinceLastRefine(initArgs, noop); - - renderFn( - { - ...this.getWidgetRenderState(initArgs), - instantSearchInstance, - }, - isFirstRendering - ); - }, - - render(renderArgs) { - const { helper, instantSearchInstance } = renderArgs; - const isFirstRendering = false; - // We don't use the state provided by the render function because we need - // to be sure that the state is the latest one for the following condition - const state = helper.state; - - const positionChangedSinceLastRefine = - Boolean(state.aroundLatLng) && - Boolean(widgetState.lastRefinePosition) && - state.aroundLatLng !== widgetState.lastRefinePosition; - - const boundingBoxChangedSinceLastRefine = - !state.insideBoundingBox && - Boolean(widgetState.lastRefineBoundingBox) && - state.insideBoundingBox !== widgetState.lastRefineBoundingBox; - - if ( - positionChangedSinceLastRefine || - boundingBoxChangedSinceLastRefine - ) { - widgetState.hasMapMoveSinceLastRefine = false; - } - - widgetState.lastRefinePosition = state.aroundLatLng || ''; - - widgetState.lastRefineBoundingBox = getBoundingBoxAsString(state); - - widgetState.internalToggleRefineOnMapMove = - createInternalToggleRefinementOnMapMove( - renderArgs, - this.render!.bind(this) - ); - - widgetState.internalSetMapMoveSinceLastRefine = - createInternalSetMapMoveSinceLastRefine( - renderArgs, - this.render!.bind(this) - ); - - const widgetRenderState = this.getWidgetRenderState(renderArgs); - - sendEvent('view:internal', widgetRenderState.items); - - renderFn( - { - ...widgetRenderState, - instantSearchInstance, - }, - isFirstRendering - ); - }, - - getWidgetRenderState(renderOptions) { - const { helper, results, instantSearchInstance } = renderOptions; - const state = helper.state; - - const items = results - ? transformItems( - results.hits.filter((hit) => hit._geoloc), - { results } - ) - : []; - - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - helper, - widgetType: $$type, - }); - } - - return { - items, - position: getPositionFromState(state), - currentRefinement: getCurrentRefinementFromState(state), - refine: refine(helper), - sendEvent, - clearMapRefinement: clearMapRefinement(helper), - isRefinedWithMap: isRefinedWithMap(state), - toggleRefineOnMapMove, - isRefineOnMapMove, - setMapMoveSinceLastRefine, - hasMapMoveSinceLastRefine, - widgetParams, - }; - }, - - getRenderState( - renderState, - renderOptions - // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition - ): IndexRenderState & GeoSearchWidgetDescription['indexRenderState'] { - return { - ...renderState, - geoSearch: this.getWidgetRenderState(renderOptions), - }; - }, - - dispose({ state }) { - unmountFn(); - - return state.setQueryParameter('insideBoundingBox', undefined); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const boundingBox = getBoundingBoxAsString(searchParameters); - - if ( - !boundingBox || - (uiState && - uiState.geoSearch && - uiState.geoSearch.boundingBox === boundingBox) - ) { - return uiState; - } - - return { - ...uiState, - geoSearch: { - boundingBox, - }, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - if (!uiState || !uiState.geoSearch) { - return searchParameters.setQueryParameter( - 'insideBoundingBox', - undefined - ); - } - return setBoundingBoxAsString( - searchParameters, - uiState.geoSearch.boundingBox - ); - }, - }; - }; -} satisfies GeoSearchConnector); +export { connectGeoSearch as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts b/packages/instantsearch.js/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts index 11eb0b3bef1..5f5e588047e 100644 --- a/packages/instantsearch.js/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts +++ b/packages/instantsearch.js/src/connectors/hierarchical-menu/connectHierarchicalMenu.ts @@ -1,530 +1,2 @@ -import { - checkRendering, - warning, - createDocumentationMessageGenerator, - createSendEventForFacet, - isEqual, - noop, -} from '../../lib/utils'; - -import type { SendEventForFacet } from '../../lib/utils'; -import type { - Connector, - CreateURL, - TransformItems, - RenderOptions, - Widget, - SortBy, - WidgetRenderState, - IndexUiState, -} from '../../types'; -import type { SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'hierarchical-menu', - connector: true, -}); - -const DEFAULT_SORT = ['name:asc']; - -export type HierarchicalMenuItem = { - /** - * Value of the menu item. - */ - value: string; - /** - * Human-readable value of the menu item. - */ - label: string; - /** - * Number of matched results after refinement is applied. - */ - count: number; - /** - * Indicates if the refinement is applied. - */ - isRefined: boolean; - /** - * n+1 level of items, same structure HierarchicalMenuItem - */ - data: HierarchicalMenuItem[] | null; -}; - -export type HierarchicalMenuConnectorParams = { - /** - * Attributes to use to generate the hierarchy of the menu. - */ - attributes: string[]; - /** - * Separator used in the attributes to separate level values. - */ - separator?: string; - /** - * Prefix path to use if the first level is not the root level. - */ - rootPath?: string | null; - /** - * Show the siblings of the selected parent levels of the current refined value. This - * does not impact the root level. - */ - showParentLevel?: boolean; - /** - * Max number of values to display. - */ - limit?: number; - /** - * Whether to display the "show more" button. - */ - showMore?: boolean; - /** - * Max number of values to display when showing more. - */ - showMoreLimit?: number; - /** - * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. - * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). - * - * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed - */ - sortBy?: SortBy; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; -}; - -export type HierarchicalMenuRenderState = { - /** - * Creates an url for the next state for a clicked item. - */ - createURL: CreateURL; - /** - * Values to be rendered. - */ - items: HierarchicalMenuItem[]; - /** - * Sets the path of the hierarchical filter and triggers a new search. - */ - refine: (value: string) => void; - /** - * Indicates if search state can be refined. - */ - canRefine: boolean; - /** - * True if the menu is displaying all the menu items. - */ - isShowingMore: boolean; - /** - * Toggles the number of values displayed between `limit` and `showMoreLimit`. - */ - toggleShowMore: () => void; - /** - * `true` if the toggleShowMore button can be activated (enough items to display more or - * already displaying more than `limit` items) - */ - canToggleShowMore: boolean; - /** - * Send event to insights middleware - */ - sendEvent: SendEventForFacet; -}; - -export type HierarchicalMenuWidgetDescription = { - $$type: 'ais.hierarchicalMenu'; - renderState: HierarchicalMenuRenderState; - indexRenderState: { - hierarchicalMenu: { - [rootAttribute: string]: WidgetRenderState< - HierarchicalMenuRenderState, - HierarchicalMenuConnectorParams - >; - }; - }; - indexUiState: { - hierarchicalMenu: { - [rootAttribute: string]: string[]; - }; - }; -}; - -export type HierarchicalMenuConnector = Connector< - HierarchicalMenuWidgetDescription, - HierarchicalMenuConnectorParams ->; - -/** - * **HierarchicalMenu** connector provides the logic to build a custom widget - * that will give the user the ability to explore facets in a tree-like structure. - * - * This is commonly used for multi-level categorization of products on e-commerce - * websites. From a UX point of view, we suggest not displaying more than two - * levels deep. - * - * @type {Connector} - * @param {function(HierarchicalMenuRenderingOptions, boolean)} renderFn Rendering function for the custom **HierarchicalMenu** widget. - * @param {function} unmountFn Unmount function called when the widget is disposed. - * @return {function(CustomHierarchicalMenuWidgetParams)} Re-usable widget factory for a custom **HierarchicalMenu** widget. - */ -const connectHierarchicalMenu: HierarchicalMenuConnector = - function connectHierarchicalMenu(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - attributes, - separator = ' > ', - rootPath = null, - showParentLevel = true, - limit = 10, - showMore = false, - showMoreLimit = 20, - sortBy = DEFAULT_SORT, - transformItems = ((items) => items) as NonNullable< - HierarchicalMenuConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if ( - !attributes || - !Array.isArray(attributes) || - attributes.length === 0 - ) { - throw new Error( - withUsage('The `attributes` option expects an array of strings.') - ); - } - - if (showMore === true && showMoreLimit <= limit) { - throw new Error( - withUsage('The `showMoreLimit` option must be greater than `limit`.') - ); - } - - type ThisWidget = Widget< - HierarchicalMenuWidgetDescription & { - widgetParams: typeof widgetParams; - } - >; - - // we need to provide a hierarchicalFacet name for the search state - // so that we can always map $hierarchicalFacetName => real attributes - // we use the first attribute name - const [hierarchicalFacetName] = attributes; - - let sendEvent: HierarchicalMenuRenderState['sendEvent']; - - // Provide the same function to the `renderFn` so that way the user - // has to only bind it once when `isFirstRendering` for instance - let toggleShowMore = () => {}; - function cachedToggleShowMore() { - toggleShowMore(); - } - - let _refine: HierarchicalMenuRenderState['refine'] | undefined; - - let isShowingMore = false; - - function createToggleShowMore( - renderOptions: RenderOptions, - widget: ThisWidget - ) { - return () => { - isShowingMore = !isShowingMore; - widget.render!(renderOptions); - }; - } - - function getLimit() { - return isShowingMore ? showMoreLimit : limit; - } - - function _prepareFacetValues( - facetValues: SearchResults.HierarchicalFacet[] - ): HierarchicalMenuItem[] { - return facetValues - .slice(0, getLimit()) - .map( - ({ name: label, escapedValue: value, data, path, ...subValue }) => { - const item: HierarchicalMenuItem = { - ...subValue, - value, - label, - data: null, - }; - if (Array.isArray(data)) { - item.data = _prepareFacetValues(data); - } - return item; - } - ); - } - - function _hasMoreItems( - facetValues: SearchResults.HierarchicalFacet[], - maxValuesPerFacet: number - ): boolean { - const currentLimit = getLimit(); - - return ( - // Check if we have exhaustive items at this level - // If the limit is the max number of facet retrieved it is impossible to know - // if the facets are exhaustive. The only moment we are sure it is exhaustive - // is when it is strictly under the number requested unless we know that another - // widget has requested more values (maxValuesPerFacet > getLimit()). - !(maxValuesPerFacet > currentLimit - ? facetValues.length <= currentLimit - : facetValues.length < currentLimit) || - // Check if any of the children are not exhaustive. - facetValues - .slice(0, limit) - .some( - (item) => - Array.isArray(item.data) && - item.data.length > 0 && - _hasMoreItems(item.data, maxValuesPerFacet) - ) - ); - } - - return { - $$type: 'ais.hierarchicalMenu', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - toggleShowMore = createToggleShowMore(renderOptions, this); - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return state - .removeHierarchicalFacet(hierarchicalFacetName) - .setQueryParameter('maxValuesPerFacet', undefined); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - hierarchicalMenu: { - ...renderState.hierarchicalMenu, - [hierarchicalFacetName]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState({ - results, - state, - createURL, - instantSearchInstance, - helper, - }) { - let items: HierarchicalMenuRenderState['items'] = []; - let canToggleShowMore = false; - - // Bind createURL to this specific attribute - const _createURL = (facetValue: string) => { - return createURL((uiState) => - this.getWidgetUiState(uiState, { - searchParameters: state - .resetPage() - .toggleFacetRefinement(hierarchicalFacetName, facetValue), - helper, - }) - ); - }; - - if (!sendEvent) { - sendEvent = createSendEventForFacet({ - instantSearchInstance, - helper, - attribute(facetValue) { - const index = facetValue.split(separator).length - 1; - - return attributes[index]; - }, - widgetType: this.$$type, - }); - } - - if (!_refine) { - _refine = function (facetValue) { - sendEvent('click:internal', facetValue); - helper - .toggleFacetRefinement(hierarchicalFacetName, facetValue) - .search(); - }; - } - - if (results) { - const facetValues = results.getFacetValues(hierarchicalFacetName, { - sortBy, - facetOrdering: sortBy === DEFAULT_SORT, - }); - const facetItems = - facetValues && !Array.isArray(facetValues) && facetValues.data - ? facetValues.data - : []; - - // Check if there are more items to show at any level - // This checks both the exhaustiveness of items retrieved from the API - // and whether there are hidden items at any visible child level - const hasMoreItems = _hasMoreItems( - facetItems, - state.maxValuesPerFacet || 0 - ); - - canToggleShowMore = showMore && (isShowingMore || hasMoreItems); - - items = transformItems(_prepareFacetValues(facetItems), { - results, - }); - } - - return { - items, - refine: _refine, - canRefine: items.length > 0, - createURL: _createURL, - sendEvent, - widgetParams, - isShowingMore, - toggleShowMore: cachedToggleShowMore, - canToggleShowMore, - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - const path = searchParameters.getHierarchicalFacetBreadcrumb( - hierarchicalFacetName - ); - - return removeEmptyRefinementsFromUiState( - { - ...uiState, - hierarchicalMenu: { - ...uiState.hierarchicalMenu, - [hierarchicalFacetName]: path, - }, - }, - hierarchicalFacetName - ); - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const values = - uiState.hierarchicalMenu && - uiState.hierarchicalMenu[hierarchicalFacetName]; - - if ( - searchParameters.isConjunctiveFacet(hierarchicalFacetName) || - searchParameters.isDisjunctiveFacet(hierarchicalFacetName) - ) { - warning( - false, - `HierarchicalMenu: Attribute "${hierarchicalFacetName}" is already used by another widget applying conjunctive or disjunctive faceting. -As this is not supported, please make sure to remove this other widget or this HierarchicalMenu widget will not work at all.` - ); - - return searchParameters; - } - - if (searchParameters.isHierarchicalFacet(hierarchicalFacetName)) { - const facet = searchParameters.getHierarchicalFacetByName( - hierarchicalFacetName - ); - - warning( - isEqual(facet.attributes, attributes) && - facet.separator === separator && - facet.rootPath === rootPath, - 'Using Breadcrumb and HierarchicalMenu on the same facet with different options overrides the configuration of the HierarchicalMenu.' - ); - } - - const withFacetConfiguration = searchParameters - .removeHierarchicalFacet(hierarchicalFacetName) - .addHierarchicalFacet({ - name: hierarchicalFacetName, - attributes, - separator, - rootPath, - showParentLevel, - }); - - const currentMaxValuesPerFacet = - withFacetConfiguration.maxValuesPerFacet || 0; - - const nextMaxValuesPerFacet = Math.max( - currentMaxValuesPerFacet, - showMore ? showMoreLimit : limit - ); - - const withMaxValuesPerFacet = - withFacetConfiguration.setQueryParameter( - 'maxValuesPerFacet', - nextMaxValuesPerFacet - ); - - if (!values) { - return withMaxValuesPerFacet.setQueryParameters({ - hierarchicalFacetsRefinements: { - ...withMaxValuesPerFacet.hierarchicalFacetsRefinements, - [hierarchicalFacetName]: [], - }, - }); - } - - return withMaxValuesPerFacet.addHierarchicalFacetRefinement( - hierarchicalFacetName, - values.join(separator) - ); - }, - }; - }; - }; - -function removeEmptyRefinementsFromUiState( - indexUiState: IndexUiState, - attribute: string -): IndexUiState { - if (!indexUiState.hierarchicalMenu) { - return indexUiState; - } - - if ( - !indexUiState.hierarchicalMenu[attribute] || - indexUiState.hierarchicalMenu[attribute].length === 0 - ) { - delete indexUiState.hierarchicalMenu[attribute]; - } - - if (Object.keys(indexUiState.hierarchicalMenu).length === 0) { - delete indexUiState.hierarchicalMenu; - } - - return indexUiState; -} - -export default connectHierarchicalMenu; +export { connectHierarchicalMenu as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/hits-per-page/connectHitsPerPage.ts b/packages/instantsearch.js/src/connectors/hits-per-page/connectHitsPerPage.ts index 28d7f12f0b4..8c5fd708ead 100644 --- a/packages/instantsearch.js/src/connectors/hits-per-page/connectHitsPerPage.ts +++ b/packages/instantsearch.js/src/connectors/hits-per-page/connectHitsPerPage.ts @@ -1,311 +1,2 @@ -import { - checkRendering, - warning, - createDocumentationMessageGenerator, - noop, -} from '../../lib/utils'; - -import type { - Connector, - TransformItems, - CreateURL, - InitOptions, - RenderOptions, - WidgetRenderState, - Widget, -} from '../../types'; -import type { - AlgoliaSearchHelper, - SearchParameters, -} from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'hits-per-page', - connector: true, -}); - -export type HitsPerPageRenderStateItem = { - /** - * Label to display in the option. - */ - label: string; - - /** - * Number of hits to display per page. - */ - value: number; - - /** - * Indicates if it's the current refined value. - */ - isRefined: boolean; -}; - -export type HitsPerPageConnectorParamsItem = { - /** - * Label to display in the option. - */ - label: string; - - /** - * Number of hits to display per page. - */ - value: number; - - /** - * The default hits per page on first search. - * - * @default false - */ - default?: boolean; -}; - -export type HitsPerPageConnectorParams = { - /** - * Array of objects defining the different values and labels. - */ - items: HitsPerPageConnectorParamsItem[]; - - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; -}; - -export type HitsPerPageRenderState = { - /** - * Array of objects defining the different values and labels. - */ - items: HitsPerPageRenderStateItem[]; - - /** - * Creates the URL for a single item name in the list. - */ - createURL: CreateURL; - - /** - * Sets the number of hits per page and triggers a search. - */ - refine: (value: number) => void; - - /** - * Indicates whether or not the search has results. - * @deprecated Use `canRefine` instead. - */ - hasNoResults: boolean; - - /** - * Indicates if search state can be refined. - */ - canRefine: boolean; -}; - -export type HitsPerPageWidgetDescription = { - $$type: 'ais.hitsPerPage'; - renderState: HitsPerPageRenderState; - indexRenderState: { - hitsPerPage: WidgetRenderState< - HitsPerPageRenderState, - HitsPerPageConnectorParams - >; - }; - indexUiState: { - hitsPerPage: number; - }; -}; - -export type HitsPerPageConnector = Connector< - HitsPerPageWidgetDescription, - HitsPerPageConnectorParams ->; - -const connectHitsPerPage: HitsPerPageConnector = function connectHitsPerPage( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - items: userItems, - transformItems = ((items) => items) as NonNullable< - HitsPerPageConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if (!Array.isArray(userItems)) { - throw new Error( - withUsage('The `items` option expects an array of objects.') - ); - } - - let items = userItems; - - const defaultItems = items.filter((item) => item.default === true); - - if (defaultItems.length === 0) { - throw new Error( - withUsage(`A default value must be specified in \`items\`.`) - ); - } - - if (defaultItems.length > 1) { - throw new Error( - withUsage('More than one default value is specified in `items`.') - ); - } - - const defaultItem = defaultItems[0]; - - const normalizeItems = ({ hitsPerPage }: SearchParameters) => { - return items.map((item) => ({ - ...item, - isRefined: Number(item.value) === Number(hitsPerPage), - })); - }; - - type ConnectorState = { - getRefine: ( - helper: AlgoliaSearchHelper - ) => (value: HitsPerPageConnectorParamsItem['value']) => any; - createURLFactory: (props: { - state: SearchParameters; - createURL: (InitOptions | RenderOptions)['createURL']; - getWidgetUiState: NonNullable; - helper: AlgoliaSearchHelper; - }) => HitsPerPageRenderState['createURL']; - }; - - const connectorState: ConnectorState = { - getRefine: (helper) => (value) => { - return !value && value !== 0 - ? helper.setQueryParameter('hitsPerPage', undefined).search() - : helper.setQueryParameter('hitsPerPage', value).search(); - }, - createURLFactory: - ({ state, createURL, getWidgetUiState, helper }) => - (value) => - createURL((uiState) => - getWidgetUiState(uiState, { - searchParameters: state - .resetPage() - .setQueryParameter( - 'hitsPerPage', - !value && value !== 0 ? undefined : value - ), - helper, - }) - ), - }; - - return { - $$type: 'ais.hitsPerPage', - - init(initOptions) { - const { state, instantSearchInstance } = initOptions; - - const isCurrentInOptions = items.some( - (item) => Number(state.hitsPerPage) === Number(item.value) - ); - - if (!isCurrentInOptions) { - warning( - state.hitsPerPage !== undefined, - ` -\`hitsPerPage\` is not defined. -The option \`hitsPerPage\` needs to be set using the \`configure\` widget. - -Learn more: https://www.algolia.com/doc/api-reference/widgets/hits-per-page/js/ - ` - ); - - warning( - false, - ` -The \`items\` option of \`hitsPerPage\` does not contain the "hits per page" value coming from the state: ${state.hitsPerPage}. - -You may want to add another entry to the \`items\` option with this value.` - ); - - items = [ - // The helper will convert the empty string to `undefined`. - { value: '' as unknown as number, label: '' }, - ...items, - ]; - } - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return state.setQueryParameter('hitsPerPage', undefined); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - hitsPerPage: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ state, results, createURL, helper }) { - const canRefine = results ? results.nbHits > 0 : false; - - return { - items: transformItems(normalizeItems(state), { results }), - refine: connectorState.getRefine(helper), - createURL: connectorState.createURLFactory({ - state, - createURL, - getWidgetUiState: this.getWidgetUiState, - helper, - }), - hasNoResults: !canRefine, - canRefine, - widgetParams, - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - const hitsPerPage = searchParameters.hitsPerPage; - - if (hitsPerPage === undefined || hitsPerPage === defaultItem.value) { - return uiState; - } - - return { - ...uiState, - hitsPerPage, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - return searchParameters.setQueryParameters({ - hitsPerPage: uiState.hitsPerPage || defaultItem.value, - }); - }, - }; - }; -}; - -export default connectHitsPerPage; +export { connectHitsPerPage as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/hits/__tests__/connectHits-test.ts b/packages/instantsearch.js/src/connectors/hits/__tests__/connectHits-test.ts index 6a2a549d729..eb1416b9cae 100644 --- a/packages/instantsearch.js/src/connectors/hits/__tests__/connectHits-test.ts +++ b/packages/instantsearch.js/src/connectors/hits/__tests__/connectHits-test.ts @@ -30,7 +30,7 @@ import type { SearchResponse, } from '../../../types'; -jest.mock('../../../lib/utils/hits-absolute-position', () => ({ +jest.mock('../../../../../instantsearch-core/src/lib/public/hits-absolute-position', () => ({ // The real implementation creates a new array instance, which can cause bugs, // especially with the __escaped mark, we thus make sure the mock also has the // same behavior regarding the array. diff --git a/packages/instantsearch.js/src/connectors/hits/__tests__/connectHitsWithInsights-test.ts b/packages/instantsearch.js/src/connectors/hits/__tests__/connectHitsWithInsights-test.ts index 838a2abbf39..3283096f49d 100644 --- a/packages/instantsearch.js/src/connectors/hits/__tests__/connectHitsWithInsights-test.ts +++ b/packages/instantsearch.js/src/connectors/hits/__tests__/connectHitsWithInsights-test.ts @@ -18,7 +18,7 @@ import connectHitsWithInsights from '../connectHitsWithInsights'; import type { Hit } from '../../../types'; -jest.mock('../../../lib/utils/hits-absolute-position', () => ({ +jest.mock('../../../../../instantsearch-core/src/lib/public/hits-absolute-position', () => ({ addAbsolutePosition: (hits: Hit[]) => hits, })); diff --git a/packages/instantsearch.js/src/connectors/hits/connectHits.ts b/packages/instantsearch.js/src/connectors/hits/connectHits.ts index e3f79074b3f..82453ec05c6 100644 --- a/packages/instantsearch.js/src/connectors/hits/connectHits.ts +++ b/packages/instantsearch.js/src/connectors/hits/connectHits.ts @@ -1,236 +1,2 @@ -import { - escapeHits, - TAG_PLACEHOLDER, - checkRendering, - createDocumentationMessageGenerator, - addAbsolutePosition, - addQueryID, - createSendEventForHits, - createBindEventForHits, - noop, -} from '../../lib/utils'; - -import type { SendEventForHits, BindEventForHits } from '../../lib/utils'; -import type { - TransformItems, - Connector, - Hit, - WidgetRenderState, - BaseHit, - Unmounter, - Renderer, - IndexRenderState, -} from '../../types'; -import type { Banner, SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'hits', - connector: true, -}); - -export type HitsRenderState = BaseHit> = { - /** - * The matched hits from Algolia API. - * @deprecated use `items` instead - */ - hits: Array>; - - /** - * The matched hits from Algolia API. - */ - items: Array>; - - /** - * The response from the Algolia API. - */ - results?: SearchResults>; - - /** - * The banner to display above the hits. - */ - banner?: Banner; - - /** - * Sends an event to the Insights middleware. - */ - sendEvent: SendEventForHits; - - /** - * Returns a string for the `data-insights-event` attribute for the Insights middleware - */ - bindEvent: BindEventForHits; -}; - -export type HitsConnectorParams = BaseHit> = { - /** - * Whether to escape HTML tags from hits string values. - * - * @default true - */ - escapeHTML?: boolean; - - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems>; -}; - -export type HitsWidgetDescription = BaseHit> = - { - $$type: 'ais.hits'; - renderState: HitsRenderState; - indexRenderState: { - hits: WidgetRenderState, HitsConnectorParams>; - }; - }; - -export type HitsConnector = BaseHit> = - Connector, HitsConnectorParams>; - -export default (function connectHits( - renderFn: Renderer, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return = BaseHit>( - widgetParams: TWidgetParams & HitsConnectorParams - ) => { - const { - // @MAJOR: this can default to false - escapeHTML = true, - transformItems = ((items) => items) as NonNullable< - HitsConnectorParams['transformItems'] - >, - } = widgetParams || {}; - let sendEvent: SendEventForHits; - let bindEvent: BindEventForHits; - - return { - $$type: 'ais.hits', - - init(initOptions) { - 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.items); - }, - - getRenderState( - renderState, - renderOptions - // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition - ): IndexRenderState & HitsWidgetDescription['indexRenderState'] { - return { - ...renderState, - hits: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ results, helper, instantSearchInstance }) { - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - helper, - widgetType: this.$$type, - }); - } - - if (!bindEvent) { - bindEvent = createBindEventForHits({ - helper, - widgetType: this.$$type, - instantSearchInstance, - }); - } - - if (!results) { - return { - hits: [], - items: [], - results: undefined, - banner: undefined, - sendEvent, - bindEvent, - widgetParams, - }; - } - - if (escapeHTML && results.hits.length > 0) { - results.hits = escapeHits(results.hits); - } - - const hitsWithAbsolutePosition = addAbsolutePosition( - results.hits, - results.page, - results.hitsPerPage - ); - - const hitsWithAbsolutePositionAndQueryID = addQueryID( - hitsWithAbsolutePosition, - results.queryID - ); - - const items = transformItems(hitsWithAbsolutePositionAndQueryID, { - results, - }); - - const banner = results.renderingContent?.widgets?.banners?.[0]; - - return { - hits: items, - items, - results, - banner, - sendEvent, - bindEvent, - widgetParams, - }; - }, - - dispose({ state }) { - unmountFn(); - - if (!escapeHTML) { - return state; - } - - return state.setQueryParameters( - Object.keys(TAG_PLACEHOLDER).reduce( - (acc, key) => ({ - ...acc, - [key]: undefined, - }), - {} - ) - ); - }, - - getWidgetSearchParameters(state, _uiState) { - if (!escapeHTML) { - return state; - } - - // @MAJOR: set this globally, not in the Hits widget to allow Hits to be conditionally used - return state.setQueryParameters(TAG_PLACEHOLDER); - }, - }; - }; -} satisfies HitsConnector); +export { connectHits as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/hits/connectHitsWithInsights.ts b/packages/instantsearch.js/src/connectors/hits/connectHitsWithInsights.ts index 12f24b9dc68..cb1db7a27ac 100644 --- a/packages/instantsearch.js/src/connectors/hits/connectHitsWithInsights.ts +++ b/packages/instantsearch.js/src/connectors/hits/connectHitsWithInsights.ts @@ -1,21 +1,2 @@ -import { withInsights } from '../../lib/insights'; - -import connectHits from './connectHits'; - -import type { Connector } from '../../types'; -import type { HitsConnectorParams, HitsWidgetDescription } from './connectHits'; - -/** - * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need - * Connector<...> imported in this file, even though it is only used implicitly. - * This _uses_ Connector<...> so it is not accidentally removed by someone. - */ -// eslint-disable-next-line no-unused-vars -declare type ImportWorkaround = Connector< - HitsWidgetDescription, - HitsConnectorParams ->; - -const connectHitsWithInsights = withInsights(connectHits); - -export default connectHitsWithInsights; +export { connectHitsWithInsights as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/index.ts b/packages/instantsearch.js/src/connectors/index.ts index e7c97a3bfdb..7900ce7f98c 100644 --- a/packages/instantsearch.js/src/connectors/index.ts +++ b/packages/instantsearch.js/src/connectors/index.ts @@ -2,7 +2,7 @@ import { deprecate } from '../lib/utils'; import connectAnswers from './answers/connectAnswers'; import connectConfigureRelatedItems from './configure-related-items/connectConfigureRelatedItems'; -import connectDynamicWidgets from './dynamic-widgets/connectDynamicWidgets'; +import { connectDynamicWidgets } from 'instantsearch-core'; /** @deprecated answers is no longer supported */ export const EXPERIMENTAL_connectAnswers = deprecate( @@ -22,39 +22,244 @@ export const EXPERIMENTAL_connectDynamicWidgets = deprecate( 'use connectDynamicWidgets' ); -export { connectDynamicWidgets }; - -export { default as connectClearRefinements } from './clear-refinements/connectClearRefinements'; -export { default as connectCurrentRefinements } from './current-refinements/connectCurrentRefinements'; -export { default as connectHierarchicalMenu } from './hierarchical-menu/connectHierarchicalMenu'; -export { default as connectHits } from './hits/connectHits'; -export { default as connectHitsWithInsights } from './hits/connectHitsWithInsights'; -export { default as connectHitsPerPage } from './hits-per-page/connectHitsPerPage'; -export { default as connectInfiniteHits } from './infinite-hits/connectInfiniteHits'; -export { default as connectInfiniteHitsWithInsights } from './infinite-hits/connectInfiniteHitsWithInsights'; -export { default as connectMenu } from './menu/connectMenu'; -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'; -export { default as connectStats } from './stats/connectStats'; -export { default as connectToggleRefinement } from './toggle-refinement/connectToggleRefinement'; -export { default as connectTrendingFacets } from './trending-facets/connectTrendingFacets'; -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'; -export { default as connectConfigure } from './configure/connectConfigure'; -export { default as connectAutocomplete } from './autocomplete/connectAutocomplete'; -export { default as connectQueryRules } from './query-rules/connectQueryRules'; -export { default as connectVoiceSearch } from './voice-search/connectVoiceSearch'; -export { default as connectRelevantSort } from './relevant-sort/connectRelevantSort'; -export { default as connectFrequentlyBoughtTogether } from './frequently-bought-together/connectFrequentlyBoughtTogether'; -export { default as connectLookingSimilar } from './looking-similar/connectLookingSimilar'; -export { default as connectChat } from './chat/connectChat'; -export { default as connectFeeds } from './feeds/connectFeeds'; -export { default as connectFilterSuggestions } from './filter-suggestions/connectFilterSuggestions'; +export { connectAutocomplete } from 'instantsearch-core'; +export { connectBreadcrumb } from 'instantsearch-core'; +export { connectChat } from 'instantsearch-core'; +export { connectClearRefinements } from 'instantsearch-core'; +export { connectConfigure } from 'instantsearch-core'; +export { connectCurrentRefinements } from 'instantsearch-core'; +export { connectDynamicWidgets } from 'instantsearch-core'; +export { connectFeeds } from 'instantsearch-core'; +export { connectFilterSuggestions } from 'instantsearch-core'; +export { connectFrequentlyBoughtTogether } from 'instantsearch-core'; +export { connectGeoSearch } from 'instantsearch-core'; +export { connectHierarchicalMenu } from 'instantsearch-core'; +export { connectHits } from 'instantsearch-core'; +export { connectHitsWithInsights } from 'instantsearch-core'; +export { connectHitsPerPage } from 'instantsearch-core'; +export { connectInfiniteHits } from 'instantsearch-core'; +export { connectInfiniteHitsWithInsights } from 'instantsearch-core'; +export { connectLookingSimilar } from 'instantsearch-core'; +export { connectMenu } from 'instantsearch-core'; +export { connectNumericMenu } from 'instantsearch-core'; +export { connectPagination } from 'instantsearch-core'; +export { connectPoweredBy } from 'instantsearch-core'; +export { connectQueryRules } from 'instantsearch-core'; +export { connectRange } from 'instantsearch-core'; +export { connectRatingMenu } from 'instantsearch-core'; +export { connectRefinementList } from 'instantsearch-core'; +export { connectRelatedProducts } from 'instantsearch-core'; +export { connectRelevantSort } from 'instantsearch-core'; +export { connectSearchBox } from 'instantsearch-core'; +export { connectSortBy } from 'instantsearch-core'; +export { connectStats } from 'instantsearch-core'; +export { connectToggleRefinement } from 'instantsearch-core'; +export { connectTrendingFacets } from 'instantsearch-core'; +export { connectTrendingItems } from 'instantsearch-core'; +export { connectVoiceSearch } from 'instantsearch-core'; +export { createFeedContainer } from 'instantsearch-core'; +export type { + ApplyFiltersParams, + AutocompleteConnector, + AutocompleteConnectorParams, + AutocompleteRenderState, + AutocompleteWidgetDescription, + BreadcrumbConnector, + BreadcrumbConnectorParams, + BreadcrumbConnectorParamsItem +} from 'instantsearch-core'; +export type { + BreadcrumbRenderState, + BreadcrumbWidgetDescription, + ChatConnector, + ChatConnectorParams, + ChatInit, + ChatInitWithoutTransport, + ChatRenderState, + ChatTransport +} from 'instantsearch-core'; +export type { + ChatWidgetDescription, + ClearRefinementsConnector, + ClearRefinementsConnectorParams, + ClearRefinementsRenderState, + ClearRefinementsWidgetDescription, + ConfigureConnector, + ConfigureConnectorParams, + ConfigureRenderState +} from 'instantsearch-core'; +export type { + ConfigureWidgetDescription, + CurrentRefinementsConnector, + CurrentRefinementsConnectorParams, + CurrentRefinementsConnectorParamsItem, + CurrentRefinementsConnectorParamsRefinement, + CurrentRefinementsRenderState, + CurrentRefinementsWidgetDescription, + DynamicWidgetsConnector +} from 'instantsearch-core'; +export type { + DynamicWidgetsConnectorParams, + DynamicWidgetsRenderState, + DynamicWidgetsWidgetDescription, + FeedsConnector, + FeedsConnectorParams, + FeedsRenderState, + FeedsWidgetDescription, + FilterSuggestionsConnector +} from 'instantsearch-core'; +export type { + FilterSuggestionsConnectorParams, + FilterSuggestionsRenderState, + FilterSuggestionsTransport, + FilterSuggestionsWidgetDescription, + FrequentlyBoughtTogetherConnector, + FrequentlyBoughtTogetherConnectorParams, + FrequentlyBoughtTogetherRenderState, + FrequentlyBoughtTogetherWidgetDescription +} from 'instantsearch-core'; +export type { + GeoHit, + GeoSearchConnector, + GeoSearchConnectorParams, + GeoSearchRenderState, + GeoSearchWidgetDescription, + HierarchicalMenuConnector, + HierarchicalMenuConnectorParams, + HierarchicalMenuItem +} from 'instantsearch-core'; +export type { + HierarchicalMenuRenderState, + HierarchicalMenuWidgetDescription, + HitsConnector, + HitsConnectorParams, + HitsPerPageConnector, + HitsPerPageConnectorParams, + HitsPerPageConnectorParamsItem, + HitsPerPageRenderState +} from 'instantsearch-core'; +export type { + HitsPerPageRenderStateItem, + HitsPerPageWidgetDescription, + HitsRenderState, + HitsWidgetDescription, + InfiniteHitsCache, + InfiniteHitsCachedHits, + InfiniteHitsConnector, + InfiniteHitsConnectorParams +} from 'instantsearch-core'; +export type { + InfiniteHitsRenderState, + InfiniteHitsWidgetDescription, + LookingSimilarConnector, + LookingSimilarConnectorParams, + LookingSimilarRenderState, + LookingSimilarWidgetDescription, + MenuConnector, + MenuConnectorParams +} from 'instantsearch-core'; +export type { + MenuItem, + MenuRenderState, + MenuWidgetDescription, + NumericMenuConnector, + NumericMenuConnectorParams, + NumericMenuConnectorParamsItem, + NumericMenuRenderState, + NumericMenuRenderStateItem +} from 'instantsearch-core'; +export type { + NumericMenuWidgetDescription, + PaginationConnector, + PaginationConnectorParams, + PaginationRenderState, + PaginationWidgetDescription, + ParamTrackedFilters, + ParamTransformRuleContexts, + PoweredByConnector +} from 'instantsearch-core'; +export type { + PoweredByConnectorParams, + PoweredByRenderState, + PoweredByWidgetDescription, + QueryRulesConnector, + QueryRulesConnectorParams, + QueryRulesRenderState, + QueryRulesWidgetDescription, + Range +} from 'instantsearch-core'; +export type { + RangeBoundaries, + RangeConnector, + RangeConnectorParams, + RangeMax, + RangeMin, + RangeRenderState, + RangeWidgetDescription, + RatingMenuConnector +} from 'instantsearch-core'; +export type { + RatingMenuConnectorParams, + RatingMenuRenderState, + RatingMenuWidgetDescription, + RefinementListConnector, + RefinementListConnectorParams, + RefinementListItem, + RefinementListRenderState, + RefinementListWidgetDescription +} from 'instantsearch-core'; +export type { + RelatedProductsConnector, + RelatedProductsConnectorParams, + RelatedProductsRenderState, + RelatedProductsWidgetDescription, + RelevantSortConnector, + RelevantSortConnectorParams, + RelevantSortRenderState, + RelevantSortWidgetDescription +} from 'instantsearch-core'; +export type { + SearchBoxConnector, + SearchBoxConnectorParams, + SearchBoxRenderState, + SearchBoxWidgetDescription, + SendEventForToggle, + SortByConnector, + SortByConnectorParams, + SortByIndexItem +} from 'instantsearch-core'; +export type { + SortByItem, + SortByRenderState, + SortByStrategyItem, + SortByWidgetDescription, + StatsConnector, + StatsConnectorParams, + StatsRenderState, + StatsWidgetDescription +} from 'instantsearch-core'; +export type { + Suggestion, + ToggleRefinementConnector, + ToggleRefinementConnectorParams, + ToggleRefinementRenderState, + ToggleRefinementValue, + ToggleRefinementWidgetDescription, + TransformItemsIndicesConfig, + TrendingFacetsConnector +} from 'instantsearch-core'; +export type { + TrendingFacetsConnectorParams, + TrendingFacetsRenderState, + TrendingFacetsWidgetDescription, + TrendingItemsConnector, + TrendingItemsConnectorParams, + TrendingItemsRenderState, + TrendingItemsWidgetDescription, + VoiceSearchConnector +} from 'instantsearch-core'; +export type { + VoiceSearchConnectorParams, + VoiceSearchRenderState, + VoiceSearchWidgetDescription +} from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts b/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts index b6c5420832f..b70fb7077b1 100644 --- a/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts +++ b/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts @@ -29,7 +29,7 @@ import type { SearchResponse, } from '../../../types'; -jest.mock('../../../lib/utils/hits-absolute-position', () => ({ +jest.mock('../../../../../instantsearch-core/src/lib/public/hits-absolute-position', () => ({ // The real implementation creates a new array instance, which can cause bugs, // especially with the __escaped mark, we thus make sure the mock also has the // same behavior regarding the array. diff --git a/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHitsWithInsights-test.ts b/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHitsWithInsights-test.ts index e850871bb35..5e45cc777df 100644 --- a/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHitsWithInsights-test.ts +++ b/packages/instantsearch.js/src/connectors/infinite-hits/__tests__/connectInfiniteHitsWithInsights-test.ts @@ -23,7 +23,7 @@ import type { Hit, } from '../../../types'; -jest.mock('../../../lib/utils/hits-absolute-position', () => ({ +jest.mock('../../../../../instantsearch-core/src/lib/public/hits-absolute-position', () => ({ addAbsolutePosition: (hits: Hit[]) => hits, })); diff --git a/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts b/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts index 2deea5c64f2..0596bdf1fa4 100644 --- a/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts +++ b/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts @@ -1,510 +1,2 @@ -import { - escapeHits, - TAG_PLACEHOLDER, - checkRendering, - createDocumentationMessageGenerator, - isEqual, - addAbsolutePosition, - addQueryID, - noop, - createSendEventForHits, - createBindEventForHits, - walkIndex, - isTwoPassWidget, -} from '../../lib/utils'; - -import type { SendEventForHits, BindEventForHits } from '../../lib/utils'; -import type { - Connector, - TransformItems, - Hit, - WidgetRenderState, - BaseHit, - Renderer, - Unmounter, - UnknownWidgetParams, - IndexRenderState, -} from '../../types'; -import type { - Banner, - AlgoliaSearchHelper as Helper, - PlainSearchParameters, - SearchParameters, - SearchResults, -} from 'algoliasearch-helper'; - -export type InfiniteHitsCachedHits> = { - [page: number]: Array>; -}; - -type Read> = ({ - state, -}: { - state: PlainSearchParameters; -}) => InfiniteHitsCachedHits | null; - -type Write> = ({ - state, - hits, -}: { - state: PlainSearchParameters; - hits: InfiniteHitsCachedHits; -}) => void; - -export type InfiniteHitsCache = BaseHit> = { - read: Read; - write: Write; -}; - -export type InfiniteHitsConnectorParams< - THit extends NonNullable = BaseHit -> = { - /** - * Escapes HTML entities from hits string values. - * - * @default `true` - */ - escapeHTML?: boolean; - - /** - * Enable the button to load previous results. - * - * @default `false` - */ - showPrevious?: boolean; - - /** - * Receives the items, and is called before displaying them. - * Useful for mapping over the items to transform, and remove or reorder them. - */ - transformItems?: TransformItems>; - - /** - * Reads and writes hits from/to cache. - * When user comes back to the search page after leaving for product page, - * this helps restore InfiniteHits and its scroll position. - */ - cache?: InfiniteHitsCache; -}; - -export type InfiniteHitsRenderState< - THit extends NonNullable = BaseHit -> = { - /** - * Loads the previous results. - */ - showPrevious: () => void; - - /** - * Loads the next page of hits. - */ - showMore: () => void; - - /** - * Indicates whether the first page of hits has been reached. - */ - isFirstPage: boolean; - - /** - * Indicates whether the last page of hits has been reached. - */ - isLastPage: boolean; - - /** - * Send event to insights middleware - */ - sendEvent: SendEventForHits; - - /** - * Returns a string of data-insights-event attribute for insights middleware - */ - bindEvent: BindEventForHits; - - /** - * Hits for the current page - */ - currentPageHits: Array>; - - /** - * Hits for current and cached pages - * @deprecated use `items` instead - */ - hits: Array>; - - /** - * Hits for current and cached pages - */ - items: Array>; - - /** - * The response from the Algolia API. - */ - results?: SearchResults> | null; - - /** - * The banner to display above the hits. - */ - banner?: Banner; -}; - -const withUsage = createDocumentationMessageGenerator({ - name: 'infinite-hits', - connector: true, -}); - -export type InfiniteHitsWidgetDescription< - THit extends NonNullable = BaseHit -> = { - $$type: 'ais.infiniteHits'; - renderState: InfiniteHitsRenderState; - indexRenderState: { - infiniteHits: WidgetRenderState< - InfiniteHitsRenderState, - InfiniteHitsConnectorParams - >; - }; - indexUiState: { - page: number; - }; -}; - -export type InfiniteHitsConnector = BaseHit> = - Connector< - InfiniteHitsWidgetDescription, - InfiniteHitsConnectorParams - >; - -function getStateWithoutPage(state: PlainSearchParameters) { - const { page, ...rest } = state || {}; - return rest; -} - -function normalizeState(state: PlainSearchParameters) { - const { clickAnalytics, userToken, ...rest } = state || {}; - return rest; -} - -function getInMemoryCache< - THit extends NonNullable ->(): InfiniteHitsCache { - let cachedHits: InfiniteHitsCachedHits | null = null; - let cachedState: PlainSearchParameters | null = null; - return { - read({ state }) { - return isEqual(cachedState, getStateWithoutPage(state)) - ? cachedHits - : null; - }, - write({ state, hits }) { - cachedState = getStateWithoutPage(state); - cachedHits = hits; - }, - }; -} - -function extractHitsFromCachedHits>( - cachedHits: InfiniteHitsCachedHits -) { - return Object.keys(cachedHits) - .map(Number) - .sort((a, b) => a - b) - .reduce((acc: Array>, page) => { - return acc.concat(cachedHits[page]); - }, []); -} - -export default (function connectInfiniteHits< - TWidgetParams extends UnknownWidgetParams ->( - renderFn: Renderer, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return = BaseHit>( - widgetParams: TWidgetParams & InfiniteHitsConnectorParams - ) => { - const { - // @MAJOR: this can default to false - escapeHTML = true, - transformItems = ((items) => items) as NonNullable< - InfiniteHitsConnectorParams['transformItems'] - >, - cache = getInMemoryCache(), - } = widgetParams || {}; - let showPrevious: () => void; - let showMore: () => void; - let sendEvent: SendEventForHits; - let bindEvent: BindEventForHits; - const getFirstReceivedPage = ( - state: SearchParameters, - cachedHits: InfiniteHitsCachedHits - ) => { - const { page = 0 } = state; - const pages = Object.keys(cachedHits).map(Number); - if (pages.length === 0) { - return page; - } else { - return Math.min(page, ...pages); - } - }; - const getLastReceivedPage = ( - state: SearchParameters, - cachedHits: InfiniteHitsCachedHits - ) => { - const { page = 0 } = state; - const pages = Object.keys(cachedHits).map(Number); - if (pages.length === 0) { - return page; - } else { - return Math.max(page, ...pages); - } - }; - - const getShowPrevious = - ( - helper: Helper, - getCachedHits: () => InfiniteHitsCachedHits - ): (() => void) => - () => { - const cachedHits = getCachedHits(); - // Using the helper's `overrideStateWithoutTriggeringChangeEvent` method - // avoid updating the browser URL when the user displays the previous page. - helper - .overrideStateWithoutTriggeringChangeEvent({ - ...helper.state, - page: getFirstReceivedPage(helper.state, cachedHits) - 1, - }) - .searchWithoutTriggeringOnStateChange(); - }; - - const getShowMore = - ( - helper: Helper, - getCachedHits: () => InfiniteHitsCachedHits - ): (() => void) => - () => { - const cachedHits = getCachedHits(); - helper - .setPage(getLastReceivedPage(helper.state, cachedHits) + 1) - .search(); - }; - - return { - $$type: 'ais.infiniteHits', - - init(initOptions) { - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - const widgetRenderState = this.getWidgetRenderState(renderOptions); - - renderFn( - { - ...widgetRenderState, - instantSearchInstance, - }, - false - ); - - sendEvent('view:internal', widgetRenderState.currentPageHits); - }, - - getRenderState( - renderState, - renderOptions - // Type is explicitly redefined, to avoid having the TWidgetParams type in the definition - ): IndexRenderState & InfiniteHitsWidgetDescription['indexRenderState'] { - return { - ...renderState, - infiniteHits: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ - results, - helper, - parent, - state: existingState, - instantSearchInstance, - }) { - const getCacheHits = () => { - const state = parent.getPreviousState() || existingState; - return cache.read({ state: normalizeState(state) }) || {}; - }; - - let isFirstPage: boolean; - let currentPageHits: Array> = []; - /** - * We bail out of optimistic UI here, as the cache is based on search - * parameters, and we don't want to invalidate the cache when the search - * is loading. - */ - const state = parent.getPreviousState() || existingState; - - const cachedHits = getCacheHits(); - - const banner = results?.renderingContent?.widgets?.banners?.[0]; - - if (!showPrevious) { - showPrevious = () => getShowPrevious(helper, getCacheHits)(); - showMore = () => getShowMore(helper, getCacheHits)(); - } - - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - helper, - widgetType: this.$$type, - }); - bindEvent = createBindEventForHits({ - helper, - widgetType: this.$$type, - instantSearchInstance, - }); - } - - if (!results) { - isFirstPage = - state.page === undefined || - getFirstReceivedPage(state, cachedHits) === 0; - } else { - const { page = 0 } = state; - - if (escapeHTML && results.hits.length > 0) { - results.hits = escapeHits(results.hits); - } - - const hitsWithAbsolutePosition = addAbsolutePosition( - results.hits, - results.page, - results.hitsPerPage - ); - - const hitsWithAbsolutePositionAndQueryID = addQueryID( - hitsWithAbsolutePosition, - results.queryID - ); - - const transformedHits = transformItems( - hitsWithAbsolutePositionAndQueryID, - { results } - ); - - /* - With dynamic widgets, facets are not included in the state before their relevant widgets are mounted. Until then, we need to bail out of writing this incomplete state representation in cache. - */ - let hasTwoPassWidgets = false; - walkIndex(instantSearchInstance.mainIndex, (indexWidget) => { - if ( - !hasTwoPassWidgets && - indexWidget.getWidgets().some(isTwoPassWidget) - ) { - hasTwoPassWidgets = true; - } - }); - - const hasNoFacets = - !state.disjunctiveFacets?.length && - !(state.facets || []).filter((f) => f !== '*').length && - !state.hierarchicalFacets?.length; - - if ( - cachedHits[page] === undefined && - !results.__isArtificial && - instantSearchInstance.status === 'idle' && - !(hasTwoPassWidgets && hasNoFacets) - ) { - cachedHits[page] = transformedHits; - cache.write({ state: normalizeState(state), hits: cachedHits }); - } - currentPageHits = transformedHits; - - isFirstPage = getFirstReceivedPage(state, cachedHits) === 0; - } - - const items = extractHitsFromCachedHits(cachedHits); - const isLastPage = results - ? results.nbPages <= getLastReceivedPage(state, cachedHits) + 1 - : true; - - return { - hits: items, - items, - currentPageHits, - sendEvent, - bindEvent, - banner, - results: results || undefined, - showPrevious, - showMore, - isFirstPage, - isLastPage, - widgetParams, - }; - }, - - dispose({ state }) { - unmountFn(); - - const stateWithoutPage = state.setQueryParameter('page', undefined); - - if (!escapeHTML) { - return stateWithoutPage; - } - - return stateWithoutPage.setQueryParameters( - Object.keys(TAG_PLACEHOLDER).reduce( - (acc, key) => ({ - ...acc, - [key]: undefined, - }), - {} - ) - ); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const page = searchParameters.page || 0; - - if (!page) { - // return without adding `page` to uiState - // because we don't want `page=1` in the URL - return uiState; - } - - return { - ...uiState, - // The page in the UI state is incremented by one - // to expose the user value (not `0`). - page: page + 1, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - let widgetSearchParameters = searchParameters; - - if (escapeHTML) { - // @MAJOR: set this globally, not in the InfiniteHits widget to allow InfiniteHits to be conditionally used - widgetSearchParameters = - searchParameters.setQueryParameters(TAG_PLACEHOLDER); - } - - // The page in the search parameters is decremented by one - // to get to the actual parameter value from the UI state. - const page = uiState.page ? uiState.page - 1 : 0; - - return widgetSearchParameters.setQueryParameter('page', page); - }, - }; - }; -} satisfies InfiniteHitsConnector); +export { connectInfiniteHits as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts b/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts index acf91b7a831..21eba4c03a7 100644 --- a/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts +++ b/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHitsWithInsights.ts @@ -1,24 +1,2 @@ -import { withInsights } from '../../lib/insights'; - -import connectInfiniteHits from './connectInfiniteHits'; - -import type { Connector } from '../../types'; -import type { - InfiniteHitsWidgetDescription, - InfiniteHitsConnectorParams, -} from './connectInfiniteHits'; - -/** - * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need - * Connector<...> imported in this file, even though it is only used implicitly. - * This _uses_ Connector<...> so it is not accidentally removed by someone. - */ -// eslint-disable-next-line no-unused-vars -declare type ImportWorkaround = Connector< - InfiniteHitsWidgetDescription, - InfiniteHitsConnectorParams ->; - -const connectInfiniteHitsWithInsights = withInsights(connectInfiniteHits); - -export default connectInfiniteHitsWithInsights; +export { connectInfiniteHitsWithInsights as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/looking-similar/connectLookingSimilar.ts b/packages/instantsearch.js/src/connectors/looking-similar/connectLookingSimilar.ts index e5bc783bbb2..e1d30e1b747 100644 --- a/packages/instantsearch.js/src/connectors/looking-similar/connectLookingSimilar.ts +++ b/packages/instantsearch.js/src/connectors/looking-similar/connectLookingSimilar.ts @@ -1,235 +1,2 @@ -import { - createDocumentationMessageGenerator, - checkRendering, - noop, - escapeHits, - TAG_PLACEHOLDER, - createSendEventForHits, - addAbsolutePosition, - addQueryID, -} from '../../lib/utils'; - -import type { SendEventForHits } from '../../lib/utils'; -import type { - Connector, - TransformItems, - BaseHit, - Renderer, - Unmounter, - UnknownWidgetParams, - RecommendResponse, - Hit, - AlgoliaHit, -} from '../../types'; -import type { PlainSearchParameters } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'looking-similar', - connector: true, -}); - -export type LookingSimilarRenderState< - THit extends NonNullable = BaseHit -> = { - /** - * The matched recommendations from the Algolia API. - */ - items: Array>; - /** - * Sends an event to the Insights middleware. - */ - sendEvent: SendEventForHits; -}; - -export type LookingSimilarConnectorParams< - THit extends NonNullable = BaseHit -> = { - /** - * The `objectIDs` of the items to get similar looking products from. - */ - objectIDs: string[]; - /** - * The number of recommendations to retrieve. - */ - limit?: 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' - >; - /** - * Whether to escape HTML tags from items string values. - * - * @default true - */ - escapeHTML?: boolean; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems< - Hit, - { results: RecommendResponse> } - >; -}; - -export type LookingSimilarWidgetDescription< - THit extends NonNullable = BaseHit -> = { - $$type: 'ais.lookingSimilar'; - renderState: LookingSimilarRenderState; -}; - -export type LookingSimilarConnector< - THit extends NonNullable = BaseHit -> = Connector< - LookingSimilarWidgetDescription, - LookingSimilarConnectorParams ->; - -export default (function connectLookingSimilar< - TWidgetParams extends UnknownWidgetParams ->( - renderFn: Renderer< - LookingSimilarRenderState, - TWidgetParams & LookingSimilarConnectorParams - >, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return = BaseHit>( - widgetParams: TWidgetParams & LookingSimilarConnectorParams - ) => { - const { - // @MAJOR: this can default to false - escapeHTML = true, - objectIDs, - limit, - threshold, - fallbackParameters, - queryParameters, - transformItems = ((items) => items) as NonNullable< - LookingSimilarConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if (!objectIDs || objectIDs.length === 0) { - throw new Error(withUsage('The `objectIDs` option is required.')); - } - - let sendEvent: SendEventForHits; - - return { - dependsOn: 'recommend', - $$type: 'ais.lookingSimilar', - - 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, helper, instantSearchInstance }) { - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - helper, - widgetType: this.$$type, - }); - } - if (results === null || results === undefined) { - return { items: [], widgetParams, sendEvent }; - } - - if (escapeHTML && results.hits.length > 0) { - results.hits = escapeHits(results.hits); - } - - const itemsWithAbsolutePosition = addAbsolutePosition( - results.hits, - 0, - 1 - ); - - const itemsWithAbsolutePositionAndQueryID = addQueryID( - itemsWithAbsolutePosition, - results.queryID - ); - - const transformedItems = transformItems( - itemsWithAbsolutePositionAndQueryID, - { - results: results as RecommendResponse>, - } - ); - - return { - items: transformedItems, - widgetParams, - sendEvent, - }; - }, - - dispose({ recommendState }) { - unmountFn(); - return recommendState.removeParams(this.$$id!); - }, - - getWidgetParameters(state) { - return objectIDs.reduce( - (acc, objectID) => - acc.addLookingSimilar({ - objectID, - maxRecommendations: limit, - threshold, - fallbackParameters: fallbackParameters - ? { - ...fallbackParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - } - : undefined, - queryParameters: { - ...queryParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - }, - $$id: this.$$id!, - }), - state.removeParams(this.$$id!) - ); - }, - }; - }; -} satisfies LookingSimilarConnector); +export { connectLookingSimilar as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/menu/connectMenu.ts b/packages/instantsearch.js/src/connectors/menu/connectMenu.ts index 93aee24dbb2..9bc9bdd7f9f 100644 --- a/packages/instantsearch.js/src/connectors/menu/connectMenu.ts +++ b/packages/instantsearch.js/src/connectors/menu/connectMenu.ts @@ -1,422 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - createSendEventForFacet, - noop, - warning, -} from '../../lib/utils'; - -import type { SendEventForFacet } from '../../lib/utils'; -import type { - Connector, - CreateURL, - IndexUiState, - RenderOptions, - SortBy, - TransformItems, - Widget, - WidgetRenderState, -} from '../../types'; -import type { SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'menu', - connector: true, -}); - -const DEFAULT_SORT = ['isRefined', 'name:asc']; - -export type MenuItem = { - /** - * The value of the menu item. - */ - value: string; - /** - * Human-readable value of the menu item. - */ - label: string; - /** - * Number of results matched after refinement is applied. - */ - count: number; - /** - * Indicates if the refinement is applied. - */ - isRefined: boolean; -}; - -export type MenuConnectorParams = { - /** - * Name of the attribute for faceting (eg. "free_shipping"). - */ - attribute: string; - /** - * How many facets values to retrieve. - */ - limit?: number; - /** - * Whether to display a button that expands the number of items. - */ - showMore?: boolean; - /** - * How many facets values to retrieve when `toggleShowMore` is called, this value is meant to be greater than `limit` option. - */ - showMoreLimit?: number; - /** - * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. - * - * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). - * - * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed - */ - sortBy?: SortBy; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; -}; - -export type MenuRenderState = { - /** - * The elements that can be refined for the current search results. - */ - items: MenuItem[]; - /** - * Creates the URL for a single item name in the list. - */ - createURL: CreateURL; - /** - * Filter the search to item value. - */ - refine: (value: string) => void; - /** - * True if refinement can be applied. - */ - canRefine: boolean; - /** - * True if the menu is displaying all the menu items. - */ - isShowingMore: boolean; - /** - * Toggles the number of values displayed between `limit` and `showMore.limit`. - */ - toggleShowMore: () => void; - /** - * `true` if the toggleShowMore button can be activated (enough items to display more or - * already displaying more than `limit` items) - */ - canToggleShowMore: boolean; - /** - * Send event to insights middleware - */ - sendEvent: SendEventForFacet; -}; - -export type MenuWidgetDescription = { - $$type: 'ais.menu'; - renderState: MenuRenderState; - indexRenderState: { - menu: { - [attribute: string]: WidgetRenderState< - MenuRenderState, - MenuConnectorParams - >; - }; - }; - indexUiState: { - menu: { - [attribute: string]: string; - }; - }; -}; - -export type MenuConnector = Connector< - MenuWidgetDescription, - MenuConnectorParams ->; - -/** - * **Menu** connector provides the logic to build a widget that will give the user the ability to choose a single value for a specific facet. The typical usage of menu is for navigation in categories. - * - * This connector provides a `toggleShowMore()` function to display more or less items and a `refine()` - * function to select an item. While selecting a new element, the `refine` will also unselect the - * one that is currently selected. - * - * **Requirement:** the attribute passed as `attribute` must be present in "attributes for faceting" on the Algolia dashboard or configured as attributesForFaceting via a set settings call to the Algolia API. - */ -const connectMenu: MenuConnector = function connectMenu( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - attribute, - limit = 10, - showMore = false, - showMoreLimit = 20, - sortBy = DEFAULT_SORT, - transformItems = ((items) => items) as NonNullable< - MenuConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if (!attribute) { - throw new Error(withUsage('The `attribute` option is required.')); - } - - if (showMore === true && showMoreLimit <= limit) { - throw new Error( - withUsage('The `showMoreLimit` option must be greater than `limit`.') - ); - } - - type ThisWidget = Widget< - MenuWidgetDescription & { widgetParams: typeof widgetParams } - >; - - let sendEvent: MenuRenderState['sendEvent'] | undefined; - let _createURL: MenuRenderState['createURL'] | undefined; - let _refine: MenuRenderState['refine'] | undefined; - - // Provide the same function to the `renderFn` so that way the user - // has to only bind it once when `isFirstRendering` for instance - let isShowingMore = false; - let toggleShowMore = () => {}; - function createToggleShowMore( - renderOptions: RenderOptions, - widget: ThisWidget - ) { - return () => { - isShowingMore = !isShowingMore; - widget.render!(renderOptions); - }; - } - function cachedToggleShowMore() { - toggleShowMore(); - } - - function getLimit() { - return isShowingMore ? showMoreLimit : limit; - } - - return { - $$type: 'ais.menu' as const, - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return state - .removeHierarchicalFacet(attribute) - .setQueryParameter('maxValuesPerFacet', undefined); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - menu: { - ...renderState.menu, - [attribute]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState(renderOptions) { - const { results, createURL, instantSearchInstance, helper } = - renderOptions; - - let items: MenuRenderState['items'] = []; - let canToggleShowMore = false; - - if (!sendEvent) { - sendEvent = createSendEventForFacet({ - instantSearchInstance, - helper, - attribute, - widgetType: this.$$type, - }); - } - - if (!_createURL) { - _createURL = (facetValue: string) => - createURL((uiState) => - this.getWidgetUiState(uiState, { - searchParameters: helper.state - .resetPage() - .toggleFacetRefinement(attribute, facetValue), - helper, - }) - ); - } - - if (!_refine) { - _refine = function (facetValue: string) { - const [refinedItem] = - helper.getHierarchicalFacetBreadcrumb(attribute); - sendEvent!('click:internal', facetValue ? facetValue : refinedItem); - helper - .toggleFacetRefinement( - attribute, - facetValue ? facetValue : refinedItem - ) - .search(); - }; - } - - if (renderOptions.results) { - toggleShowMore = createToggleShowMore(renderOptions, this); - } - - if (results) { - const facetValues = results.getFacetValues(attribute, { - sortBy, - facetOrdering: sortBy === DEFAULT_SORT, - }); - const facetItems = - facetValues && !Array.isArray(facetValues) && facetValues.data - ? facetValues.data - : []; - - canToggleShowMore = - showMore && (isShowingMore || facetItems.length > getLimit()); - - items = transformItems( - facetItems - .slice(0, getLimit()) - .map(({ name: label, escapedValue: value, path, ...item }) => ({ - ...item, - label, - value, - })), - { results } - ); - } - - return { - items, - createURL: _createURL, - refine: _refine, - sendEvent, - canRefine: items.length > 0, - widgetParams, - isShowingMore, - toggleShowMore: cachedToggleShowMore, - canToggleShowMore, - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - const [value] = - searchParameters.getHierarchicalFacetBreadcrumb(attribute); - - return removeEmptyRefinementsFromUiState( - { - ...uiState, - menu: { - ...uiState.menu, - [attribute]: value, - }, - }, - attribute - ); - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const value = uiState.menu && uiState.menu[attribute]; - - if ( - searchParameters.isConjunctiveFacet(attribute) || - searchParameters.isDisjunctiveFacet(attribute) - ) { - warning( - false, - `Menu: Attribute "${attribute}" is already used by another widget applying conjunctive or disjunctive faceting. -As this is not supported, please make sure to remove this other widget or this Menu widget will not work at all.` - ); - - return searchParameters; - } - - const withFacetConfiguration = searchParameters - .removeHierarchicalFacet(attribute) - .addHierarchicalFacet({ - name: attribute, - attributes: [attribute], - }); - - const currentMaxValuesPerFacet = - withFacetConfiguration.maxValuesPerFacet || 0; - - const nextMaxValuesPerFacet = Math.max( - currentMaxValuesPerFacet, - showMore ? showMoreLimit : limit - ); - - const withMaxValuesPerFacet = withFacetConfiguration.setQueryParameter( - 'maxValuesPerFacet', - nextMaxValuesPerFacet - ); - - if (!value) { - return withMaxValuesPerFacet.setQueryParameters({ - hierarchicalFacetsRefinements: { - ...withMaxValuesPerFacet.hierarchicalFacetsRefinements, - [attribute]: [], - }, - }); - } - - return withMaxValuesPerFacet.addHierarchicalFacetRefinement( - attribute, - value - ); - }, - }; - }; -}; - -function removeEmptyRefinementsFromUiState( - indexUiState: IndexUiState, - attribute: string -): IndexUiState { - if (!indexUiState.menu) { - return indexUiState; - } - - if (indexUiState.menu[attribute] === undefined) { - delete indexUiState.menu[attribute]; - } - - if (Object.keys(indexUiState.menu).length === 0) { - delete indexUiState.menu; - } - - return indexUiState; -} - -export default connectMenu; +export { connectMenu as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/numeric-menu/connectNumericMenu.ts b/packages/instantsearch.js/src/connectors/numeric-menu/connectNumericMenu.ts index f190de8bbcd..fb1435387b8 100644 --- a/packages/instantsearch.js/src/connectors/numeric-menu/connectNumericMenu.ts +++ b/packages/instantsearch.js/src/connectors/numeric-menu/connectNumericMenu.ts @@ -1,507 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - isFiniteNumber, - noop, -} from '../../lib/utils'; - -import type { SendEventForFacet } from '../../lib/utils'; -import type { - Connector, - CreateURL, - IndexUiState, - InstantSearch, - TransformItems, - WidgetRenderState, -} from '../../types'; -import type { SearchParameters } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'numeric-menu', - connector: true, -}); - -export type NumericMenuConnectorParamsItem = { - /** - * Name of the option - */ - label: string; - - /** - * Higher bound of the option (<=) - */ - start?: number; - - /** - * Lower bound of the option (>=) - */ - end?: number; -}; - -export type NumericMenuRenderStateItem = { - /** - * Name of the option. - */ - label: string; - - /** - * URL encoded of the bounds object with the form `{start, end}`. - * This value can be used verbatim in the webpage and can be read by `refine` - * directly. If you want to inspect the value, you can do: - * `JSON.parse(decodeURI(value))` to get the object. - */ - value: string; - - /** - * True if the value is selected - */ - isRefined: boolean; -}; - -export type NumericMenuConnectorParams = { - /** - * Name of the attribute for filtering - */ - attribute: string; - - /** - * List of all the items - */ - items: NumericMenuConnectorParamsItem[]; - - /** - * Function to transform the items passed to the templates - */ - transformItems?: TransformItems; -}; - -export type NumericMenuRenderState = { - /** - * The list of available choices - */ - items: NumericMenuRenderStateItem[]; - - /** - * Creates URLs for the next state, the string is the name of the selected option - */ - createURL: CreateURL; - - /** - * `true` if the last search contains no result - * @deprecated Use `canRefine` instead. - */ - hasNoResults: boolean; - - /** - * Indicates if search state can be refined. - * - * This is `true` if the last search contains no result and - * "All" range is selected - */ - canRefine: boolean; - - /** - * Sets the selected value and trigger a new search - */ - refine: (facetValue: string) => void; - - /** - * Send event to insights middleware - */ - sendEvent: SendEventForFacet; -}; - -export type NumericMenuWidgetDescription = { - $$type: 'ais.numericMenu'; - renderState: NumericMenuRenderState; - indexRenderState: { - numericMenu: { - [attribute: string]: WidgetRenderState< - NumericMenuRenderState, - NumericMenuConnectorParams - >; - }; - }; - indexUiState: { - numericMenu: { - // @TODO: this could possibly become `${number}:${number}` later - [attribute: string]: string; - }; - }; -}; - -export type NumericMenuConnector = Connector< - NumericMenuWidgetDescription, - NumericMenuConnectorParams ->; - -const $$type = 'ais.numericMenu'; - -const createSendEvent = - ({ instantSearchInstance }: { instantSearchInstance: InstantSearch }) => - (...args: Parameters) => { - if (args.length === 1) { - instantSearchInstance.sendEventToInsights(args[0]); - return; - } - }; - -const connectNumericMenu: NumericMenuConnector = function connectNumericMenu( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - attribute = '', - items = [], - transformItems = ((item) => item) as NonNullable< - NumericMenuConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if (attribute === '') { - throw new Error(withUsage('The `attribute` option is required.')); - } - - if (!items || items.length === 0) { - throw new Error( - withUsage('The `items` option expects an array of objects.') - ); - } - - type ConnectorState = { - refine?: (facetValue: string) => void; - createURL?: (state: SearchParameters) => (facetValue: string) => string; - sendEvent?: SendEventForFacet; - }; - - const prepareItems = (state: SearchParameters) => - items.map(({ start, end, label }) => ({ - label, - value: encodeURI(JSON.stringify({ start, end })), - isRefined: isRefined(state, attribute, { start, end, label }), - })); - - const connectorState: ConnectorState = {}; - - return { - $$type, - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - return state.removeNumericRefinement(attribute); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const values = searchParameters.getNumericRefinements(attribute); - - const equal = values['='] && values['='][0]; - - if (equal || equal === 0) { - return { - ...uiState, - numericMenu: { - ...uiState.numericMenu, - [attribute]: `${values['=']}`, - }, - }; - } - - const min = (values['>='] && values['>='][0]) || ''; - const max = (values['<='] && values['<='][0]) || ''; - - return removeEmptyRefinementsFromUiState( - { - ...uiState, - numericMenu: { - ...uiState.numericMenu, - [attribute]: `${min}:${max}`, - }, - }, - attribute - ); - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const value = uiState.numericMenu && uiState.numericMenu[attribute]; - - const withoutRefinements = searchParameters.setQueryParameters({ - numericRefinements: { - ...searchParameters.numericRefinements, - [attribute]: {}, - }, - }); - - if (!value) { - return withoutRefinements; - } - - const isExact = value.indexOf(':') === -1; - - if (isExact) { - return withoutRefinements.addNumericRefinement( - attribute, - '=', - Number(value) - ); - } - - const [min, max] = value.split(':').map(parseFloat); - - const withMinRefinement = isFiniteNumber(min) - ? withoutRefinements.addNumericRefinement(attribute, '>=', min) - : withoutRefinements; - - const withMaxRefinement = isFiniteNumber(max) - ? withMinRefinement.addNumericRefinement(attribute, '<=', max) - : withMinRefinement; - - return withMaxRefinement; - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - numericMenu: { - ...renderState.numericMenu, - [attribute]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState({ - results, - state, - instantSearchInstance, - helper, - createURL, - }) { - if (!connectorState.refine) { - connectorState.refine = (facetValue) => { - const refinedState = getRefinedState( - helper.state, - attribute, - facetValue - ); - connectorState.sendEvent!('click:internal', facetValue); - helper.setState(refinedState).search(); - }; - } - - if (!connectorState.createURL) { - connectorState.createURL = (newState) => (facetValue) => - createURL((uiState) => - this.getWidgetUiState(uiState, { - searchParameters: getRefinedState( - newState, - attribute, - facetValue - ), - helper, - }) - ); - } - - if (!connectorState.sendEvent) { - connectorState.sendEvent = createSendEvent({ - instantSearchInstance, - }); - } - - const hasNoResults = results ? results.nbHits === 0 : true; - const preparedItems = prepareItems(state); - let allIsSelected = true; - // @TODO avoid for..of for polyfill reasons - // eslint-disable-next-line instantsearch/no-for-of - for (const item of preparedItems) { - if (item.isRefined && decodeURI(item.value) !== '{}') { - allIsSelected = false; - break; - } - } - - return { - createURL: connectorState.createURL(state), - items: transformItems(preparedItems, { results }), - hasNoResults, - canRefine: !(hasNoResults && allIsSelected), - refine: connectorState.refine, - sendEvent: connectorState.sendEvent, - widgetParams, - }; - }, - }; - }; -}; - -function isRefined( - state: SearchParameters, - attribute: string, - option: NumericMenuConnectorParamsItem -) { - // @TODO: same as another spot, why is this mixing arrays & elements? - const currentRefinements = state.getNumericRefinements(attribute); - - if (option.start !== undefined && option.end !== undefined) { - if (option.start === option.end) { - return hasNumericRefinement(currentRefinements, '=', option.start); - } else { - return ( - hasNumericRefinement(currentRefinements, '>=', option.start) && - hasNumericRefinement(currentRefinements, '<=', option.end) - ); - } - } - - if (option.start !== undefined) { - return hasNumericRefinement(currentRefinements, '>=', option.start); - } - - if (option.end !== undefined) { - return hasNumericRefinement(currentRefinements, '<=', option.end); - } - - if (option.start === undefined && option.end === undefined) { - return ( - Object.keys(currentRefinements) as SearchParameters.Operator[] - ).every((operator) => (currentRefinements[operator] || []).length === 0); - } - - return false; -} - -function getRefinedState( - state: SearchParameters, - attribute: string, - facetValue: string -) { - let resolvedState = state; - - const refinedOption = JSON.parse(decodeURI(facetValue)); - - // @TODO: why is array / element mixed here & hasRefinements; seems wrong? - const currentRefinements = resolvedState.getNumericRefinements(attribute); - - if (refinedOption.start === undefined && refinedOption.end === undefined) { - return resolvedState.removeNumericRefinement(attribute); - } - - if (!isRefined(resolvedState, attribute, refinedOption)) { - resolvedState = resolvedState.removeNumericRefinement(attribute); - } - - if (refinedOption.start !== undefined && refinedOption.end !== undefined) { - if (refinedOption.start > refinedOption.end) { - throw new Error('option.start should be > to option.end'); - } - - if (refinedOption.start === refinedOption.end) { - if (hasNumericRefinement(currentRefinements, '=', refinedOption.start)) { - resolvedState = resolvedState.removeNumericRefinement( - attribute, - '=', - refinedOption.start - ); - } else { - resolvedState = resolvedState.addNumericRefinement( - attribute, - '=', - refinedOption.start - ); - } - return resolvedState; - } - } - - if (refinedOption.start !== undefined) { - if (hasNumericRefinement(currentRefinements, '>=', refinedOption.start)) { - resolvedState = resolvedState.removeNumericRefinement( - attribute, - '>=', - refinedOption.start - ); - } - resolvedState = resolvedState.addNumericRefinement( - attribute, - '>=', - refinedOption.start - ); - } - - if (refinedOption.end !== undefined) { - if (hasNumericRefinement(currentRefinements, '<=', refinedOption.end)) { - resolvedState = resolvedState.removeNumericRefinement( - attribute, - '<=', - refinedOption.end - ); - } - resolvedState = resolvedState.addNumericRefinement( - attribute, - '<=', - refinedOption.end - ); - } - - if (typeof resolvedState.page === 'number') { - resolvedState.page = 0; - } - - return resolvedState; -} - -function hasNumericRefinement( - currentRefinements: SearchParameters.OperatorList, - operator: SearchParameters.Operator, - value: number -) { - const refinements = currentRefinements[operator]; - - return refinements !== undefined && refinements.includes(value); -} - -function removeEmptyRefinementsFromUiState( - indexUiState: IndexUiState, - attribute: string -): IndexUiState { - if (!indexUiState.numericMenu) { - return indexUiState; - } - - if (indexUiState.numericMenu[attribute] === ':') { - delete indexUiState.numericMenu[attribute]; - } - - if (Object.keys(indexUiState.numericMenu).length === 0) { - delete indexUiState.numericMenu; - } - - return indexUiState; -} - -export default connectNumericMenu; +export { connectNumericMenu as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/pagination/connectPagination.ts b/packages/instantsearch.js/src/connectors/pagination/connectPagination.ts index 93294a34dbf..6165680391e 100644 --- a/packages/instantsearch.js/src/connectors/pagination/connectPagination.ts +++ b/packages/instantsearch.js/src/connectors/pagination/connectPagination.ts @@ -1,207 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - noop, -} from '../../lib/utils'; - -import Paginator from './Paginator'; - -import type { Connector, CreateURL, WidgetRenderState } from '../../types'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'pagination', - connector: true, -}); - -export type PaginationConnectorParams = { - /** - * The total number of pages to browse. - */ - totalPages?: number; - - /** - * The padding of pages to show around the current page - * @default 3 - */ - padding?: number; -}; - -export type PaginationRenderState = { - /** Creates URLs for the next state, the number is the page to generate the URL for. */ - createURL: CreateURL; - - /** Sets the current page and triggers a search. */ - refine: (page: number) => void; - - /** true if this search returned more than one page */ - canRefine: boolean; - - /** The number of the page currently displayed. */ - currentRefinement: number; - - /** The number of hits computed for the last query (can be approximated). */ - nbHits: number; - - /** The number of pages for the result set. */ - nbPages: number; - - /** The actual pages relevant to the current situation and padding. */ - pages: number[]; - - /** true if the current page is also the first page. */ - isFirstPage: boolean; - - /** true if the current page is also the last page. */ - isLastPage: boolean; -}; - -export type PaginationWidgetDescription = { - $$type: 'ais.pagination'; - renderState: PaginationRenderState; - indexRenderState: { - pagination: WidgetRenderState< - PaginationRenderState, - PaginationConnectorParams - >; - }; - indexUiState: { - page: number; - }; -}; - -export type PaginationConnector = Connector< - PaginationWidgetDescription, - PaginationConnectorParams ->; - -/** - * **Pagination** connector provides the logic to build a widget that will let the user - * choose the current page of the results. - * - * When using the pagination with Algolia, you should be aware that the engine won't provide you pages - * beyond the 1000th hits by default. You can find more information on the [Algolia documentation](https://www.algolia.com/doc/guides/searching/pagination/#pagination-limitations). - */ -const connectPagination: PaginationConnector = function connectPagination( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { totalPages, padding = 3 } = widgetParams || {}; - - const pager = new Paginator({ - currentPage: 0, - total: 0, - padding, - }); - - type ConnectorState = { - refine?: (page: number) => void; - createURL?: (page: number) => string; - }; - - const connectorState: ConnectorState = {}; - - function getMaxPage({ nbPages }: { nbPages: number }) { - return totalPages !== undefined ? Math.min(totalPages, nbPages) : nbPages; - } - - return { - $$type: 'ais.pagination', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return state.setQueryParameter('page', undefined); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const page = searchParameters.page || 0; - - if (!page) { - return uiState; - } - - return { - ...uiState, - page: page + 1, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const page = uiState.page ? uiState.page - 1 : 0; - - return searchParameters.setQueryParameter('page', page); - }, - - getWidgetRenderState({ results, helper, state, createURL }) { - if (!connectorState.refine) { - connectorState.refine = (page) => { - helper.setPage(page); - helper.search(); - }; - } - - if (!connectorState.createURL) { - connectorState.createURL = (page) => - createURL((uiState) => ({ - ...uiState, - page: page + 1, - })); - } - - const page = state.page || 0; - const nbPages = getMaxPage(results || { nbPages: 0 }); - pager.currentPage = page; - pager.total = nbPages; - - return { - createURL: connectorState.createURL, - refine: connectorState.refine, - canRefine: nbPages > 1, - currentRefinement: page, - nbHits: results?.nbHits || 0, - nbPages, - pages: results ? pager.pages() : [], - isFirstPage: pager.isFirstPage(), - isLastPage: pager.isLastPage(), - widgetParams, - }; - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - pagination: this.getWidgetRenderState(renderOptions), - }; - }, - }; - }; -}; - -export default connectPagination; +export { connectPagination as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/powered-by/connectPoweredBy.ts b/packages/instantsearch.js/src/connectors/powered-by/connectPoweredBy.ts index 88ba26ba8c2..d9623a7057a 100644 --- a/packages/instantsearch.js/src/connectors/powered-by/connectPoweredBy.ts +++ b/packages/instantsearch.js/src/connectors/powered-by/connectPoweredBy.ts @@ -1,111 +1,2 @@ -import { - safelyRunOnBrowser, - checkRendering, - createDocumentationMessageGenerator, - noop, -} from '../../lib/utils'; - -import type { Connector, WidgetRenderState } from '../../types'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'powered-by', - connector: true, -}); - -export type PoweredByRenderState = { - /** the url to redirect to on click */ - url: string; -}; - -export type PoweredByConnectorParams = { - /** the url to redirect to on click */ - url?: string; -}; - -export type PoweredByWidgetDescription = { - $$type: 'ais.poweredBy'; - renderState: PoweredByRenderState; - indexRenderState: { - poweredBy: WidgetRenderState< - PoweredByRenderState, - PoweredByConnectorParams - >; - }; -}; - -export type PoweredByConnector = Connector< - PoweredByWidgetDescription, - PoweredByConnectorParams ->; - -/** - * **PoweredBy** connector provides the logic to build a custom widget that will displays - * the logo to redirect to Algolia. - */ -const connectPoweredBy: PoweredByConnector = function connectPoweredBy( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - const defaultUrl = - 'https://www.algolia.com/?' + - 'utm_source=instantsearch.js&' + - 'utm_medium=website&' + - `utm_content=${safelyRunOnBrowser( - ({ window }) => window.location?.hostname || '', - { fallback: () => '' } - )}&` + - 'utm_campaign=poweredby'; - - return (widgetParams) => { - const { url = defaultUrl } = widgetParams || {}; - - return { - $$type: 'ais.poweredBy', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - poweredBy: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState() { - return { - url, - widgetParams, - }; - }, - - dispose() { - unmountFn(); - }, - }; - }; -}; - -export default connectPoweredBy; +export { connectPoweredBy as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/query-rules/connectQueryRules.ts b/packages/instantsearch.js/src/connectors/query-rules/connectQueryRules.ts index 8a84f22dc1b..a07ccbe831a 100644 --- a/packages/instantsearch.js/src/connectors/query-rules/connectQueryRules.ts +++ b/packages/instantsearch.js/src/connectors/query-rules/connectQueryRules.ts @@ -1,277 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - warning, - getRefinements, - isEqual, - noop, -} from '../../lib/utils'; - -import type { - Refinement as InternalRefinement, - NumericRefinement as InternalNumericRefinement, -} from '../../lib/utils'; -import type { Connector, TransformItems, WidgetRenderState } from '../../types'; -import type { - AlgoliaSearchHelper as Helper, - SearchParameters, -} from 'algoliasearch-helper'; - -type TrackedFilterRefinement = string | number | boolean; - -export type ParamTrackedFilters = { - [facetName: string]: ( - facetValues: TrackedFilterRefinement[] - ) => TrackedFilterRefinement[]; -}; -export type ParamTransformRuleContexts = (ruleContexts: string[]) => string[]; - -export type QueryRulesConnectorParams = { - trackedFilters?: ParamTrackedFilters; - transformRuleContexts?: ParamTransformRuleContexts; - transformItems?: TransformItems; -}; - -export type QueryRulesRenderState = { - items: any[]; -}; - -const withUsage = createDocumentationMessageGenerator({ - name: 'query-rules', - connector: true, -}); - -function hasStateRefinements(state: SearchParameters): boolean { - return [ - state.disjunctiveFacetsRefinements, - state.facetsRefinements, - state.hierarchicalFacetsRefinements, - state.numericRefinements, - ].some((refinement) => - Boolean(refinement && Object.keys(refinement).length > 0) - ); -} - -// A context rule must consist only of alphanumeric characters, hyphens, and underscores. -// See https://www.algolia.com/doc/guides/managing-results/refine-results/merchandising-and-promoting/in-depth/implementing-query-rules/#context -function escapeRuleContext(ruleName: string): string { - return ruleName.replace(/[^a-z0-9-_]+/gi, '_'); -} - -function getRuleContextsFromTrackedFilters({ - helper, - sharedHelperState, - trackedFilters, -}: { - helper: Helper; - sharedHelperState: SearchParameters; - trackedFilters: ParamTrackedFilters; -}): string[] { - const ruleContexts = Object.keys(trackedFilters).reduce( - (facets, facetName) => { - const facetRefinements: TrackedFilterRefinement[] = getRefinements( - helper.lastResults || {}, - sharedHelperState, - true - ) - .filter( - (refinement: InternalRefinement) => refinement.attribute === facetName - ) - .map( - (refinement: InternalRefinement) => - (refinement as InternalNumericRefinement).numericValue || - refinement.name - ); - - const getTrackedFacetValues = trackedFilters[facetName]; - const trackedFacetValues = getTrackedFacetValues(facetRefinements); - - return [ - ...facets, - ...facetRefinements - .filter((facetRefinement) => - trackedFacetValues.includes(facetRefinement) - ) - .map((facetValue) => - escapeRuleContext(`ais-${facetName}-${facetValue}`) - ), - ]; - }, - [] - ); - - return ruleContexts; -} - -function applyRuleContexts( - this: { - helper: Helper; - initialRuleContexts: string[]; - trackedFilters: ParamTrackedFilters; - transformRuleContexts: ParamTransformRuleContexts; - }, - event: { state: SearchParameters } -): void { - const { helper, initialRuleContexts, trackedFilters, transformRuleContexts } = - this; - - const sharedHelperState = event.state; - const previousRuleContexts: string[] = sharedHelperState.ruleContexts || []; - const newRuleContexts = getRuleContextsFromTrackedFilters({ - helper, - sharedHelperState, - trackedFilters, - }); - const nextRuleContexts = [...initialRuleContexts, ...newRuleContexts]; - - warning( - nextRuleContexts.length <= 10, - ` -The maximum number of \`ruleContexts\` is 10. They have been sliced to that limit. -Consider using \`transformRuleContexts\` to minimize the number of rules sent to Algolia. -` - ); - - const ruleContexts = transformRuleContexts(nextRuleContexts).slice(0, 10); - - if (!isEqual(previousRuleContexts, ruleContexts)) { - helper.overrideStateWithoutTriggeringChangeEvent({ - ...sharedHelperState, - ruleContexts, - }); - } -} - -export type QueryRulesWidgetDescription = { - $$type: 'ais.queryRules'; - renderState: QueryRulesRenderState; - indexRenderState: { - queryRules: WidgetRenderState< - QueryRulesRenderState, - QueryRulesConnectorParams - >; - }; -}; - -export type QueryRulesConnector = Connector< - QueryRulesWidgetDescription, - QueryRulesConnectorParams ->; - -const connectQueryRules: QueryRulesConnector = function connectQueryRules( - render, - unmount = noop -) { - checkRendering(render, withUsage()); - - return (widgetParams) => { - const { - trackedFilters = {} as ParamTrackedFilters, - transformRuleContexts = ((rules) => rules) as ParamTransformRuleContexts, - transformItems = ((items) => items) as NonNullable< - QueryRulesConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - Object.keys(trackedFilters).forEach((facetName) => { - if (typeof trackedFilters[facetName] !== 'function') { - throw new Error( - withUsage( - `'The "${facetName}" filter value in the \`trackedFilters\` option expects a function.` - ) - ); - } - }); - - const hasTrackedFilters = Object.keys(trackedFilters).length > 0; - - // We store the initial rule contexts applied before creating the widget - // so that we do not override them with the rules created from `trackedFilters`. - let initialRuleContexts: string[] = []; - let onHelperChange: (event: { state: SearchParameters }) => void; - - return { - $$type: 'ais.queryRules', - - init(initOptions) { - const { helper, state, instantSearchInstance } = initOptions; - - initialRuleContexts = state.ruleContexts || []; - onHelperChange = applyRuleContexts.bind({ - helper, - initialRuleContexts, - trackedFilters, - transformRuleContexts, - }); - - if (hasTrackedFilters) { - // We need to apply the `ruleContexts` based on the `trackedFilters` - // before the helper changes state in some cases: - // - Some filters are applied on the first load (e.g. using `configure`) - // - The `transformRuleContexts` option sets initial `ruleContexts`. - if ( - hasStateRefinements(state) || - Boolean(widgetParams.transformRuleContexts) - ) { - onHelperChange({ state }); - } - - // We track every change in the helper to override its state and add - // any `ruleContexts` needed based on the `trackedFilters`. - helper.on('change', onHelperChange); - } - - render( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - render( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - getWidgetRenderState({ results }) { - const { userData = [] } = results || {}; - const items = transformItems(userData, { results }); - - return { - items, - widgetParams, - }; - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - queryRules: this.getWidgetRenderState(renderOptions), - }; - }, - - dispose({ helper, state }) { - unmount(); - - if (hasTrackedFilters) { - helper.removeListener('change', onHelperChange); - - return state.setQueryParameter('ruleContexts', initialRuleContexts); - } - - return state; - }, - }; - }; -}; - -export default connectQueryRules; +export { connectQueryRules as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/range/connectRange.ts b/packages/instantsearch.js/src/connectors/range/connectRange.ts index b951b05e33c..2696d05de9e 100644 --- a/packages/instantsearch.js/src/connectors/range/connectRange.ts +++ b/packages/instantsearch.js/src/connectors/range/connectRange.ts @@ -1,484 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - isFiniteNumber, - find, - noop, -} from '../../lib/utils'; - -import type { SendEventForFacet } from '../../lib/utils'; -import type { Connector, InstantSearch, WidgetRenderState } from '../../types'; -import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator( - { name: 'range-input', connector: true }, - { name: 'range-slider', connector: true } -); - -const $$type = 'ais.range'; - -export type RangeMin = number | undefined; -export type RangeMax = number | undefined; - -// @MAJOR: potentially we should consolidate these types -export type RangeBoundaries = [RangeMin, RangeMax]; -export type Range = { - min: RangeMin; - max: RangeMax; -}; - -export type RangeRenderState = { - /** - * Sets a range to filter the results on. Both values - * are optional, and will default to the higher and lower bounds. You can use `undefined` to remove a - * previously set bound or to set an infinite bound. - * @param rangeValue tuple of [min, max] bounds - */ - refine: (rangeValue: RangeBoundaries) => void; - - /** - * Indicates whether this widget can be refined - */ - canRefine: boolean; - - /** - * Send an event to the insights middleware - */ - sendEvent: SendEventForFacet; - - /** - * Maximum range possible for this search - */ - range: Range; - - /** - * Current refinement of the search - */ - start: RangeBoundaries; - - /** - * Transform for the rendering `from` and/or `to` values. - * Both functions take a `number` as input and should output a `string`. - */ - format: { - from: (fromValue: number) => string; - to: (toValue: number) => string; - }; -}; - -export type RangeConnectorParams = { - /** - * Name of the attribute for faceting. - */ - attribute: string; - - /** - * Minimal range value, default to automatically computed from the result set. - */ - min?: number; - - /** - * Maximal range value, default to automatically computed from the result set. - */ - max?: number; - - /** - * Number of digits after decimal point to use. - */ - precision?: number; -}; - -export type RangeWidgetDescription = { - $$type: 'ais.range'; - renderState: RangeRenderState; - indexRenderState: { - range: { - [attribute: string]: WidgetRenderState< - RangeRenderState, - RangeConnectorParams - >; - }; - }; - indexUiState: { - range: { - // @TODO: this could possibly become `${number}:${number}` later - [attribute: string]: string; - }; - }; -}; - -export type RangeConnector = Connector< - RangeWidgetDescription, - RangeConnectorParams ->; - -function toPrecision({ - min, - max, - precision, -}: { - min?: number; - max?: number; - precision: number; -}) { - const pow = Math.pow(10, precision); - - return { - min: min ? Math.floor(min * pow) / pow : min, - max: max ? Math.ceil(max * pow) / pow : max, - }; -} - -/** - * **Range** connector provides the logic to create custom widget that will let - * the user refine results using a numeric range. - * - * This connectors provides a `refine()` function that accepts bounds. It will also provide - * information about the min and max bounds for the current result set. - */ -const connectRange: RangeConnector = function connectRange( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - attribute = '', - min: minBound, - max: maxBound, - precision = 0, - } = widgetParams || {}; - - if (!attribute) { - throw new Error(withUsage('The `attribute` option is required.')); - } - - if ( - isFiniteNumber(minBound) && - isFiniteNumber(maxBound) && - minBound > maxBound - ) { - throw new Error(withUsage("The `max` option can't be lower than `min`.")); - } - - const formatToNumber = (v: string | number) => - Number(Number(v).toFixed(precision)); - - const rangeFormatter = { - from: (v: number) => v.toLocaleString(), - to: (v: number) => formatToNumber(v).toLocaleString(), - }; - - // eslint-disable-next-line complexity - const getRefinedState = ( - helper: AlgoliaSearchHelper, - currentRange: Range, - nextMin: RangeMin | string, - nextMax: RangeMax | string - ) => { - let resolvedState = helper.state; - const { min: currentRangeMin, max: currentRangeMax } = currentRange; - - const [min] = resolvedState.getNumericRefinement(attribute, '>=') || []; - const [max] = resolvedState.getNumericRefinement(attribute, '<=') || []; - - const isResetMin = nextMin === undefined || nextMin === ''; - const isResetMax = nextMax === undefined || nextMax === ''; - - const { min: nextMinAsNumber, max: nextMaxAsNumber } = toPrecision({ - min: !isResetMin ? parseFloat(nextMin as string) : undefined, - max: !isResetMax ? parseFloat(nextMax as string) : undefined, - precision, - }); - - let newNextMin: RangeMin; - if (!isFiniteNumber(minBound) && currentRangeMin === nextMinAsNumber) { - newNextMin = undefined; - } else if (isFiniteNumber(minBound) && isResetMin) { - newNextMin = minBound; - } else { - newNextMin = nextMinAsNumber; - } - - let newNextMax: RangeMax; - if (!isFiniteNumber(maxBound) && currentRangeMax === nextMaxAsNumber) { - newNextMax = undefined; - } else if (isFiniteNumber(maxBound) && isResetMax) { - newNextMax = maxBound; - } else { - newNextMax = nextMaxAsNumber; - } - - const isResetNewNextMin = newNextMin === undefined; - - const isGreaterThanCurrentRange = - isFiniteNumber(currentRangeMin) && currentRangeMin <= newNextMin!; - const isMinValid = - isResetNewNextMin || - (isFiniteNumber(newNextMin) && - (!isFiniteNumber(currentRangeMin) || isGreaterThanCurrentRange)); - - const isResetNewNextMax = newNextMax === undefined; - const isLowerThanRange = - isFiniteNumber(newNextMax) && currentRangeMax! >= newNextMax; - const isMaxValid = - isResetNewNextMax || - (isFiniteNumber(newNextMax) && - (!isFiniteNumber(currentRangeMax) || isLowerThanRange)); - - const hasMinChange = min !== newNextMin; - const hasMaxChange = max !== newNextMax; - - if ((hasMinChange || hasMaxChange) && isMinValid && isMaxValid) { - resolvedState = resolvedState.removeNumericRefinement(attribute); - - if (isFiniteNumber(newNextMin)) { - resolvedState = resolvedState.addNumericRefinement( - attribute, - '>=', - newNextMin - ); - } - - if (isFiniteNumber(newNextMax)) { - resolvedState = resolvedState.addNumericRefinement( - attribute, - '<=', - newNextMax - ); - } - - return resolvedState.resetPage(); - } - - return null; - }; - - const createSendEvent = - (instantSearchInstance: InstantSearch) => - (...args: Parameters) => { - if (args.length === 1) { - instantSearchInstance.sendEventToInsights(args[0]); - return; - } - }; - - function _getCurrentRange( - stats: Partial> - ) { - let min: number; - if (isFiniteNumber(minBound)) { - min = minBound; - } else if (isFiniteNumber(stats.min)) { - min = stats.min; - } else { - min = 0; - } - - let max: number; - if (isFiniteNumber(maxBound)) { - max = maxBound; - } else if (isFiniteNumber(stats.max)) { - max = stats.max; - } else { - max = 0; - } - - return toPrecision({ min, max, precision }); - } - - function _getCurrentRefinement( - helper: AlgoliaSearchHelper - ): RangeBoundaries { - const [minValue] = helper.getNumericRefinement(attribute, '>=') || []; - - const [maxValue] = helper.getNumericRefinement(attribute, '<=') || []; - - const min = isFiniteNumber(minValue) ? minValue : -Infinity; - const max = isFiniteNumber(maxValue) ? maxValue : Infinity; - - return [min, max]; - } - - function _refine(helper: AlgoliaSearchHelper, currentRange: Range) { - return ([nextMin, nextMax]: RangeBoundaries = [undefined, undefined]) => { - const refinedState = getRefinedState( - helper, - currentRange, - nextMin, - nextMax - ); - if (refinedState) { - helper.setState(refinedState).search(); - } - }; - } - - return { - $$type, - - init(initOptions) { - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - range: { - ...renderState.range, - [attribute]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState({ results, helper, instantSearchInstance }) { - const facetsFromResults = (results && results.disjunctiveFacets) || []; - const facet = find( - facetsFromResults, - (facetResult) => facetResult.name === attribute - ); - const stats = (facet && facet.stats) || { - min: undefined, - max: undefined, - }; - - const currentRange = _getCurrentRange(stats); - const start = _getCurrentRefinement(helper); - - let refine: ReturnType; - - if (!results) { - // On first render pass an empty range - // to be able to bypass the validation - // related to it - refine = _refine(helper, { - min: undefined, - max: undefined, - }); - } else { - refine = _refine(helper, currentRange); - } - - return { - refine, - canRefine: currentRange.min !== currentRange.max, - format: rangeFormatter, - range: currentRange, - sendEvent: createSendEvent(instantSearchInstance), - widgetParams: { - ...widgetParams, - precision, - }, - start, - }; - }, - - dispose({ state }) { - unmountFn(); - - return state - .removeDisjunctiveFacet(attribute) - .removeNumericRefinement(attribute); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const { '>=': min = [], '<=': max = [] } = - searchParameters.getNumericRefinements(attribute); - - if (min.length === 0 && max.length === 0) { - return uiState; - } - - return { - ...uiState, - range: { - ...uiState.range, - [attribute]: `${min}:${max}`, - }, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - let widgetSearchParameters = searchParameters - .addDisjunctiveFacet(attribute) - .setQueryParameters({ - numericRefinements: { - ...searchParameters.numericRefinements, - [attribute]: {}, - }, - }); - - if (isFiniteNumber(minBound)) { - widgetSearchParameters = widgetSearchParameters.addNumericRefinement( - attribute, - '>=', - minBound - ); - } - - if (isFiniteNumber(maxBound)) { - widgetSearchParameters = widgetSearchParameters.addNumericRefinement( - attribute, - '<=', - maxBound - ); - } - - const value = uiState.range && uiState.range[attribute]; - - if (!value || value.indexOf(':') === -1) { - return widgetSearchParameters; - } - - const [lowerBound, upperBound] = value.split(':').map(parseFloat); - - if ( - isFiniteNumber(lowerBound) && - (!isFiniteNumber(minBound) || minBound < lowerBound) - ) { - widgetSearchParameters = - widgetSearchParameters.removeNumericRefinement(attribute, '>='); - widgetSearchParameters = widgetSearchParameters.addNumericRefinement( - attribute, - '>=', - lowerBound - ); - } - - if ( - isFiniteNumber(upperBound) && - (!isFiniteNumber(maxBound) || upperBound < maxBound) - ) { - widgetSearchParameters = - widgetSearchParameters.removeNumericRefinement(attribute, '<='); - widgetSearchParameters = widgetSearchParameters.addNumericRefinement( - attribute, - '<=', - upperBound - ); - } - - return widgetSearchParameters; - }, - }; - }; -}; - -export default connectRange; +export { connectRange as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/rating-menu/connectRatingMenu.ts b/packages/instantsearch.js/src/connectors/rating-menu/connectRatingMenu.ts index 85c4b1407d4..f231af2cd7d 100644 --- a/packages/instantsearch.js/src/connectors/rating-menu/connectRatingMenu.ts +++ b/packages/instantsearch.js/src/connectors/rating-menu/connectRatingMenu.ts @@ -1,498 +1,2 @@ -import { - checkRendering, - createDocumentationLink, - createDocumentationMessageGenerator, - noop, - warning, -} from '../../lib/utils'; - -import type { InsightsEvent } from '../../middlewares'; -import type { - Connector, - InstantSearch, - CreateURL, - WidgetRenderState, - Widget, - InitOptions, - RenderOptions, - IndexUiState, -} from '../../types'; -import type { - AlgoliaSearchHelper, - SearchParameters, - SearchResults, -} from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'rating-menu', - connector: true, -}); - -const $$type = 'ais.ratingMenu'; - -const MAX_VALUES_PER_FACET_API_LIMIT = 1000; -const STEP = 1; - -type SendEvent = (...args: [InsightsEvent] | [string, string, string?]) => void; - -type CreateSendEvent = (createSendEventArgs: { - instantSearchInstance: InstantSearch; - helper: AlgoliaSearchHelper; - getRefinedStar: () => number | number[] | undefined; - attribute: string; -}) => SendEvent; - -const createSendEvent: CreateSendEvent = - ({ instantSearchInstance, helper, getRefinedStar, attribute }) => - (...args) => { - if (args.length === 1) { - instantSearchInstance.sendEventToInsights(args[0]); - return; - } - const [, facetValue, eventName = 'Filter Applied'] = args; - const [eventType, eventModifier] = args[0].split(':'); - if (eventType !== 'click') { - return; - } - const isRefined = getRefinedStar() === Number(facetValue); - if (!isRefined) { - instantSearchInstance.sendEventToInsights({ - insightsMethod: 'clickedFilters', - widgetType: $$type, - eventType, - eventModifier, - payload: { - eventName, - index: helper.lastResults?.index || helper.state.index, - filters: [`${attribute}>=${facetValue}`], - }, - attribute, - }); - } - }; - -type StarRatingItems = { - /** - * Name corresponding to the number of stars. - */ - name: string; - /** - * Human-readable name corresponding to the number of stars. - */ - label: string; - /** - * Number of stars as string. - */ - value: string; - /** - * Count of matched results corresponding to the number of stars. - */ - count: number; - /** - * Array of length of maximum rating value with stars to display or not. - */ - stars: boolean[]; - /** - * Indicates if star rating refinement is applied. - */ - isRefined: boolean; -}; - -export type RatingMenuConnectorParams = { - /** - * Name of the attribute for faceting (eg. "free_shipping"). - */ - attribute: string; - - /** - * The maximum rating value. - */ - max?: number; -}; - -export type RatingMenuRenderState = { - /** - * Possible star ratings the user can apply. - */ - items: StarRatingItems[]; - - /** - * Creates an URL for the next state (takes the item value as parameter). Takes the value of an item as parameter. - */ - createURL: CreateURL; - - /** - * Indicates if search state can be refined. - */ - canRefine: boolean; - - /** - * Selects a rating to filter the results (takes the filter value as parameter). Takes the value of an item as parameter. - */ - refine: (value: string) => void; - - /** - * `true` if the last search contains no result. - * - * @deprecated Use `canRefine` instead. - */ - hasNoResults: boolean; - - /** - * Send event to insights middleware - */ - sendEvent: SendEvent; -}; - -export type RatingMenuWidgetDescription = { - $$type: 'ais.ratingMenu'; - renderState: RatingMenuRenderState; - indexRenderState: { - ratingMenu: { - [attribute: string]: WidgetRenderState< - RatingMenuRenderState, - RatingMenuConnectorParams - >; - }; - }; - indexUiState: { - ratingMenu: { - [attribute: string]: number | undefined; - }; - }; -}; - -export type RatingMenuConnector = Connector< - RatingMenuWidgetDescription, - RatingMenuConnectorParams ->; - -/** - * **StarRating** connector provides the logic to build a custom widget that will let - * the user refine search results based on ratings. - * - * The connector provides to the rendering: `refine()` to select a value and - * `items` that are the values that can be selected. `refine` should be used - * with `items.value`. - */ -const connectRatingMenu: RatingMenuConnector = function connectRatingMenu( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { attribute, max = 5 } = widgetParams || {}; - let sendEvent: SendEvent; - - if (!attribute) { - throw new Error(withUsage('The `attribute` option is required.')); - } - - const getRefinedStar = (state: SearchParameters) => { - const values = state.getNumericRefinements(attribute); - - if (!values['>=']?.length) { - return undefined; - } - - return values['>='][0]; - }; - - const getFacetsMaxDecimalPlaces = ( - facetResults: SearchResults.FacetValue[] - ) => { - let maxDecimalPlaces = 0; - facetResults.forEach((facetResult) => { - const [, decimal = ''] = facetResult.name.split('.'); - maxDecimalPlaces = Math.max(maxDecimalPlaces, decimal.length); - }); - return maxDecimalPlaces; - }; - - const getFacetValuesWarningMessage = ({ - maxDecimalPlaces, - maxFacets, - maxValuesPerFacet, - }: { - maxDecimalPlaces: number; - maxFacets: number; - maxValuesPerFacet: number; - }) => { - const maxDecimalPlacesInRange = Math.max( - 0, - Math.floor(Math.log10(MAX_VALUES_PER_FACET_API_LIMIT / max)) - ); - const maxFacetsInRange = Math.min( - MAX_VALUES_PER_FACET_API_LIMIT, - Math.pow(10, maxDecimalPlacesInRange) * max - ); - - const solutions: string[] = []; - - if (maxFacets > MAX_VALUES_PER_FACET_API_LIMIT) { - solutions.push( - `- Update your records to lower the precision of the values in the "${attribute}" attribute (for example: ${(5.123456789).toPrecision( - maxDecimalPlaces + 1 - )} to ${(5.123456789).toPrecision(maxDecimalPlacesInRange + 1)})` - ); - } - if (maxValuesPerFacet < maxFacetsInRange) { - solutions.push( - `- Increase the maximum number of facet values to ${maxFacetsInRange} using the "configure" widget ${createDocumentationLink( - { name: 'configure' } - )} and the "maxValuesPerFacet" parameter https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/` - ); - } - - return `The ${attribute} attribute can have ${maxFacets} different values (0 to ${max} with a maximum of ${maxDecimalPlaces} decimals = ${maxFacets}) but you retrieved only ${maxValuesPerFacet} facet values. Therefore the number of results that match the refinements can be incorrect. - ${ - solutions.length - ? `To resolve this problem you can:\n${solutions.join('\n')}` - : `` - }`; - }; - - function getRefinedState(state: SearchParameters, facetValue: string) { - const isRefined = getRefinedStar(state) === Number(facetValue); - - const emptyState = state.resetPage().removeNumericRefinement(attribute); - - if (!isRefined) { - return emptyState - .addNumericRefinement(attribute, '<=', max) - .addNumericRefinement(attribute, '>=', Number(facetValue)); - } - return emptyState; - } - - const toggleRefinement = ( - helper: AlgoliaSearchHelper, - facetValue: string - ) => { - sendEvent('click:internal', facetValue); - helper.setState(getRefinedState(helper.state, facetValue)).search(); - }; - - type ConnectorState = { - toggleRefinementFactory: ( - helper: AlgoliaSearchHelper - ) => (facetValue: string) => void; - createURLFactory: ({ - state, - createURL, - }: { - state: SearchParameters; - createURL: (InitOptions | RenderOptions)['createURL']; - getWidgetUiState: NonNullable; - helper: AlgoliaSearchHelper; - }) => (value: string) => string; - }; - - const connectorState: ConnectorState = { - toggleRefinementFactory: (helper) => toggleRefinement.bind(null, helper), - createURLFactory: - ({ state, createURL, getWidgetUiState, helper }) => - (value) => - createURL((uiState) => - getWidgetUiState(uiState, { - searchParameters: getRefinedState(state, value), - helper, - }) - ), - }; - - return { - $$type, - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - ratingMenu: { - ...renderState.ratingMenu, - [attribute]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState({ - helper, - results, - state, - instantSearchInstance, - createURL, - }) { - let facetValues: StarRatingItems[] = []; - - if (!sendEvent) { - sendEvent = createSendEvent({ - instantSearchInstance, - helper, - getRefinedStar: () => getRefinedStar(helper.state), - attribute, - }); - } - - let refinementIsApplied = false; - let totalCount = 0; - - const facetResults = results?.getFacetValues(attribute, {}) as - | SearchResults.FacetValue[] - | undefined; - - if (results && facetResults) { - const maxValuesPerFacet = facetResults.length; - const maxDecimalPlaces = getFacetsMaxDecimalPlaces(facetResults); - const maxFacets = Math.pow(10, maxDecimalPlaces) * max; - - warning( - maxFacets <= maxValuesPerFacet || Boolean(results.__isArtificial), - getFacetValuesWarningMessage({ - maxDecimalPlaces, - maxFacets, - maxValuesPerFacet, - }) - ); - - const refinedStar = getRefinedStar(state); - - for (let star = STEP; star < max; star += STEP) { - const isRefined = refinedStar === star; - refinementIsApplied = refinementIsApplied || isRefined; - - const count = facetResults - .filter((f) => Number(f.name) >= star && Number(f.name) <= max) - .map((f) => f.count) - .reduce((sum, current) => sum + current, 0); - totalCount += count; - - if (refinedStar && !isRefined && count === 0) { - // skip count==0 when at least 1 refinement is enabled - // eslint-disable-next-line no-continue - continue; - } - - const stars = [...new Array(Math.floor(max / STEP))].map( - (_v, i) => i * STEP < star - ); - - facetValues.push({ - stars, - name: String(star), - label: String(star), - value: String(star), - count, - isRefined, - }); - } - } - facetValues = facetValues.reverse(); - - const hasNoResults = results ? results.nbHits === 0 : true; - - return { - items: facetValues, - hasNoResults, - canRefine: (!hasNoResults || refinementIsApplied) && totalCount > 0, - refine: connectorState.toggleRefinementFactory(helper), - sendEvent, - createURL: connectorState.createURLFactory({ - state, - createURL, - helper, - getWidgetUiState: this.getWidgetUiState, - }), - widgetParams, - }; - }, - - dispose({ state }) { - unmountFn(); - - return state.removeNumericRefinement(attribute); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const value = getRefinedStar(searchParameters); - - return removeEmptyRefinementsFromUiState( - { - ...uiState, - ratingMenu: { - ...uiState.ratingMenu, - [attribute]: typeof value === 'number' ? value : undefined, - }, - }, - attribute - ); - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const value = uiState.ratingMenu && uiState.ratingMenu[attribute]; - - const withDisjunctiveFacet = searchParameters - .addDisjunctiveFacet(attribute) - .removeNumericRefinement(attribute) - .removeDisjunctiveFacetRefinement(attribute); - - if (!value) { - return withDisjunctiveFacet.setQueryParameters({ - numericRefinements: { - ...withDisjunctiveFacet.numericRefinements, - [attribute]: {}, - }, - }); - } - - return withDisjunctiveFacet - .addNumericRefinement(attribute, '<=', max) - .addNumericRefinement(attribute, '>=', value); - }, - }; - }; -}; - -function removeEmptyRefinementsFromUiState( - indexUiState: IndexUiState, - attribute: string -): IndexUiState { - if (!indexUiState.ratingMenu) { - return indexUiState; - } - - if (typeof indexUiState.ratingMenu[attribute] !== 'number') { - delete indexUiState.ratingMenu[attribute]; - } - - if (Object.keys(indexUiState.ratingMenu).length === 0) { - delete indexUiState.ratingMenu; - } - - return indexUiState; -} - -export default connectRatingMenu; +export { connectRatingMenu as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/refinement-list/connectRefinementList.ts b/packages/instantsearch.js/src/connectors/refinement-list/connectRefinementList.ts index 1aa2342dc01..b35ff78add4 100644 --- a/packages/instantsearch.js/src/connectors/refinement-list/connectRefinementList.ts +++ b/packages/instantsearch.js/src/connectors/refinement-list/connectRefinementList.ts @@ -1,591 +1,2 @@ -import { - escapeFacets, - TAG_PLACEHOLDER, - TAG_REPLACEMENT, - checkRendering, - createDocumentationMessageGenerator, - createSendEventForFacet, - noop, - warning, -} from '../../lib/utils'; - -import type { SendEventForFacet } from '../../lib/utils'; -import type { - Connector, - TransformItems, - SortBy, - RenderOptions, - Widget, - InitOptions, - FacetHit, - CreateURL, - WidgetRenderState, - IndexUiState, -} from '../../types'; -import type { AlgoliaSearchHelper, SearchResults } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'refinement-list', - connector: true, -}); - -const DEFAULT_SORT = ['isRefined', 'count:desc', 'name:asc']; - -export type RefinementListItem = { - /** - * The value of the refinement list item. - */ - value: string; - /** - * Human-readable value of the refinement list item. - */ - label: string; - /** - * Human-readable value of the searched refinement list item. - */ - highlighted?: string; - /** - * Number of matched results after refinement is applied. - */ - count: number; - /** - * Indicates if the list item is refined. - */ - isRefined: boolean; -}; - -export type RefinementListConnectorParams = { - /** - * The name of the attribute in the records. - */ - attribute: string; - /** - * How the filters are combined together. - */ - operator?: 'and' | 'or'; - /** - * The max number of items to display when - * `showMoreLimit` is not set or if the widget is showing less value. - */ - limit?: number; - /** - * Whether to display a button that expands the number of items. - */ - showMore?: boolean; - /** - * The max number of items to display if the widget - * is showing more items. - */ - showMoreLimit?: number; - /** - * How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`. - * - * You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax). - * - * If a facetOrdering is set in the index settings, it is used when sortBy isn't passed - */ - sortBy?: SortBy; - /** - * Escapes the content of the facet values. - */ - escapeFacetValues?: boolean; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; -}; - -export type RefinementListRenderState = { - /** - * The list of filtering values returned from Algolia API. - */ - items: RefinementListItem[]; - /** - * indicates whether the results are exhaustive (complete) - */ - hasExhaustiveItems: boolean; - /** - * Creates the next state url for a selected refinement. - */ - createURL: CreateURL; - /** - * Action to apply selected refinements. - */ - refine: (value: string) => void; - /** - * Send event to insights middleware - */ - sendEvent: SendEventForFacet; - /** - * Searches for values inside the list. - */ - searchForItems: (query: string) => void; - /** - * `true` if the values are from an index search. - */ - isFromSearch: boolean; - /** - * `true` if a refinement can be applied. - * @MAJOR: reconsider how `canRefine` is computed so it both accounts for the - * items returned in the main search and in SFFV. - */ - canRefine: boolean; - /** - * `true` if the toggleShowMore button can be activated (enough items to display more or - * already displaying more than `limit` items) - */ - canToggleShowMore: boolean; - /** - * True if the menu is displaying all the menu items. - */ - isShowingMore: boolean; - /** - * Toggles the number of values displayed between `limit` and `showMoreLimit`. - */ - toggleShowMore: () => void; -}; - -export type RefinementListWidgetDescription = { - $$type: 'ais.refinementList'; - renderState: RefinementListRenderState; - indexRenderState: { - refinementList: { - [attribute: string]: WidgetRenderState< - RefinementListRenderState, - RefinementListConnectorParams - >; - }; - }; - indexUiState: { - refinementList: { - [attribute: string]: string[]; - }; - }; -}; - -export type RefinementListConnector = Connector< - RefinementListWidgetDescription, - RefinementListConnectorParams ->; - -/** - * **RefinementList** connector provides the logic to build a custom widget that - * will let the user filter the results based on the values of a specific facet. - * - * **Requirement:** the attribute passed as `attribute` must be present in - * attributesForFaceting of the searched index. - * - * This connector provides: - * - a `refine()` function to select an item. - * - a `toggleShowMore()` function to display more or less items - * - a `searchForItems()` function to search within the items. - */ -const connectRefinementList: RefinementListConnector = - function connectRefinementList(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - attribute, - operator = 'or', - limit = 10, - showMore = false, - showMoreLimit = 20, - sortBy = DEFAULT_SORT, - escapeFacetValues = true, - transformItems = ((items) => items) as NonNullable< - RefinementListConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - type ThisWidget = Widget< - RefinementListWidgetDescription & { widgetParams: typeof widgetParams } - >; - - if (!attribute) { - throw new Error(withUsage('The `attribute` option is required.')); - } - - if (!/^(and|or)$/.test(operator)) { - throw new Error( - withUsage( - `The \`operator\` must one of: \`"and"\`, \`"or"\` (got "${operator}").` - ) - ); - } - - if (showMore === true && showMoreLimit <= limit) { - throw new Error( - withUsage('`showMoreLimit` should be greater than `limit`.') - ); - } - - const formatItems = ({ - name: label, - escapedValue: value, - ...item - }: SearchResults.FacetValue): RefinementListItem => ({ - ...item, - value, - label, - highlighted: label, - }); - - let lastResultsFromMainSearch: SearchResults; - let lastItemsFromMainSearch: RefinementListItem[] = []; - let hasExhaustiveItems = true; - let triggerRefine: RefinementListRenderState['refine'] | undefined; - let sendEvent: RefinementListRenderState['sendEvent'] | undefined; - - let isShowingMore = false; - // Provide the same function to the `renderFn` so that way the user - // has to only bind it once when `isFirstRendering` for instance - let toggleShowMore = () => {}; - function cachedToggleShowMore() { - toggleShowMore(); - } - - function createToggleShowMore( - renderOptions: RenderOptions, - widget: ThisWidget - ) { - return () => { - isShowingMore = !isShowingMore; - widget.render!(renderOptions); - }; - } - - function getLimit() { - return isShowingMore ? showMoreLimit : limit; - } - - let searchForFacetValues: ( - renderOptions: RenderOptions | InitOptions - ) => RefinementListRenderState['searchForItems'] = () => () => {}; - - const createSearchForFacetValues = function ( - helper: AlgoliaSearchHelper, - widget: ThisWidget - ) { - return (renderOptions: RenderOptions | InitOptions) => - (query: string) => { - const { instantSearchInstance, results: searchResults } = - renderOptions; - if (query === '' && lastItemsFromMainSearch) { - // render with previous data from the helper. - renderFn( - { - ...widget.getWidgetRenderState({ - ...renderOptions, - results: lastResultsFromMainSearch, - }), - instantSearchInstance, - }, - false - ); - } else { - const tags = { - highlightPreTag: escapeFacetValues - ? TAG_PLACEHOLDER.highlightPreTag - : TAG_REPLACEMENT.highlightPreTag, - highlightPostTag: escapeFacetValues - ? TAG_PLACEHOLDER.highlightPostTag - : TAG_REPLACEMENT.highlightPostTag, - }; - - helper - .searchForFacetValues( - attribute, - query, - // We cap the `maxFacetHits` value to 100 because the Algolia API - // doesn't support a greater number. - // See https://www.algolia.com/doc/api-reference/api-parameters/maxFacetHits/ - Math.min(getLimit(), 100), - tags - ) - .then((results) => { - const facetValues = escapeFacetValues - ? escapeFacets(results.facetHits) - : results.facetHits; - - const normalizedFacetValues = transformItems( - facetValues.map(({ escapedValue, value, ...item }) => ({ - ...item, - value: escapedValue, - label: value, - })), - { results: searchResults } - ); - - renderFn( - { - ...widget.getWidgetRenderState({ - ...renderOptions, - results: lastResultsFromMainSearch, - }), - items: normalizedFacetValues, - canToggleShowMore: false, - canRefine: true, - isFromSearch: true, - instantSearchInstance, - }, - false - ); - }); - } - }; - }; - - return { - $$type: 'ais.refinementList' as const, - - init(initOptions) { - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance: initOptions.instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance: renderOptions.instantSearchInstance, - }, - false - ); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - refinementList: { - ...renderState.refinementList, - [attribute]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState(renderOptions) { - const { results, state, createURL, instantSearchInstance, helper } = - renderOptions; - let items: RefinementListItem[] = []; - let facetValues: SearchResults.FacetValue[] | FacetHit[] = []; - - if (!sendEvent || !triggerRefine || !searchForFacetValues) { - sendEvent = createSendEventForFacet({ - instantSearchInstance, - helper, - attribute, - widgetType: this.$$type, - }); - - triggerRefine = (facetValue) => { - sendEvent!('click:internal', facetValue); - helper.toggleFacetRefinement(attribute, facetValue).search(); - }; - - searchForFacetValues = createSearchForFacetValues(helper, this); - } - - if (results) { - const values = results.getFacetValues(attribute, { - sortBy, - facetOrdering: sortBy === DEFAULT_SORT, - }); - facetValues = values && Array.isArray(values) ? values : []; - items = transformItems( - facetValues.slice(0, getLimit()).map(formatItems), - { results } - ); - - const maxValuesPerFacetConfig = state.maxValuesPerFacet; - const currentLimit = getLimit(); - // If the limit is the max number of facet retrieved it is impossible to know - // if the facets are exhaustive. The only moment we are sure it is exhaustive - // is when it is strictly under the number requested unless we know that another - // widget has requested more values (maxValuesPerFacet > getLimit()). - // Because this is used for making the search of facets unable or not, it is important - // to be conservative here. - hasExhaustiveItems = - maxValuesPerFacetConfig! > currentLimit - ? facetValues.length <= currentLimit - : facetValues.length < currentLimit; - - lastResultsFromMainSearch = results; - lastItemsFromMainSearch = items; - - if (renderOptions.results) { - toggleShowMore = createToggleShowMore(renderOptions, this); - } - } - - // Do not mistake searchForFacetValues and searchFacetValues which is the actual search - // function - const searchFacetValues = - searchForFacetValues && searchForFacetValues(renderOptions); - - const canShowLess = - isShowingMore && lastItemsFromMainSearch.length > limit; - const canShowMore = showMore && !hasExhaustiveItems; - - const canToggleShowMore = canShowLess || canShowMore; - - return { - createURL: (facetValue: string) => { - return createURL((uiState) => - this.getWidgetUiState(uiState, { - searchParameters: state - .resetPage() - .toggleFacetRefinement(attribute, facetValue), - helper, - }) - ); - }, - items, - refine: triggerRefine, - searchForItems: searchFacetValues, - isFromSearch: false, - canRefine: items.length > 0, - widgetParams, - isShowingMore, - canToggleShowMore, - toggleShowMore: cachedToggleShowMore, - sendEvent, - hasExhaustiveItems, - }; - }, - - dispose({ state }) { - unmountFn(); - - const withoutMaxValuesPerFacet = state.setQueryParameter( - 'maxValuesPerFacet', - undefined - ); - if (operator === 'and') { - return withoutMaxValuesPerFacet.removeFacet(attribute); - } - return withoutMaxValuesPerFacet.removeDisjunctiveFacet(attribute); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const values = - operator === 'or' - ? searchParameters.getDisjunctiveRefinements(attribute) - : searchParameters.getConjunctiveRefinements(attribute); - - return removeEmptyRefinementsFromUiState( - { - ...uiState, - refinementList: { - ...uiState.refinementList, - [attribute]: values, - }, - }, - attribute - ); - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const isDisjunctive = operator === 'or'; - - if (searchParameters.isHierarchicalFacet(attribute)) { - warning( - false, - `RefinementList: Attribute "${attribute}" is already used by another widget applying hierarchical faceting. -As this is not supported, please make sure to remove this other widget or this RefinementList widget will not work at all.` - ); - - return searchParameters; - } - - if ( - (isDisjunctive && searchParameters.isConjunctiveFacet(attribute)) || - (!isDisjunctive && searchParameters.isDisjunctiveFacet(attribute)) - ) { - warning( - false, - `RefinementList: Attribute "${attribute}" is used by another refinement list with a different operator. -As this is not supported, please make sure to only use this attribute with one of the two operators.` - ); - - return searchParameters; - } - - const values = - uiState.refinementList && uiState.refinementList[attribute]; - - const withFacetConfiguration = isDisjunctive - ? searchParameters - .addDisjunctiveFacet(attribute) - .removeDisjunctiveFacetRefinement(attribute) - : searchParameters - .addFacet(attribute) - .removeFacetRefinement(attribute); - - const currentMaxValuesPerFacet = - withFacetConfiguration.maxValuesPerFacet || 0; - - const nextMaxValuesPerFacet = Math.max( - currentMaxValuesPerFacet, - showMore ? showMoreLimit : limit - ); - - const withMaxValuesPerFacet = - withFacetConfiguration.setQueryParameter( - 'maxValuesPerFacet', - nextMaxValuesPerFacet - ); - - if (!values) { - const key = isDisjunctive - ? 'disjunctiveFacetsRefinements' - : 'facetsRefinements'; - - return withMaxValuesPerFacet.setQueryParameters({ - [key]: { - ...withMaxValuesPerFacet[key], - [attribute]: [], - }, - }); - } - - return values.reduce( - (parameters, value) => - isDisjunctive - ? parameters.addDisjunctiveFacetRefinement(attribute, value) - : parameters.addFacetRefinement(attribute, value), - withMaxValuesPerFacet - ); - }, - }; - }; - }; - -function removeEmptyRefinementsFromUiState( - indexUiState: IndexUiState, - attribute: string -): IndexUiState { - if (!indexUiState.refinementList) { - return indexUiState; - } - - if ( - !indexUiState.refinementList[attribute] || - indexUiState.refinementList[attribute].length === 0 - ) { - delete indexUiState.refinementList[attribute]; - } - - if (Object.keys(indexUiState.refinementList).length === 0) { - delete indexUiState.refinementList; - } - - return indexUiState; -} - -export default connectRefinementList; +export { connectRefinementList as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/related-products/connectRelatedProducts.ts b/packages/instantsearch.js/src/connectors/related-products/connectRelatedProducts.ts index ac1610fda27..14a9aa929bf 100644 --- a/packages/instantsearch.js/src/connectors/related-products/connectRelatedProducts.ts +++ b/packages/instantsearch.js/src/connectors/related-products/connectRelatedProducts.ts @@ -1,236 +1,2 @@ -import { - createDocumentationMessageGenerator, - checkRendering, - noop, - escapeHits, - TAG_PLACEHOLDER, - createSendEventForHits, - addAbsolutePosition, - addQueryID, -} from '../../lib/utils'; - -import type { SendEventForHits } from '../../lib/utils'; -import type { - Connector, - TransformItems, - BaseHit, - Renderer, - Unmounter, - UnknownWidgetParams, - RecommendResponse, - Hit, - AlgoliaHit, -} from '../../types'; -import type { PlainSearchParameters } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'related-products', - connector: true, -}); - -export type RelatedProductsRenderState< - THit extends NonNullable = BaseHit -> = { - /** - * The matched recommendations from the Algolia API. - */ - items: Array>; - - /** - * Sends an event to the Insights middleware. - */ - sendEvent: SendEventForHits; -}; - -export type RelatedProductsConnectorParams< - THit extends NonNullable = BaseHit -> = { - /** - * The `objectIDs` of the items to get related products from. - */ - objectIDs: string[]; - /** - * The number of recommendations to retrieve. - */ - limit?: 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' - >; - /** - * Whether to escape HTML tags from items string values. - * - * @default true - */ - escapeHTML?: boolean; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems< - Hit, - { results: RecommendResponse> } - >; -}; - -export type RelatedProductsWidgetDescription< - THit extends NonNullable = BaseHit -> = { - $$type: 'ais.relatedProducts'; - renderState: RelatedProductsRenderState; -}; - -export type RelatedProductsConnector< - THit extends NonNullable = BaseHit -> = Connector< - RelatedProductsWidgetDescription, - RelatedProductsConnectorParams ->; - -export default (function connectRelatedProducts< - TWidgetParams extends UnknownWidgetParams ->( - renderFn: Renderer< - RelatedProductsRenderState, - RelatedProductsConnectorParams & TWidgetParams - >, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return = BaseHit>( - widgetParams: TWidgetParams & RelatedProductsConnectorParams - ) => { - const { - // @MAJOR: this can default to false - escapeHTML = true, - objectIDs, - limit, - 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.')); - } - - let sendEvent: SendEventForHits; - - 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, helper, instantSearchInstance }) { - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - helper, - widgetType: this.$$type, - }); - } - if (results === null || results === undefined) { - return { items: [], widgetParams, sendEvent }; - } - - if (escapeHTML && results.hits.length > 0) { - results.hits = escapeHits(results.hits); - } - - const itemsWithAbsolutePosition = addAbsolutePosition( - results.hits, - 0, - 1 - ); - - const itemsWithAbsolutePositionAndQueryID = addQueryID( - itemsWithAbsolutePosition, - results.queryID - ); - - const transformedItems = transformItems( - itemsWithAbsolutePositionAndQueryID, - { - results: results as RecommendResponse>, - } - ); - - return { - items: transformedItems, - widgetParams, - sendEvent, - }; - }, - - dispose({ recommendState }) { - unmountFn(); - return recommendState.removeParams(this.$$id!); - }, - - getWidgetParameters(state) { - return objectIDs.reduce( - (acc, objectID) => - acc.addRelatedProducts({ - objectID, - maxRecommendations: limit, - threshold, - fallbackParameters: fallbackParameters - ? { - ...fallbackParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - } - : undefined, - queryParameters: { - ...queryParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - }, - $$id: this.$$id!, - }), - state.removeParams(this.$$id!) - ); - }, - }; - }; -} satisfies RelatedProductsConnector); +export { connectRelatedProducts as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/relevant-sort/connectRelevantSort.ts b/packages/instantsearch.js/src/connectors/relevant-sort/connectRelevantSort.ts index e118b105551..2f1fe77f798 100644 --- a/packages/instantsearch.js/src/connectors/relevant-sort/connectRelevantSort.ts +++ b/packages/instantsearch.js/src/connectors/relevant-sort/connectRelevantSort.ts @@ -1,142 +1,2 @@ -import { noop } from '../../lib/utils'; - -import type { Connector, WidgetRenderState } from '../../types'; - -export type RelevantSortConnectorParams = Record; - -type Refine = (relevancyStrictness: number | undefined) => void; - -export type RelevantSortRenderState = { - /** - * Indicates if it has appliedRelevancyStrictness greater than zero - */ - isRelevantSorted: boolean; - - /** - * Indicates if the results come from a virtual replica - */ - isVirtualReplica: boolean; - - /** - * Indicates if search state can be refined - */ - canRefine: boolean; - - /** - * Sets the value as relevancyStrictness and trigger a new search - */ - refine: Refine; -}; - -export type RelevantSortWidgetDescription = { - $$type: 'ais.relevantSort'; - renderState: RelevantSortRenderState; - indexRenderState: { - relevantSort: WidgetRenderState< - RelevantSortRenderState, - RelevantSortConnectorParams - >; - }; - indexUiState: { - relevantSort: number; - }; -}; - -export type RelevantSortConnector = Connector< - RelevantSortWidgetDescription, - RelevantSortConnectorParams ->; - -const connectRelevantSort: RelevantSortConnector = function connectRelevantSort( - renderFn = noop, - unmountFn = noop -) { - return (widgetParams) => { - type ConnectorState = { - refine?: Refine; - }; - - const connectorState: ConnectorState = {}; - - return { - $$type: 'ais.relevantSort', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return state.setQueryParameter('relevancyStrictness', undefined); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - relevantSort: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ results, helper }) { - if (!connectorState.refine) { - connectorState.refine = (relevancyStrictness) => { - helper - .setQueryParameter('relevancyStrictness', relevancyStrictness) - .search(); - }; - } - - const { appliedRelevancyStrictness } = results || {}; - - const isVirtualReplica = appliedRelevancyStrictness !== undefined; - - return { - isRelevantSorted: - typeof appliedRelevancyStrictness !== 'undefined' && - appliedRelevancyStrictness > 0, - isVirtualReplica, - canRefine: isVirtualReplica, - refine: connectorState.refine, - widgetParams, - }; - }, - - getWidgetSearchParameters(state, { uiState }) { - return state.setQueryParameter( - 'relevancyStrictness', - uiState.relevantSort ?? state.relevancyStrictness - ); - }, - - getWidgetUiState(uiState, { searchParameters }) { - return { - ...uiState, - relevantSort: - searchParameters.relevancyStrictness || uiState.relevantSort, - }; - }, - }; - }; -}; - -export default connectRelevantSort; +export { connectRelevantSort as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/search-box/connectSearchBox.ts b/packages/instantsearch.js/src/connectors/search-box/connectSearchBox.ts index d9efe514262..1ca25eb4e73 100644 --- a/packages/instantsearch.js/src/connectors/search-box/connectSearchBox.ts +++ b/packages/instantsearch.js/src/connectors/search-box/connectSearchBox.ts @@ -1,176 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - noop, -} from '../../lib/utils'; - -import type { Connector, WidgetRenderState } from '../../types'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'search-box', - connector: true, -}); - -export type SearchBoxConnectorParams = { - /** - * A function that will be called every time - * a new value for the query is set. The first parameter is the query and the second is a - * function to actually trigger the search. The function takes the query as the parameter. - * - * This queryHook can be used to debounce the number of searches done from the searchBox. - */ - queryHook?: (query: string, hook: (value: string) => void) => void; -}; - -/** - * @typedef {Object} CustomSearchBoxWidgetParams - * @property {function(string, function(string))} [queryHook = undefined] A function that will be called every time - * a new value for the query is set. The first parameter is the query and the second is a - * function to actually trigger the search. The function takes the query as the parameter. - * - * This queryHook can be used to debounce the number of searches done from the searchBox. - */ - -export type SearchBoxRenderState = { - /** - * The query from the last search. - */ - query: string; - /** - * Sets a new query and searches. - */ - refine: (value: string) => void; - /** - * Remove the query and perform search. - */ - clear: () => void; - /** - * `true` if the search results takes more than a certain time to come back - * from Algolia servers. This can be configured on the InstantSearch constructor with the attribute - * `stalledSearchDelay` which is 200ms, by default. - * @deprecated use `instantSearchInstance.status` instead - */ - isSearchStalled: boolean; -}; - -export type SearchBoxWidgetDescription = { - $$type: 'ais.searchBox'; - renderState: SearchBoxRenderState; - indexRenderState: { - searchBox: WidgetRenderState< - SearchBoxRenderState, - SearchBoxConnectorParams - >; - }; - indexUiState: { - query: string; - }; -}; - -export type SearchBoxConnector = Connector< - SearchBoxWidgetDescription, - SearchBoxConnectorParams ->; - -const defaultQueryHook: SearchBoxConnectorParams['queryHook'] = (query, hook) => - hook(query); - -/** - * **SearchBox** connector provides the logic to build a widget that will let the user search for a query. - * - * The connector provides to the rendering: `refine()` to set the query. The behaviour of this function - * may be impacted by the `queryHook` widget parameter. - */ -const connectSearchBox: SearchBoxConnector = function connectSearchBox( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { queryHook = defaultQueryHook } = widgetParams || {}; - - let _refine: SearchBoxRenderState['refine']; - let _clear: SearchBoxRenderState['clear']; - - return { - $$type: 'ais.searchBox', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return state.setQueryParameter('query', undefined); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - searchBox: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ helper, instantSearchInstance, state }) { - if (!_refine) { - _refine = (query) => { - queryHook(query, (q) => helper.setQuery(q).search()); - }; - - _clear = () => { - helper.setQuery('').search(); - }; - } - - return { - query: state.query || '', - refine: _refine, - clear: _clear, - widgetParams, - isSearchStalled: instantSearchInstance.status === 'stalled', - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - const query = searchParameters.query || ''; - - if (query === '' || (uiState && uiState.query === query)) { - return uiState; - } - - return { - ...uiState, - query, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - return searchParameters.setQueryParameter('query', uiState.query || ''); - }, - }; - }; -}; - -export default connectSearchBox; +export { connectSearchBox as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/sort-by/connectSortBy.ts b/packages/instantsearch.js/src/connectors/sort-by/connectSortBy.ts index 3af89aaa2c1..ab1eeb9d21a 100644 --- a/packages/instantsearch.js/src/connectors/sort-by/connectSortBy.ts +++ b/packages/instantsearch.js/src/connectors/sort-by/connectSortBy.ts @@ -1,396 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - find, - warning, - noop, -} from '../../lib/utils'; - -import type { Connector, TransformItems, WidgetRenderState } from '../../types'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'sort-by', - connector: true, -}); - -/** - * The **SortBy** connector provides the logic to build a custom widget that will display a - * list of indices or sorting strategies. With Algolia, this is most commonly used for changing - * ranking strategy. This allows a user to change how the hits are being sorted. - * - * This connector supports two sorting modes: - * 1. **Index-based (traditional)**: Uses the `value` property to switch between different indices. - * This is the standard behavior for non-composition setups. - * - * 2. **Strategy-based (composition mode)**: Uses the `strategy` property to apply sorting strategies - * via the `sortBy` search parameter. This is only available when using Algolia Compositions. - * - * Items can mix both types in the same widget, allowing for flexible sorting options. - */ - -export type SortByIndexItem = { - /** - * The name of the index to target. - */ - value: string; - /** - * The label of the index to display. - */ - label: string; - /** - * Ensures mutual exclusivity with strategy. - */ - strategy?: never; -}; - -export type SortByStrategyItem = { - /** - * The name of the sorting strategy to use. - * Only available in composition mode. - */ - strategy: string; - /** - * The label of the strategy to display. - */ - label: string; - /** - * Ensures mutual exclusivity with value. - */ - value?: never; -}; - -export type SortByItem = SortByIndexItem | SortByStrategyItem; - -export type SortByConnectorParams = { - /** - * Array of objects defining the different indices or strategies to choose from. - */ - items: SortByItem[]; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems; -}; - -export type SortByRenderState = { - /** - * The initially selected index or strategy. - */ - initialIndex?: string; - /** - * The currently selected index or strategy. - */ - currentRefinement: string; - /** - * All the available indices and strategies - */ - options: Array<{ value: string; label: string }>; - /** - * Switches indices or strategies and triggers a new search. - */ - refine: (value: string) => void; - /** - * `true` if the last search contains no result. - * @deprecated Use `canRefine` instead. - */ - hasNoResults: boolean; - /** - * `true` if we can refine. - */ - canRefine: boolean; -}; - -export type SortByWidgetDescription = { - $$type: 'ais.sortBy'; - renderState: SortByRenderState; - indexRenderState: { - sortBy: WidgetRenderState; - }; - indexUiState: { - sortBy: string; - }; -}; - -export type SortByConnector = Connector< - SortByWidgetDescription, - SortByConnectorParams ->; - -function isStrategyItem(item: SortByItem): item is SortByStrategyItem { - return 'strategy' in item && item.strategy !== undefined; -} - -function getItemValue(item: SortByItem): string { - if (isStrategyItem(item)) { - return item.strategy; - } - return item.value; -} - -function isValidStrategy( - itemsLookup: Record, - value: string | undefined -): boolean { - if (!value) return false; - const item = itemsLookup[value]; - return item !== undefined && isStrategyItem(item); -} - -const connectSortBy: SortByConnector = function connectSortBy( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - const connectorState: ConnectorState = {}; - - type ConnectorState = { - refine?: (value: string) => void; - initialValue?: string; - // Cached flag: whether we're in composition mode (checked once, never changes) - // This is cached because instantSearchInstance is not available in all lifecycle methods - isUsingComposition?: boolean; - // Object for O(1) lookup: value/strategy -> item - itemsLookup?: Record; - }; - - return (widgetParams) => { - const { - items, - transformItems = ((x) => x) as NonNullable< - SortByConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if (!Array.isArray(items)) { - throw new Error( - withUsage('The `items` option expects an array of objects.') - ); - } - - const itemsLookup: Record = {}; - - items.forEach((item, index) => { - const hasValue = 'value' in item && item.value !== undefined; - const hasStrategy = 'strategy' in item && item.strategy !== undefined; - - // Validate mutual exclusivity - if (hasValue && hasStrategy) { - throw new Error( - withUsage( - `Item at index ${index} cannot have both "value" and "strategy" properties.` - ) - ); - } - - if (!hasValue && !hasStrategy) { - throw new Error( - withUsage( - `Item at index ${index} must have either a "value" or "strategy" property.` - ) - ); - } - - const itemValue = getItemValue(item); - - itemsLookup[itemValue] = item; - }); - - connectorState.itemsLookup = itemsLookup; - - return { - $$type: 'ais.sortBy', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - // Check if strategies are used outside composition mode - const hasStrategyItems = items.some( - (item) => 'strategy' in item && item.strategy - ); - - if (hasStrategyItems && !instantSearchInstance.compositionID) { - throw new Error( - withUsage( - 'Sorting strategies can only be used in composition mode. Please provide a "compositionID" to your InstantSearch instance.' - ) - ); - } - - const widgetRenderState = this.getWidgetRenderState(initOptions); - const currentIndex = widgetRenderState.currentRefinement; - const isCurrentIndexInItems = find( - items, - (item) => getItemValue(item) === currentIndex - ); - - warning( - isCurrentIndexInItems !== undefined, - `The index named "${currentIndex}" is not listed in the \`items\` of \`sortBy\`.` - ); - - renderFn( - { - ...widgetRenderState, - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - // Clear sortBy parameter if it was set - if (connectorState.isUsingComposition && state.sortBy) { - state = state.setQueryParameter('sortBy' as any, undefined); - } - - // Restore initial index if changed - if ( - connectorState.initialValue && - state.index !== connectorState.initialValue - ) { - return state.setIndex(connectorState.initialValue); - } - - return state; - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - sortBy: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ - results, - helper, - state, - parent, - instantSearchInstance, - }) { - // Capture initial value (composition ID or main index) - if (!connectorState.initialValue && parent) { - connectorState.initialValue = parent.getIndexName(); - } - - // Create refine function if not exists - if (!connectorState.refine) { - // Cache composition mode status for lifecycle methods that don't have access to instantSearchInstance - connectorState.isUsingComposition = Boolean( - instantSearchInstance?.compositionID - ); - - connectorState.refine = (value: string) => { - // O(1) lookup using the items lookup table - const item = connectorState.itemsLookup![value]; - - if (item && isStrategyItem(item)) { - // Strategy-based: set sortBy parameter for composition API - // The composition backend will interpret this and apply the sorting strategy - helper.setQueryParameter('sortBy', item.strategy).search(); - } else { - // Index-based: clear any existing sortBy parameter and switch to the new index - // Clearing sortBy is critical when transitioning from strategy to index-based sorting - helper - .setQueryParameter('sortBy', undefined) - .setIndex(value) - .search(); - } - }; - } - - // Transform items first (on original structure) - const transformedItems = transformItems(items, { results }); - - // Normalize items: all get a 'value' property for the render state - const normalizedItems = transformedItems.map((item) => ({ - label: item.label, - value: getItemValue(item), - })); - - // Determine current refinement - // In composition mode, prefer sortBy parameter if it corresponds to a valid strategy item - // Otherwise use the index (for index-based items or when no valid strategy is active) - const currentRefinement = - connectorState.isUsingComposition && - isValidStrategy(connectorState.itemsLookup!, state.sortBy) - ? state.sortBy! - : state.index; - - const hasNoResults = results ? results.nbHits === 0 : true; - - return { - currentRefinement, - options: normalizedItems, - refine: connectorState.refine, - hasNoResults, - canRefine: !hasNoResults && items.length > 0, - widgetParams, - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - // In composition mode with an active strategy, use sortBy parameter - // Otherwise use index-based behavior (traditional mode) - const currentValue = - connectorState.isUsingComposition && - isValidStrategy(connectorState.itemsLookup!, searchParameters.sortBy) - ? searchParameters.sortBy! - : searchParameters.index; - - return { - ...uiState, - sortBy: - currentValue !== connectorState.initialValue - ? currentValue - : undefined, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - const isUiStateSortByInItems = - !uiState.sortBy || - Object.prototype.hasOwnProperty.call( - connectorState.itemsLookup, - uiState.sortBy - ); - - warning( - Boolean(isUiStateSortByInItems), - `The index named "${uiState.sortBy}" is not listed in the \`items\` of \`sortBy\`.` - ); - - const sortByValue = - (isUiStateSortByInItems ? uiState.sortBy : undefined) || - connectorState.initialValue || - searchParameters.index; - - if (isValidStrategy(connectorState.itemsLookup!, sortByValue)) { - const item = connectorState.itemsLookup![sortByValue]; - // Strategy-based: set the sortBy parameter for composition API - // The index remains as the compositionID - return searchParameters.setQueryParameter('sortBy', item.strategy); - } - - // Index-based: set the index parameter (traditional behavior) - return searchParameters.setQueryParameter('index', sortByValue); - }, - }; - }; -}; - -export default connectSortBy; +export { connectSortBy as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/stats/connectStats.ts b/packages/instantsearch.js/src/connectors/stats/connectStats.ts index f31cf708e9e..d8ca892aa1f 100644 --- a/packages/instantsearch.js/src/connectors/stats/connectStats.ts +++ b/packages/instantsearch.js/src/connectors/stats/connectStats.ts @@ -1,146 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - noop, -} from '../../lib/utils'; - -import type { Connector, WidgetRenderState } from '../../types'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'stats', - connector: true, -}); - -/** - * **Stats** connector provides the logic to build a custom widget that will displays - * search statistics (hits number and processing time). - */ - -export type StatsRenderState = { - /** - * The maximum number of hits per page returned by Algolia. - */ - hitsPerPage?: number; - /** - * The number of hits in the result set. - */ - nbHits: number; - /** - * The number of sorted hits in the result set (when using Relevant sort). - */ - nbSortedHits?: number; - /** - * Indicates whether the index is currently using Relevant sort and is displaying only sorted hits. - */ - areHitsSorted: boolean; - /** - * The number of pages computed for the result set. - */ - nbPages: number; - /** - * The current page. - */ - page: number; - /** - * The time taken to compute the results inside the Algolia engine. - */ - processingTimeMS: number; - /** - * The query used for the current search. - */ - query: string; -}; - -export type StatsConnectorParams = Record; - -export type StatsWidgetDescription = { - $$type: 'ais.stats'; - renderState: StatsRenderState; - indexRenderState: { - stats: WidgetRenderState; - }; -}; - -export type StatsConnector = Connector< - StatsWidgetDescription, - StatsConnectorParams ->; - -const connectStats: StatsConnector = function connectStats( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => ({ - $$type: 'ais.stats', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose() { - unmountFn(); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - stats: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState({ results, state }) { - if (!results) { - return { - hitsPerPage: state.hitsPerPage, - nbHits: 0, - nbSortedHits: undefined, - areHitsSorted: false, - nbPages: 0, - page: state.page || 0, - processingTimeMS: -1, - query: state.query || '', - widgetParams, - }; - } - - return { - hitsPerPage: results.hitsPerPage, - nbHits: results.nbHits, - nbSortedHits: results.nbSortedHits, - areHitsSorted: - typeof results.appliedRelevancyStrictness !== 'undefined' && - results.appliedRelevancyStrictness > 0 && - results.nbSortedHits !== results.nbHits, - nbPages: results.nbPages, - page: results.page, - processingTimeMS: results.processingTimeMS, - query: results.query, - widgetParams, - }; - }, - }); -}; - -export default connectStats; +export { connectStats as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/toggle-refinement/connectToggleRefinement.ts b/packages/instantsearch.js/src/connectors/toggle-refinement/connectToggleRefinement.ts index fccc3373ff6..50643d955ba 100644 --- a/packages/instantsearch.js/src/connectors/toggle-refinement/connectToggleRefinement.ts +++ b/packages/instantsearch.js/src/connectors/toggle-refinement/connectToggleRefinement.ts @@ -1,505 +1,2 @@ -import { - checkRendering, - escapeFacetValue, - createDocumentationMessageGenerator, - find, - noop, - toArray, - warning, -} from '../../lib/utils'; - -import type { - Connector, - CreateURL, - InitOptions, - InstantSearch, - RenderOptions, - Widget, - WidgetRenderState, -} from '../../types'; -import type { - AlgoliaSearchHelper, - SearchParameters, - SearchResults, -} from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'toggle-refinement', - connector: true, -}); - -const $$type = 'ais.toggleRefinement'; - -type BuiltInSendEventForToggle = ( - eventType: string, - isRefined: boolean, - eventName?: string -) => void; -type CustomSendEventForToggle = (customPayload: any) => void; - -export type SendEventForToggle = BuiltInSendEventForToggle & - CustomSendEventForToggle; - -const createSendEvent = ({ - instantSearchInstance, - helper, - attribute, - on, -}: { - instantSearchInstance: InstantSearch; - helper: AlgoliaSearchHelper; - attribute: string; - on: string[] | undefined; -}) => { - const sendEventForToggle: SendEventForToggle = (...args: any[]) => { - if (args.length === 1) { - instantSearchInstance.sendEventToInsights(args[0]); - return; - } - const [, isRefined, eventName = 'Filter Applied'] = args; - const [eventType, eventModifier] = args[0].split(':'); - if (eventType !== 'click' || on === undefined) { - return; - } - - // only send an event when the refinement gets applied, - // not when it gets removed - if (!isRefined) { - instantSearchInstance.sendEventToInsights({ - insightsMethod: 'clickedFilters', - widgetType: $$type, - eventType, - eventModifier, - payload: { - eventName, - index: helper.lastResults?.index || helper.state.index, - filters: on.map((value) => `${attribute}:${value}`), - }, - attribute, - }); - } - }; - return sendEventForToggle; -}; - -export type ToggleRefinementValue = { - /** - * Whether this option is enabled. - */ - isRefined: boolean; - /** - * Number of result if this option is toggled. - */ - count: number | null; -}; - -export type ToggleRefinementConnectorParams = { - /** - * Name of the attribute for faceting (e.g., "free_shipping"). - */ - attribute: string; - /** - * Value to filter on when toggled. - * @default "true" - */ - on?: FacetValue | FacetValue[]; - /** - * Value to filter on when not toggled. - */ - off?: FacetValue | FacetValue[]; -}; - -type FacetValue = string | boolean | number; - -export type ToggleRefinementRenderState = { - /** The current toggle value */ - value: { - /** - * The attribute name of this toggle. - */ - name: string; - /** - * Whether the current option is "on" (true) or "off" (false) - */ - isRefined: boolean; - /** - * Number of results if this option is toggled. - */ - count: number | null; - /** - * Information about the "on" toggle. - */ - onFacetValue: ToggleRefinementValue; - /** - * Information about the "off" toggle. - */ - offFacetValue: ToggleRefinementValue; - }; - /** - * Creates an URL for the next state. - */ - createURL: CreateURL; - /** - * Send a "Facet Clicked" Insights event. - */ - sendEvent: SendEventForToggle; - /** - * Indicates if search state can be refined. - */ - canRefine: boolean; - /** - * Updates to the next state by applying the toggle refinement. - */ - refine: (value?: { isRefined: boolean }) => void; -}; - -export type ToggleRefinementWidgetDescription = { - $$type: 'ais.toggleRefinement'; - renderState: ToggleRefinementRenderState; - indexRenderState: { - toggleRefinement: { - [attribute: string]: WidgetRenderState< - ToggleRefinementRenderState, - ToggleRefinementConnectorParams - >; - }; - }; - indexUiState: { - toggle: { - [attribute: string]: boolean; - }; - }; -}; - -export type ToggleRefinementConnector = Connector< - ToggleRefinementWidgetDescription, - ToggleRefinementConnectorParams ->; - -/** - * **Toggle** connector provides the logic to build a custom widget that will provide - * an on/off filtering feature based on an attribute value or values. - * - * Two modes are implemented in the custom widget: - * - with or without the value filtered - * - switch between two values. - */ -const connectToggleRefinement: ToggleRefinementConnector = - function connectToggleRefinement(renderFn, unmountFn = noop) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { attribute, on: userOn = true, off: userOff } = widgetParams || {}; - - if (!attribute) { - throw new Error(withUsage('The `attribute` option is required.')); - } - - const hasAnOffValue = userOff !== undefined; - // even though facet values can be numbers and boolean, - // the helper methods only accept string in the type - const on = toArray(userOn).map(escapeFacetValue) as string[]; - const off = hasAnOffValue - ? (toArray(userOff).map(escapeFacetValue) as string[]) - : undefined; - - let sendEvent: SendEventForToggle; - - const toggleRefinementFactory = - (helper: AlgoliaSearchHelper) => - ( - { - isRefined, - }: { - isRefined: boolean; - } = { isRefined: false } - ) => { - if (!isRefined) { - sendEvent('click:internal', isRefined); - if (hasAnOffValue) { - off!.forEach((v) => - helper.removeDisjunctiveFacetRefinement(attribute, v) - ); - } - - on.forEach((v) => - helper.addDisjunctiveFacetRefinement(attribute, v) - ); - } else { - on.forEach((v) => - helper.removeDisjunctiveFacetRefinement(attribute, v) - ); - - if (hasAnOffValue) { - off!.forEach((v) => - helper.addDisjunctiveFacetRefinement(attribute, v) - ); - } - } - - helper.search(); - }; - - const connectorState = { - createURLFactory: - ( - isRefined: boolean, - { - state, - createURL, - getWidgetUiState, - helper, - }: { - state: SearchParameters; - createURL: (InitOptions | RenderOptions)['createURL']; - getWidgetUiState: NonNullable; - helper: AlgoliaSearchHelper; - } - ) => - () => { - state = state.resetPage(); - - const valuesToRemove = isRefined ? on : off; - if (valuesToRemove) { - valuesToRemove.forEach((v) => { - state = state.removeDisjunctiveFacetRefinement(attribute, v); - }); - } - - const valuesToAdd = isRefined ? off : on; - if (valuesToAdd) { - valuesToAdd.forEach((v) => { - state = state.addDisjunctiveFacetRefinement(attribute, v); - }); - } - - return createURL((uiState) => - getWidgetUiState(uiState, { searchParameters: state, helper }) - ); - }, - }; - - return { - $$type, - - init(initOptions) { - const { instantSearchInstance } = initOptions; - - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - dispose({ state }) { - unmountFn(); - - return state.removeDisjunctiveFacet(attribute); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - toggleRefinement: { - ...renderState.toggleRefinement, - [attribute]: this.getWidgetRenderState(renderOptions), - }, - }; - }, - - getWidgetRenderState({ - state, - helper, - results, - createURL, - instantSearchInstance, - }) { - const isRefined = results - ? on.every((v) => state.isDisjunctiveFacetRefined(attribute, v)) - : on.every((v) => state.isDisjunctiveFacetRefined(attribute, v)); - - let onFacetValue: ToggleRefinementValue = { - isRefined, - count: 0, - }; - - let offFacetValue: ToggleRefinementValue = { - isRefined: hasAnOffValue && !isRefined, - count: 0, - }; - - if (results) { - const offValue = toArray(off || false); - const allFacetValues = (results.getFacetValues(attribute, {}) || - []) as SearchResults.FacetValue[]; - - const onData = on - .map((v) => - find( - allFacetValues, - ({ escapedValue }) => - escapedValue === escapeFacetValue(String(v)) - ) - ) - .filter((v): v is SearchResults.FacetValue => v !== undefined); - - const offData = hasAnOffValue - ? offValue - .map((v) => - find( - allFacetValues, - ({ escapedValue }) => - escapedValue === escapeFacetValue(String(v)) - ) - ) - .filter((v): v is SearchResults.FacetValue => v !== undefined) - : []; - - onFacetValue = { - isRefined: onData.length - ? onData.every((v) => v.isRefined) - : false, - count: onData.reduce((acc, v) => acc + v.count, 0) || null, - }; - - offFacetValue = { - isRefined: offData.length - ? offData.every((v) => v.isRefined) - : false, - count: - offData.reduce((acc, v) => acc + v.count, 0) || - allFacetValues.reduce((total, { count }) => total + count, 0), - }; - } - - if (!sendEvent) { - sendEvent = createSendEvent({ - instantSearchInstance, - attribute, - on, - helper, - }); - } - const nextRefinement = isRefined ? offFacetValue : onFacetValue; - - return { - value: { - name: attribute, - isRefined, - count: results ? nextRefinement.count : null, - onFacetValue, - offFacetValue, - }, - createURL: connectorState.createURLFactory(isRefined, { - state, - createURL, - helper, - getWidgetUiState: this.getWidgetUiState, - }), - sendEvent, - canRefine: Boolean(results ? nextRefinement.count : null), - refine: toggleRefinementFactory(helper), - widgetParams, - }; - }, - - getWidgetUiState(uiState, { searchParameters }) { - const isRefined = - on && - on.every((v) => - searchParameters.isDisjunctiveFacetRefined(attribute, v) - ); - - if (!isRefined) { - // This needs to be done in the case `uiState` comes from `createURL` - delete uiState.toggle?.[attribute]; - return uiState; - } - - return { - ...uiState, - toggle: { - ...uiState.toggle, - [attribute]: isRefined, - }, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - if ( - searchParameters.isHierarchicalFacet(attribute) || - searchParameters.isConjunctiveFacet(attribute) - ) { - warning( - false, - `ToggleRefinement: Attribute "${attribute}" is already used by another widget of a different type. -As this is not supported, please make sure to remove this other widget or this ToggleRefinement widget will not work at all.` - ); - - return searchParameters; - } - - let withFacetConfiguration = searchParameters - .addDisjunctiveFacet(attribute) - .removeDisjunctiveFacetRefinement(attribute); - - const isRefined = Boolean( - uiState.toggle && uiState.toggle[attribute] - ); - - if (isRefined) { - if (on) { - on.forEach((v) => { - withFacetConfiguration = - withFacetConfiguration.addDisjunctiveFacetRefinement( - attribute, - v - ); - }); - } - - return withFacetConfiguration; - } - - // It's not refined with an `off` value - if (hasAnOffValue) { - if (off) { - off.forEach((v) => { - withFacetConfiguration = - withFacetConfiguration.addDisjunctiveFacetRefinement( - attribute, - v - ); - }); - } - return withFacetConfiguration; - } - - // It's not refined without an `off` value - return withFacetConfiguration.setQueryParameters({ - disjunctiveFacetsRefinements: { - ...searchParameters.disjunctiveFacetsRefinements, - [attribute]: [], - }, - }); - }, - }; - }; - }; - -export default connectToggleRefinement; +export { connectToggleRefinement as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/trending-facets/connectTrendingFacets.ts b/packages/instantsearch.js/src/connectors/trending-facets/connectTrendingFacets.ts index c3086784a2f..59e83c72998 100644 --- a/packages/instantsearch.js/src/connectors/trending-facets/connectTrendingFacets.ts +++ b/packages/instantsearch.js/src/connectors/trending-facets/connectTrendingFacets.ts @@ -1,205 +1,2 @@ -import { - createDocumentationMessageGenerator, - checkRendering, - noop, - TAG_PLACEHOLDER, -} from '../../lib/utils'; -import { escape } from '../../lib/utils/escape-html'; - -import type { - Connector, - TransformItems, - Renderer, - Unmounter, - UnknownWidgetParams, - RecommendResponse, -} from '../../types'; -import type { TrendingFacetItem } from '../../types/recommend'; -import type { PlainSearchParameters } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'trending-facets', - connector: true, -}); - -export type TrendingFacetsRenderState = { - /** - * The trending facet values from the Algolia Recommend API. - */ - items: TrendingFacetItem[]; -}; - -export type TrendingFacetsConnectorParams = { - /** - * The facet attribute to get trending values for. - */ - facetName: string; - /** - * The number of recommendations to retrieve. - */ - limit?: 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' - >; - /** - * Whether to escape HTML tags from items string values. - * - * @default true - */ - escapeHTML?: boolean; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems< - TrendingFacetItem, - { results: RecommendResponse } - >; -}; - -export type TrendingFacetsWidgetDescription = { - $$type: 'ais.trendingFacets'; - renderState: TrendingFacetsRenderState; -}; - -export type TrendingFacetsConnector = Connector< - TrendingFacetsWidgetDescription, - TrendingFacetsConnectorParams ->; - -export default (function connectTrendingFacets< - TWidgetParams extends UnknownWidgetParams ->( - renderFn: Renderer< - TrendingFacetsRenderState, - TWidgetParams & TrendingFacetsConnectorParams - >, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams: TWidgetParams & TrendingFacetsConnectorParams) => { - const { - facetName, - limit, - threshold, - fallbackParameters, - queryParameters, - // @MAJOR: this can default to false - escapeHTML = true, - transformItems = ((items) => items) as NonNullable< - TrendingFacetsConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if (!facetName) { - throw new Error( - withUsage('The `facetName` option is required.') - ); - } - - return { - dependsOn: 'recommend', - $$type: 'ais.trendingFacets', - - 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, helper, instantSearchInstance }) { - void helper; - void instantSearchInstance; - - if (results === null || results === undefined) { - return { items: [], widgetParams }; - } - - let items: TrendingFacetItem[] = ( - (results as RecommendResponse).hits || [] - ).map((hit: any) => ({ - facetName: hit.facetName as string, - facetValue: hit.facetValue as string, - _score: hit._score as number, - })); - - if (escapeHTML) { - items = items.map((item) => ({ - ...item, - facetValue: escape(item.facetValue), - })); - } - - items = transformItems(items, { - results: results as RecommendResponse, - }); - - return { - items, - widgetParams, - }; - }, - - dispose({ recommendState }) { - unmountFn(); - return recommendState.removeParams(this.$$id!); - }, - - getWidgetParameters(state) { - // v4 TrendingFacetsQuery doesn't include queryParameters or - // fallbackParameters, but the v5 API and the helper support them. - return state.removeParams(this.$$id!).addTrendingFacets({ - facetName, - maxRecommendations: limit, - threshold, - fallbackParameters: fallbackParameters - ? { - ...fallbackParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - } - : undefined, - queryParameters: { - ...queryParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - }, - $$id: this.$$id!, - } as any); - }, - }; - }; -} satisfies TrendingFacetsConnector); +export { connectTrendingFacets as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/trending-items/connectTrendingItems.ts b/packages/instantsearch.js/src/connectors/trending-items/connectTrendingItems.ts index a2548fb767e..c27fa99e8e9 100644 --- a/packages/instantsearch.js/src/connectors/trending-items/connectTrendingItems.ts +++ b/packages/instantsearch.js/src/connectors/trending-items/connectTrendingItems.ts @@ -1,253 +1,2 @@ -import { - createDocumentationMessageGenerator, - checkRendering, - noop, - escapeHits, - TAG_PLACEHOLDER, - getObjectType, - createSendEventForHits, - addAbsolutePosition, - addQueryID, -} from '../../lib/utils'; - -import type { SendEventForHits } from '../../lib/utils'; -import type { - Connector, - TransformItems, - BaseHit, - Renderer, - Unmounter, - UnknownWidgetParams, - RecommendResponse, - Hit, - AlgoliaHit, -} from '../../types'; -import type { PlainSearchParameters } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'trending-items', - connector: true, -}); - -export type TrendingItemsRenderState< - THit extends NonNullable = BaseHit -> = { - /** - * The matched recommendations from the Algolia API. - */ - items: Array>; - - /** - * Sends an event to the Insights middleware. - */ - sendEvent: SendEventForHits; -}; - -export type TrendingItemsConnectorParams< - THit extends NonNullable = BaseHit -> = ( - | { - /** - * The facet attribute to get recommendations for. - */ - facetName: string; - /** - * The facet value to get recommendations for. - */ - facetValue: string; - } - | { - facetName?: string; - facetValue?: string; - } -) & { - /** - * The number of recommendations to retrieve. - */ - limit?: 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' - >; - /** - * Whether to escape HTML tags from items string values. - * - * @default true - */ - escapeHTML?: boolean; - /** - * Function to transform the items passed to the templates. - */ - transformItems?: TransformItems< - Hit, - { results: RecommendResponse> } - >; -}; - -export type TrendingItemsWidgetDescription< - THit extends NonNullable = BaseHit -> = { - $$type: 'ais.trendingItems'; - renderState: TrendingItemsRenderState; -}; - -export type TrendingItemsConnector = BaseHit> = - Connector< - TrendingItemsWidgetDescription, - TrendingItemsConnectorParams - >; - -export default (function connectTrendingItems< - TWidgetParams extends UnknownWidgetParams ->( - renderFn: Renderer< - TrendingItemsRenderState, - TWidgetParams & TrendingItemsConnectorParams - >, - unmountFn: Unmounter = noop -) { - checkRendering(renderFn, withUsage()); - - return = BaseHit>( - widgetParams: TWidgetParams & TrendingItemsConnectorParams - ) => { - const { - facetName, - facetValue, - limit, - threshold, - fallbackParameters, - queryParameters, - // @MAJOR: this can default to false - escapeHTML = true, - transformItems = ((items) => items) as NonNullable< - TrendingItemsConnectorParams['transformItems'] - >, - } = widgetParams || {}; - - if ((facetName && !facetValue) || (!facetName && facetValue)) { - throw new Error( - withUsage( - `When you provide facetName (received type ${getObjectType( - facetName - )}), you must also provide facetValue (received type ${getObjectType( - facetValue - )}).` - ) - ); - } - - let sendEvent: SendEventForHits; - - 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, helper, instantSearchInstance }) { - if (!sendEvent) { - sendEvent = createSendEventForHits({ - instantSearchInstance, - helper, - widgetType: this.$$type, - }); - } - if (results === null || results === undefined) { - return { items: [], widgetParams, sendEvent }; - } - - if (escapeHTML && results.hits.length > 0) { - results.hits = escapeHits(results.hits); - } - - const itemsWithAbsolutePosition = addAbsolutePosition( - results.hits, - 0, - 1 - ); - - const itemsWithAbsolutePositionAndQueryID = addQueryID( - itemsWithAbsolutePosition, - results.queryID - ); - - const transformedItems = transformItems( - itemsWithAbsolutePositionAndQueryID, - { - results: results as RecommendResponse>, - } - ); - - return { - items: transformedItems, - widgetParams, - sendEvent, - }; - }, - - dispose({ recommendState }) { - unmountFn(); - return recommendState.removeParams(this.$$id!); - }, - - getWidgetParameters(state) { - return state.removeParams(this.$$id!).addTrendingItems({ - facetName: facetName as string, - facetValue: facetValue as string, - maxRecommendations: limit, - threshold, - fallbackParameters: fallbackParameters - ? { - ...fallbackParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - } - : undefined, - queryParameters: { - ...queryParameters, - ...(escapeHTML ? TAG_PLACEHOLDER : {}), - }, - $$id: this.$$id!, - }); - }, - }; - }; -} satisfies TrendingItemsConnector); +export { connectTrendingItems as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/connectors/voice-search/__tests__/connectVoiceSearch-test.ts b/packages/instantsearch.js/src/connectors/voice-search/__tests__/connectVoiceSearch-test.ts index f0456a0e615..532c767aea2 100644 --- a/packages/instantsearch.js/src/connectors/voice-search/__tests__/connectVoiceSearch-test.ts +++ b/packages/instantsearch.js/src/connectors/voice-search/__tests__/connectVoiceSearch-test.ts @@ -15,9 +15,9 @@ import connectVoiceSearch from '../connectVoiceSearch'; import type { VoiceSearchHelperParams, VoiceSearchHelper, -} from '../../../lib/voiceSearchHelper/types'; +} from '../../../../../instantsearch-core/src/lib/voiceSearchHelper/types'; -jest.mock('../../../lib/voiceSearchHelper', () => { +jest.mock('../../../../../instantsearch-core/src/lib/voiceSearchHelper', () => { const createVoiceHelper = ({ onStateChange, onQueryChange, diff --git a/packages/instantsearch.js/src/connectors/voice-search/connectVoiceSearch.ts b/packages/instantsearch.js/src/connectors/voice-search/connectVoiceSearch.ts index ad252d07f79..329073b23fa 100644 --- a/packages/instantsearch.js/src/connectors/voice-search/connectVoiceSearch.ts +++ b/packages/instantsearch.js/src/connectors/voice-search/connectVoiceSearch.ts @@ -1,222 +1,2 @@ -import { - checkRendering, - createDocumentationMessageGenerator, - noop, -} from '../../lib/utils'; -import builtInCreateVoiceSearchHelper from '../../lib/voiceSearchHelper'; - -import type { - CreateVoiceSearchHelper, - VoiceListeningState, -} from '../../lib/voiceSearchHelper/types'; -import type { Connector, WidgetRenderState } from '../../types'; -import type { PlainSearchParameters } from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'voice-search', - connector: true, -}); - -export type VoiceSearchConnectorParams = { - searchAsYouSpeak?: boolean; - language?: string; - additionalQueryParameters?: (params: { - query: string; - }) => PlainSearchParameters | void; - createVoiceSearchHelper?: CreateVoiceSearchHelper; -}; - -export type VoiceSearchRenderState = { - isBrowserSupported: boolean; - isListening: boolean; - toggleListening: () => void; - voiceListeningState: VoiceListeningState; -}; - -export type VoiceSearchWidgetDescription = { - $$type: 'ais.voiceSearch'; - renderState: VoiceSearchRenderState; - indexRenderState: { - voiceSearch: WidgetRenderState< - VoiceSearchRenderState, - VoiceSearchConnectorParams - >; - }; - indexUiState: { - query: string; - }; -}; - -export type VoiceSearchConnector = Connector< - VoiceSearchWidgetDescription, - VoiceSearchConnectorParams ->; - -const connectVoiceSearch: VoiceSearchConnector = function connectVoiceSearch( - renderFn, - unmountFn = noop -) { - checkRendering(renderFn, withUsage()); - - return (widgetParams) => { - const { - searchAsYouSpeak = false, - language, - additionalQueryParameters, - createVoiceSearchHelper = builtInCreateVoiceSearchHelper, - } = widgetParams; - - return { - $$type: 'ais.voiceSearch', - - init(initOptions) { - const { instantSearchInstance } = initOptions; - renderFn( - { - ...this.getWidgetRenderState(initOptions), - instantSearchInstance, - }, - true - ); - }, - - render(renderOptions) { - const { instantSearchInstance } = renderOptions; - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - - getRenderState(renderState, renderOptions) { - return { - ...renderState, - voiceSearch: this.getWidgetRenderState(renderOptions), - }; - }, - - getWidgetRenderState(renderOptions) { - const { helper, instantSearchInstance } = renderOptions; - if (!(this as any)._refine) { - (this as any)._refine = (query: string): void => { - if (query !== helper.state.query) { - const queryLanguages = language - ? [language.split('-')[0]] - : undefined; - // @ts-ignore queryLanguages is allowed to be a string, not just an array - helper.setQueryParameter('queryLanguages', queryLanguages); - - if (typeof additionalQueryParameters === 'function') { - helper.setState( - helper.state.setQueryParameters({ - ignorePlurals: true, - removeStopWords: true, - // @ts-ignore optionalWords is allowed to be a string too - optionalWords: query, - ...additionalQueryParameters({ query }), - }) - ); - } - - helper.setQuery(query).search(); - } - }; - } - - if (!(this as any)._voiceSearchHelper) { - (this as any)._voiceSearchHelper = createVoiceSearchHelper({ - searchAsYouSpeak, - language, - onQueryChange: (query) => (this as any)._refine(query), - onStateChange: () => { - renderFn( - { - ...this.getWidgetRenderState(renderOptions), - instantSearchInstance, - }, - false - ); - }, - }); - } - - const { - isBrowserSupported, - isListening, - startListening, - stopListening, - getState, - } = (this as any)._voiceSearchHelper; - - return { - isBrowserSupported: isBrowserSupported(), - isListening: isListening(), - toggleListening() { - if (!isBrowserSupported()) { - return; - } - if (isListening()) { - stopListening(); - } else { - startListening(); - } - }, - voiceListeningState: getState(), - widgetParams, - }; - }, - - dispose({ state }) { - (this as any)._voiceSearchHelper.dispose(); - - unmountFn(); - - let newState = state; - if (typeof additionalQueryParameters === 'function') { - const additional = additionalQueryParameters({ query: '' }); - const toReset = additional - ? ( - Object.keys(additional) as Array - ).reduce((acc, current) => { - // @ts-ignore search parameters is typed as readonly in v4 - acc[current] = undefined; - return acc; - }, {}) - : {}; - newState = state.setQueryParameters({ - // @ts-ignore (queryLanguages is not added to algoliasearch v3) - queryLanguages: undefined, - ignorePlurals: undefined, - removeStopWords: undefined, - optionalWords: undefined, - ...toReset, - }); - } - - return newState.setQueryParameter('query', undefined); - }, - - getWidgetUiState(uiState, { searchParameters }) { - const query = searchParameters.query || ''; - - if (!query) { - return uiState; - } - - return { - ...uiState, - query, - }; - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - return searchParameters.setQueryParameter('query', uiState.query || ''); - }, - }; - }; -}; - -export default connectVoiceSearch; +export { connectVoiceSearch as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 19baddd252a..17415acf0e2 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -1,16 +1,12 @@ import EventEmitter from '@algolia/events'; import algoliasearchHelper from 'algoliasearch-helper'; -import { createInsightsMiddleware } from '../middlewares/createInsightsMiddleware'; import { + createInsightsMiddleware, createMetadataMiddleware, + createRouterMiddleware, isMetadataEnabled, -} from '../middlewares/createMetadataMiddleware'; -import { createRouterMiddleware } from '../middlewares/createRouterMiddleware'; -import index from '../widgets/index/index'; - -import createHelpers from './createHelpers'; -import { + index as indexWidget, createDocumentationMessageGenerator, createDocumentationLink, defer, @@ -20,15 +16,15 @@ import { warning, setIndexHelperState, isIndexWidget, -} from './utils'; +} from 'instantsearch-core'; + +import createHelpers from './createHelpers'; import version from './version'; import type { InsightsEvent, InsightsProps, -} from '../middlewares/createInsightsMiddleware'; -import type { RouterProps } from '../middlewares/createRouterMiddleware'; -import type { + RouterProps, InsightsClient as AlgoliaInsightsClient, SearchClient, Widget, @@ -40,7 +36,7 @@ import type { RenderState, InitialResults, CompositionClient, -} from '../types'; +} from 'instantsearch-core'; import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; const withUsage = createDocumentationMessageGenerator({ @@ -356,7 +352,7 @@ See documentation: ${createDocumentationLink({ this.compositionID = compositionID; this.helper = null; this.mainHelper = null; - this.mainIndex = index({ + this.mainIndex = indexWidget({ // we use an index widget to render compositions // this only works because there's only one composition index allow for now indexName: this.compositionID || this.indexName, diff --git a/packages/instantsearch.js/src/lib/__tests__/instantsearch-core-interface.test.ts b/packages/instantsearch.js/src/lib/__tests__/instantsearch-core-interface.test.ts new file mode 100644 index 00000000000..67e8e574b1e --- /dev/null +++ b/packages/instantsearch.js/src/lib/__tests__/instantsearch-core-interface.test.ts @@ -0,0 +1,22 @@ +import type { InstantSearch as InstantSearchInterface } from 'instantsearch-core'; + +import InstantSearch from '../InstantSearch'; + +type InstantSearchInstance = InstanceType; + +/** + * Compile-time guard: the runtime class must structurally satisfy the + * `InstantSearch` interface exported from `instantsearch-core`. + */ +type InstantSearchSatisfiesCore = InstantSearchInstance extends InstantSearchInterface< + any, + any +> + ? true + : false; + +const _instantSearchSatisfiesCoreInterface: InstantSearchSatisfiesCore = true; + +test('InstantSearch runtime class satisfies instantsearch-core InstantSearch interface', () => { + expect(_instantSearchSatisfiesCoreInterface).toBe(true); +}); diff --git a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts index ce2d34beba4..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/abstract-chat.ts @@ -1,1196 +1 @@ -/* eslint-disable @typescript-eslint/consistent-type-assertions */ -import { processStream } from './stream-parser'; -import { generateId as defaultGenerateId, SerialJobExecutor } from './utils'; - -import type { - ChatInit, - ChatRequestOptions, - ChatState, - ChatStatus, - ChatTransport, - CreateUIMessage, - FileUIPart, - IdGenerator, - InferUIMessageChunk, - InferUIMessageMetadata, - InferUIMessageToolCall, - InferUIMessageTools, - UIMessage, - UIMessageChunk, - ChatOnErrorCallback, - ChatOnToolCallCallback, - ChatOnFinishCallback, - ChatOnDataCallback, -} from './types'; - -type ActiveResponse = { - abortController: AbortController; - stream?: ReadableStream; -}; - -const tryParseJson = (value: string): unknown | undefined => { - try { - return JSON.parse(value); - } catch { - return undefined; - } -}; - -const repairPartialJson = (value: string): string => { - let repaired = value.trim(); - - if (!repaired) { - return repaired; - } - - let inString = false; - let isEscaped = false; - const stack: Array<'{' | '['> = []; - - for (let index = 0; index < repaired.length; index++) { - const char = repaired[index]; - if (inString) { - if (isEscaped) { - isEscaped = false; - } else if (char === '\\') { - isEscaped = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === '{' || char === '[') { - stack.push(char); - continue; - } - - if (char === '}' && stack[stack.length - 1] === '{') { - stack.pop(); - continue; - } - - if (char === ']' && stack[stack.length - 1] === '[') { - stack.pop(); - } - } - - if (inString && !isEscaped) { - repaired += '"'; - } - - repaired = repaired.replace(/,\s*$/u, ''); - - if (stack.length > 0) { - repaired += stack - .reverse() - .map((opening) => (opening === '{' ? '}' : ']')) - .join(''); - } - - return repaired.replace(/,\s*([}\]])/gu, '$1'); -}; - -const parseToolInputDelta = ( - accumulatedRawInput: string, - fallbackInput: unknown -): unknown => { - const normalized = accumulatedRawInput.trim(); - if (!normalized) { - return fallbackInput; - } - - const directParsed = tryParseJson(normalized); - if (directParsed !== undefined) { - return directParsed; - } - - const repairedParsed = tryParseJson(repairPartialJson(normalized)); - if (repairedParsed !== undefined) { - return repairedParsed; - } - - return fallbackInput; -}; - -/** - * Abstract base class for chat implementations. - */ -export abstract class AbstractChat { - readonly id: string; - readonly generateId: IdGenerator; - protected state: ChatState; - - private readonly transport?: ChatTransport; - private onError?: ChatOnErrorCallback; - private onToolCall?: ChatOnToolCallCallback; - private onFinish?: ChatOnFinishCallback; - private onData?: ChatOnDataCallback; - private sendAutomaticallyWhen?: (options: { - messages: TUIMessage[]; - }) => boolean | PromiseLike; - private shouldRepairToolInput?: (toolName: string) => boolean; - - private activeResponse: ActiveResponse< - InferUIMessageChunk - > | null = null; - private jobExecutor = new SerialJobExecutor(); - - constructor({ - generateId = defaultGenerateId, - id = generateId(), - transport, - state, - onError, - onToolCall, - onFinish, - onData, - sendAutomaticallyWhen, - shouldRepairToolInput, - }: Omit, 'messages'> & { - state: ChatState; - }) { - this.id = id; - this.generateId = generateId; - this.state = state; - this.transport = transport; - this.onError = onError; - this.onToolCall = onToolCall; - this.onFinish = onFinish; - this.onData = onData; - this.sendAutomaticallyWhen = sendAutomaticallyWhen; - this.shouldRepairToolInput = shouldRepairToolInput; - } - - /** - * Hook status: - * - * - `submitted`: The message has been sent to the API and we're awaiting the start of the response stream. - * - `streaming`: The response is actively streaming in from the API, receiving chunks of data. - * - `ready`: The full response has been received and processed; a new user message can be submitted. - * - `error`: An error occurred during the API request, preventing successful completion. - */ - get status(): ChatStatus { - return this.state.status; - } - - protected setStatus({ - status, - error, - }: { - status: ChatStatus; - error?: Error; - }): void { - this.state.status = status; - if (error !== undefined) { - this.state.error = error; - } - } - - get error(): Error | undefined { - return this.state.error; - } - - get messages(): TUIMessage[] { - return this.state.messages; - } - - set messages(messages: TUIMessage[]) { - this.state.messages = messages; - } - - get lastMessage(): TUIMessage | undefined { - return this.state.messages[this.state.messages.length - 1]; - } - - /** - * Appends or replaces a user message to the chat list. This triggers the API call to fetch - * the assistant's response. - */ - sendMessage = ( - message?: - | (CreateUIMessage & { - text?: never; - files?: never; - messageId?: string; - }) - | { - text: string; - files?: FileList | FileUIPart[]; - metadata?: InferUIMessageMetadata; - parts?: never; - messageId?: string; - } - | { - files: FileList | FileUIPart[]; - metadata?: InferUIMessageMetadata; - parts?: never; - messageId?: string; - }, - options?: ChatRequestOptions - ): Promise => { - return this.jobExecutor.run(() => { - // Build the user message - let userMessagePromise: Promise; - - if (message) { - const messageId = message.messageId || this.generateId(); - - if ('parts' in message && message.parts) { - // Full message with parts provided - userMessagePromise = Promise.resolve({ - id: messageId, - role: 'user', - ...message, - } as TUIMessage); - } else if ('text' in message && message.text) { - // Build from text - const parts: TUIMessage['parts'] = [ - { type: 'text', text: message.text }, - ]; - - // Add file parts if provided - if (message.files) { - userMessagePromise = this.convertFilesToParts(message.files).then( - (fileParts) => { - parts.push(...fileParts); - return { - id: messageId, - role: 'user', - parts, - metadata: message.metadata, - } as TUIMessage; - } - ); - } else { - userMessagePromise = Promise.resolve({ - id: messageId, - role: 'user', - parts, - metadata: message.metadata, - } as TUIMessage); - } - } else if ('files' in message && message.files) { - // Files only - userMessagePromise = this.convertFilesToParts(message.files).then( - (fileParts) => - ({ - id: messageId, - role: 'user', - parts: fileParts, - metadata: message.metadata, - } as TUIMessage) - ); - } else { - userMessagePromise = Promise.resolve(undefined); - } - } else { - userMessagePromise = Promise.resolve(undefined); - } - - return userMessagePromise.then((userMessage) => { - if (userMessage) { - this.state.pushMessage(userMessage); - } - - return this.makeRequest({ - trigger: 'submit-message', - messageId: userMessage?.id, - ...options, - }); - }); - }); - }; - - /** - * Regenerate the assistant message with the provided message id. - * If no message id is provided, the last assistant message will be regenerated. - */ - regenerate = ({ - messageId, - ...options - }: { messageId?: string } & ChatRequestOptions = {}): Promise => { - return this.jobExecutor.run(() => { - // Find the message to regenerate from - let targetIndex = -1; - - if (messageId) { - targetIndex = this.state.messages.findIndex((m) => m.id === messageId); - } else { - // Find the last assistant message - for (let i = this.state.messages.length - 1; i >= 0; i--) { - if (this.state.messages[i].role === 'assistant') { - targetIndex = i; - break; - } - } - } - - if (targetIndex >= 0) { - // Remove the assistant message and all messages after it - this.state.messages = this.state.messages.slice(0, targetIndex); - } - - return this.makeRequest({ - trigger: 'regenerate-message', - messageId, - ...options, - }); - }); - }; - - /** - * Attempt to resume an ongoing streaming response. - */ - resumeStream = (options?: ChatRequestOptions): Promise => { - return this.jobExecutor.run(() => { - if (!this.transport) { - return Promise.reject( - new Error( - 'Transport is required for resuming stream. Please provide a transport when initializing the chat.' - ) - ); - } - - this.setStatus({ status: 'submitted' }); - - return this.transport - .reconnectToStream({ - chatId: this.id, - ...options, - }) - .then( - (stream) => { - if (stream) { - return this.processStreamWithCallbacks(stream); - } else { - this.setStatus({ status: 'ready' }); - return Promise.resolve(); - } - }, - (error) => { - this.handleError(error as Error); - return Promise.resolve(); - } - ); - }); - }; - - /** - * Clear the error state and set the status to ready if the chat is in an error state. - */ - clearError = (): void => { - if (this.state.status === 'error') { - this.setStatus({ status: 'ready', error: undefined }); - } - }; - - /** - * Add a tool result for a tool call. - */ - addToolResult = >({ - tool, - toolCallId, - output, - }: { - tool: TTool; - toolCallId: string; - output: InferUIMessageTools[TTool]['output']; - }): Promise => { - return this.jobExecutor.run(() => { - // Find the message with this tool call - const messageIndex = this.state.messages.findIndex( - (m) => - m.parts?.some( - (p) => - ('toolCallId' in p && p.toolCallId === toolCallId) || - ('type' in p && p.type === `tool-${String(tool)}`) - ) ?? false - ); - - if (messageIndex === -1) return Promise.resolve(); - - const message = this.state.messages[messageIndex]; - const updatedParts = message.parts.map((part) => { - if ( - 'toolCallId' in part && - part.toolCallId === toolCallId && - 'state' in part - ) { - return { - ...part, - state: 'output-available' as const, - output, - }; - } - return part; - }); - - this.state.replaceMessage(messageIndex, { - ...message, - parts: updatedParts, - } as TUIMessage); - - // Check if we should auto-send based on sendAutomaticallyWhen - if (this.sendAutomaticallyWhen) { - return Promise.resolve( - this.sendAutomaticallyWhen({ - messages: this.state.messages, - }) - ).then((shouldSend) => { - if (shouldSend) { - return this.makeRequest({ - trigger: 'submit-message', - }); - } - return Promise.resolve(); - }); - } - - return Promise.resolve(); - }); - }; - - /** - * Abort the current request immediately, keep the generated tokens if any. - */ - stop = (): Promise => { - if (this.activeResponse) { - this.activeResponse.abortController.abort(); - this.activeResponse = null; - } - this.setStatus({ status: 'ready' }); - return Promise.resolve(); - }; - - private makeRequest( - options: { - trigger: 'submit-message' | 'regenerate-message'; - messageId?: string; - } & ChatRequestOptions - ): Promise { - if (!this.transport) { - return Promise.reject( - new Error( - 'Transport is required for sending messages. Please provide a transport when initializing the chat.' - ) - ); - } - - // Abort any existing request - if (this.activeResponse) { - this.activeResponse.abortController.abort(); - } - - const abortController = new AbortController(); - this.activeResponse = { abortController }; - - this.setStatus({ status: 'submitted' }); - - return this.transport - .sendMessages({ - chatId: this.id, - messages: this.state.messages, - abortSignal: abortController.signal, - trigger: options.trigger, - messageId: options.messageId, - headers: options.headers, - body: options.body, - requestMetadata: options.metadata, - }) - .then( - (stream) => { - this.activeResponse!.stream = stream; - return this.processStreamWithCallbacks(stream); - }, - (error) => { - if ((error as Error).name === 'AbortError') { - // Request was aborted, don't treat as error - return Promise.resolve(); - } - this.handleError(error as Error); - return Promise.resolve(); - } - ); - } - - private processStreamWithCallbacks( - stream: ReadableStream> - ): Promise { - this.setStatus({ status: 'streaming' }); - - let currentMessageId: string | undefined; - let currentMessage: TUIMessage | undefined; - let currentMessageIndex = -1; - let isAbort = false; - let isDisconnect = false; - let isError = false; - - // Track current text/reasoning part state - let currentTextPartId: string | undefined; - let currentReasoningPartId: string | undefined; - const toolRawInputByCallId: Record = {}; - const toolRawOutputByCallId: Record = {}; - - // Promise chain for handling tool calls that return promises - let pendingToolCall: Promise = Promise.resolve(); - - return new Promise((resolve) => { - processStream( - stream as ReadableStream, - // eslint-disable-next-line complexity - (chunk) => { - switch (chunk.type) { - case 'start': { - currentMessageId = chunk.messageId || this.generateId(); - - // Check if we're continuing an existing message or creating a new one - const lastMessage = this.lastMessage; - if ( - lastMessage && - lastMessage.role === 'assistant' && - lastMessage.id === currentMessageId - ) { - currentMessage = lastMessage; - currentMessageIndex = this.state.messages.length - 1; - } else { - currentMessage = { - id: currentMessageId, - role: 'assistant', - parts: [], - metadata: chunk.messageMetadata, - } as unknown as TUIMessage; - this.state.pushMessage(currentMessage); - currentMessageIndex = this.state.messages.length - 1; - } - break; - } - - case 'text-start': { - if (!currentMessage) break; - currentTextPartId = chunk.id; - - const textPart = { - type: 'text' as const, - text: '', - state: 'streaming' as const, - providerMetadata: chunk.providerMetadata, - }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, textPart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'text-delta': { - if (!currentMessage || !currentTextPartId) break; - - const partIndex = currentMessage.parts.findIndex( - (p) => p.type === 'text' && p.state === 'streaming' - ); - if (partIndex === -1) break; - - const updatedParts = [...currentMessage.parts]; - const textPart = updatedParts[partIndex] as { - type: 'text'; - text: string; - state?: 'streaming' | 'done'; - }; - updatedParts[partIndex] = { - ...textPart, - text: textPart.text + chunk.delta, - }; - - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'text-end': { - if (!currentMessage) break; - - const partIndex = currentMessage.parts.findIndex( - (p) => p.type === 'text' && p.state === 'streaming' - ); - if (partIndex === -1) break; - - const updatedParts = [...currentMessage.parts]; - const textPart = updatedParts[partIndex] as { - type: 'text'; - text: string; - state?: 'streaming' | 'done'; - }; - updatedParts[partIndex] = { - ...textPart, - state: 'done' as const, - }; - - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - currentTextPartId = undefined; - break; - } - - case 'reasoning-start': { - if (!currentMessage) break; - currentReasoningPartId = chunk.id; - - const reasoningPart = { - type: 'reasoning' as const, - text: '', - state: 'streaming' as const, - providerMetadata: chunk.providerMetadata, - }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, reasoningPart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'reasoning-delta': { - if (!currentMessage || !currentReasoningPartId) break; - - const partIndex = currentMessage.parts.findIndex( - (p) => p.type === 'reasoning' && p.state === 'streaming' - ); - if (partIndex === -1) break; - - const updatedParts = [...currentMessage.parts]; - const reasoningPart = updatedParts[partIndex] as { - type: 'reasoning'; - text: string; - state?: 'streaming' | 'done'; - }; - updatedParts[partIndex] = { - ...reasoningPart, - text: reasoningPart.text + chunk.delta, - }; - - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'reasoning-end': { - if (!currentMessage) break; - - const partIndex = currentMessage.parts.findIndex( - (p) => p.type === 'reasoning' && p.state === 'streaming' - ); - if (partIndex === -1) break; - - const updatedParts = [...currentMessage.parts]; - const reasoningPart = updatedParts[partIndex] as { - type: 'reasoning'; - text: string; - state?: 'streaming' | 'done'; - }; - updatedParts[partIndex] = { - ...reasoningPart, - state: 'done' as const, - }; - - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - currentReasoningPartId = undefined; - break; - } - - case 'tool-input-start': { - if (!currentMessage) break; - - const initialRawInput = - typeof chunk.input === 'string' - ? chunk.input - : chunk.input !== undefined - ? JSON.stringify(chunk.input) - : ''; - - toolRawInputByCallId[chunk.toolCallId] = initialRawInput; - - const toolPart = { - type: `tool-${chunk.toolName}` as const, - toolCallId: chunk.toolCallId, - state: 'input-streaming' as const, - input: chunk.input, - rawInput: initialRawInput || undefined, - providerExecuted: chunk.providerExecuted, - }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, toolPart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'tool-input-delta': { - if (!currentMessage) break; - - const toolIndex = currentMessage.parts.findIndex( - (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId - ); - - const existingPart = - toolIndex >= 0 - ? (currentMessage.parts[toolIndex] as any) - : null; - const previousRawInput = - existingPart?.rawInput ?? - toolRawInputByCallId[chunk.toolCallId] ?? - ''; - const nextRawInput = `${previousRawInput}${chunk.inputTextDelta}`; - toolRawInputByCallId[chunk.toolCallId] = nextRawInput; - - const toolName = - chunk.toolName ?? existingPart?.type?.replace('tool-', ''); - const shouldRepair = toolName - ? this.shouldRepairToolInput?.(toolName) ?? true - : true; - const parsedInput = shouldRepair - ? parseToolInputDelta(nextRawInput, existingPart?.input) - : existingPart?.input; - - const nextToolPart = { - ...(existingPart ?? { - type: `tool-${chunk.toolName}` as const, - toolCallId: chunk.toolCallId, - }), - state: 'input-streaming' as const, - input: parsedInput, - rawInput: nextRawInput, - }; - - if (toolIndex >= 0) { - const updatedParts = [...currentMessage.parts]; - updatedParts[toolIndex] = nextToolPart; - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - } else { - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, nextToolPart], - } as TUIMessage; - } - - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'tool-input-available': { - if (!currentMessage) break; - - delete toolRawInputByCallId[chunk.toolCallId]; - - // Find existing tool part or create new one - const existingIndex = currentMessage.parts.findIndex( - (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId - ); - - const toolPart = { - type: `tool-${chunk.toolName}` as const, - toolCallId: chunk.toolCallId, - state: 'input-available' as const, - input: chunk.input, - callProviderMetadata: chunk.callProviderMetadata, - providerExecuted: chunk.providerExecuted, - }; - - if (existingIndex >= 0) { - const updatedParts = [...currentMessage.parts]; - updatedParts[existingIndex] = toolPart; - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - } else { - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, toolPart], - } as TUIMessage; - } - this.state.replaceMessage(currentMessageIndex, currentMessage); - - // Trigger onToolCall callback only for client-executed tools - // (server-executed tools have providerExecuted: true and don't need client handling) - if (this.onToolCall && !chunk.providerExecuted) { - const result = this.onToolCall({ - toolCall: { - toolName: chunk.toolName, - toolCallId: chunk.toolCallId, - input: chunk.input, - dynamic: 'dynamic' in chunk ? chunk.dynamic : undefined, - } as InferUIMessageToolCall, - }); - if (result && typeof result.then === 'function') { - pendingToolCall = pendingToolCall.then(() => result); - } - } - break; - } - - case 'data-tool-output-delta': { - if (!currentMessage) break; - - const { toolCallId, toolName, delta } = chunk.data as { - toolCallId: string; - toolName: string; - delta: string; - }; - - const toolIndex = currentMessage.parts.findIndex( - (p) => 'toolCallId' in p && p.toolCallId === toolCallId - ); - - const existingPart = - toolIndex >= 0 - ? (currentMessage.parts[toolIndex] as any) - : null; - const previousRawOutput = - existingPart?.rawOutput ?? - toolRawOutputByCallId[toolCallId] ?? - ''; - const nextRawOutput = `${previousRawOutput}${delta}`; - toolRawOutputByCallId[toolCallId] = nextRawOutput; - - const parsedOutput = parseToolInputDelta( - nextRawOutput, - existingPart?.output - ); - - const nextToolPart = { - ...(existingPart ?? { - type: `tool-${toolName}` as const, - toolCallId, - input: undefined, - }), - state: 'output-available' as const, - output: parsedOutput, - rawOutput: nextRawOutput, - preliminary: true, - }; - - if (toolIndex >= 0) { - const updatedParts = [...currentMessage.parts]; - updatedParts[toolIndex] = nextToolPart; - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - } else { - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, nextToolPart], - } as TUIMessage; - } - - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'tool-output-available': { - if (!currentMessage) break; - - const toolIndex = currentMessage.parts.findIndex( - (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId - ); - - if (toolIndex >= 0) { - delete toolRawInputByCallId[chunk.toolCallId]; - delete toolRawOutputByCallId[chunk.toolCallId]; - - const updatedParts = [...currentMessage.parts]; - const existingPart = updatedParts[toolIndex] as any; - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - const { rawOutput: _ignored, ...rest } = existingPart; - updatedParts[toolIndex] = { - ...rest, - state: 'output-available', - output: chunk.output, - callProviderMetadata: chunk.callProviderMetadata, - preliminary: chunk.preliminary, - }; - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - } - break; - } - - case 'tool-error': { - if (!currentMessage) break; - - const toolIndex = currentMessage.parts.findIndex( - (p) => 'toolCallId' in p && p.toolCallId === chunk.toolCallId - ); - - if (toolIndex >= 0) { - delete toolRawInputByCallId[chunk.toolCallId]; - delete toolRawOutputByCallId[chunk.toolCallId]; - - const updatedParts = [...currentMessage.parts]; - const existingPart = updatedParts[toolIndex] as any; - const { - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - rawOutput: _ignoredRawOutput, - // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars - preliminary: _ignoredPreliminary, - ...rest - } = existingPart; - updatedParts[toolIndex] = { - ...rest, - state: 'output-error', - errorText: chunk.errorText, - input: chunk.input ?? existingPart.input, - callProviderMetadata: chunk.callProviderMetadata, - }; - currentMessage = { - ...currentMessage, - parts: updatedParts, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - } - break; - } - - case 'source-url': { - if (!currentMessage) break; - - const sourcePart = { - type: 'source-url' as const, - sourceId: chunk.sourceId, - url: chunk.url, - title: chunk.title, - }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, sourcePart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'source-document': { - if (!currentMessage) break; - - const docPart = { - type: 'source-document' as const, - sourceId: chunk.sourceId, - mediaType: chunk.mediaType, - title: chunk.title, - filename: chunk.filename, - providerMetadata: chunk.providerMetadata, - }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, docPart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'file': { - if (!currentMessage) break; - - const filePart = { - type: 'file' as const, - url: chunk.url, - mediaType: chunk.mediaType, - }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, filePart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'start-step': { - if (!currentMessage) break; - - const stepPart = { type: 'step-start' as const }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, stepPart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'message-metadata': { - if (!currentMessage) break; - - currentMessage = { - ...currentMessage, - metadata: chunk.messageMetadata, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - break; - } - - case 'error': { - isError = true; - throw new Error(chunk.errorText); - } - - case 'abort': { - isAbort = true; - break; - } - - case 'finish': { - if (currentMessage && chunk.messageMetadata !== undefined) { - currentMessage = { - ...currentMessage, - metadata: chunk.messageMetadata, - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - } - break; - } - - default: { - // Handle data parts (data-*) - const chunkType = (chunk as any).type as string; - if (chunkType?.startsWith('data-') && currentMessage) { - const dataPart = { - type: chunkType, - id: (chunk as any).id, - data: (chunk as any).data, - }; - - currentMessage = { - ...currentMessage, - parts: [...currentMessage.parts, dataPart], - } as TUIMessage; - this.state.replaceMessage(currentMessageIndex, currentMessage); - - // Trigger onData callback - if (this.onData) { - this.onData(dataPart as any); - } - } - } - } - }, - () => { - // Wait for any pending tool calls to complete - pendingToolCall.then(() => { - // Stream finished successfully - this.setStatus({ status: 'ready' }); - this.activeResponse = null; - - // Trigger onFinish callback - if (this.onFinish && currentMessage) { - this.onFinish({ - message: currentMessage, - messages: this.state.messages, - isAbort, - isDisconnect, - isError, - }); - } - - // Note: sendAutomaticallyWhen is only checked in addToolResult, - // not here. For server-executed tools, the server continues the - // conversation. For client-executed tools, addToolResult handles it. - resolve(); - }); - }, - (error) => { - if (error.name === 'AbortError') { - isAbort = true; - this.setStatus({ status: 'ready' }); - } else { - isDisconnect = true; - this.handleError(error); - } - - // Still call onFinish even on error/abort - if (this.onFinish && currentMessage) { - this.onFinish({ - message: currentMessage, - messages: this.state.messages, - isAbort, - isDisconnect, - isError, - }); - } - - resolve(); - } - ); - }); - } - - private handleError(error: Error): void { - this.setStatus({ status: 'error', error }); - - if (this.onError) { - this.onError(error); - } - } - - private convertFilesToParts( - files: FileList | FileUIPart[] - ): Promise { - if (Array.isArray(files)) { - return Promise.resolve(files); - } - - const promises: Array> = []; - for (let i = 0; i < files.length; i++) { - const file = files[i]; - promises.push( - this.fileToDataUrl(file).then((dataUrl) => ({ - type: 'file' as const, - mediaType: file.type, - filename: file.name, - url: dataUrl, - })) - ); - } - return Promise.all(promises); - } - - private fileToDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); - } -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/ai-lite/index.ts b/packages/instantsearch.js/src/lib/ai-lite/index.ts index 3f69531f593..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/index.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/index.ts @@ -1,74 +1 @@ -/** - * ai-lite module - a minimal reimplementation of the 'ai' package. - * - * This module provides the core chat functionality needed for InstantSearch - * without the full weight of the Vercel AI SDK. - */ - -// Classes -export { AbstractChat } from './abstract-chat'; -export { DefaultChatTransport, HttpChatTransport } from './transport'; - -// Utilities -export { - generateId, - lastAssistantMessageIsCompleteWithToolCalls, - SerialJobExecutor, -} from './utils'; - -// Stream parsing -export { parseJsonEventStream, processStream } from './stream-parser'; - -// Types -export type { - // Status - ChatStatus, - - // Message types - UIMessage, - UIMessagePart, - UIMessageChunk, - UIDataTypes, - UITools, - UITool, - ProviderMetadata, - - // Message part types - TextUIPart, - ReasoningUIPart, - ToolUIPart, - DynamicToolUIPart, - SourceUrlUIPart, - SourceDocumentUIPart, - FileUIPart, - StepStartUIPart, - DataUIPart, - - // Inference types - InferUIMessageMetadata, - InferUIMessageData, - InferUIMessageTools, - InferUIMessageToolCall, - InferUIMessageChunk, - - // State types - ChatState, - - // Transport types - ChatTransport, - ChatRequestOptions, - HttpChatTransportInitOptions, - PrepareSendMessagesRequest, - PrepareReconnectToStreamRequest, - Resolvable, - FetchFunction, - - // Init types - ChatInit, - IdGenerator, - ChatOnErrorCallback, - ChatOnToolCallCallback, - ChatOnFinishCallback, - ChatOnDataCallback, - CreateUIMessage, -} from './types'; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts b/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts index f4d9687756a..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/stream-parser.ts @@ -1,148 +1 @@ -/** - * Stream parser for parsing SSE (Server-Sent Events) streams. - * The AI SDK 5 format uses SSE with JSON payloads prefixed by "data: ". - */ -import type { UIMessageChunk } from './types'; - -/** - * Parse a stream of bytes as SSE (Server-Sent Events) and convert to UIMessageChunk events. - * Handles the "data: " prefix used by the AI SDK 5 streaming format. - * - * @param stream - The input stream of raw bytes - * @returns A ReadableStream of parsed UIMessageChunk events - */ -export function parseJsonEventStream< - TChunk extends UIMessageChunk = UIMessageChunk ->(stream: ReadableStream): ReadableStream { - const decoder = new TextDecoder(); - let buffer = ''; - - return new ReadableStream({ - start(controller) { - const reader = stream.getReader(); - - const processChunk = (): void => { - reader.read().then( - ({ done, value }) => { - if (done) { - // Process any remaining data in the buffer - if (buffer.trim()) { - const jsonData = extractJsonFromLine(buffer.trim()); - if (jsonData) { - try { - const chunk = JSON.parse(jsonData) as TChunk; - controller.enqueue(chunk); - } catch { - // Ignore parsing errors for incomplete data at end - } - } - } - controller.close(); - return; - } - - // Decode the chunk and add to buffer - buffer += decoder.decode(value, { stream: true }); - - // Process complete lines - const lines = buffer.split('\n'); - // Keep the last potentially incomplete line in the buffer - buffer = lines.pop() || ''; - - for (let i = 0; i < lines.length; i++) { - const trimmedLine = lines[i].trim(); - // eslint-disable-next-line no-continue - if (!trimmedLine) continue; - - // Extract JSON from SSE data line or plain JSON - const jsonData = extractJsonFromLine(trimmedLine); - // eslint-disable-next-line no-continue - if (!jsonData) continue; - - try { - const chunk = JSON.parse(jsonData) as TChunk; - controller.enqueue(chunk); - } catch { - // Skip malformed lines - } - } - - // Continue reading - processChunk(); - }, - (error) => { - controller.error(error); - } - ); - }; - - processChunk(); - }, - }); -} - -/** - * Extract JSON data from an SSE line or plain JSON line. - * Handles both "data: {...}" SSE format and plain "{...}" NDJSON format. - */ -function extractJsonFromLine(line: string): string | null { - // Handle SSE format: "data: {...}" - if (line.startsWith('data:')) { - const data = line.slice(5).trim(); - // Skip SSE stream termination signal - if (data === '[DONE]') return null; - return data; - } - - // Handle plain JSON (NDJSON format) - if (line.startsWith('{')) { - return line; - } - - // Skip other SSE fields (event:, id:, retry:, etc.) - return null; -} - -/** - * Process a ReadableStream using a callback for each value. - * This is a non-async alternative to for-await-of iteration. - */ -export function processStream( - stream: ReadableStream, - onChunk: (chunk: T) => void | Promise, - onDone: () => void, - onError: (error: Error) => void -): void { - const reader = stream.getReader(); - - const read = (): void => { - reader.read().then( - ({ done, value }) => { - if (done) { - reader.releaseLock(); - onDone(); - return; - } - - const result = onChunk(value); - if (result && typeof result.then === 'function') { - result.then( - () => read(), - (error) => { - reader.releaseLock(); - onError(error as Error); - } - ); - } else { - read(); - } - }, - (error) => { - reader.releaseLock(); - onError(error as Error); - } - ); - }; - - read(); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/ai-lite/transport.ts b/packages/instantsearch.js/src/lib/ai-lite/transport.ts index 3d8b70dee17..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/transport.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/transport.ts @@ -1,251 +1 @@ -/** - * HTTP transport implementation for chat. - */ -import { parseJsonEventStream } from './stream-parser'; -import { resolveValue } from './utils'; - -import type { - ChatTransport, - HttpChatTransportInitOptions, - InferUIMessageChunk, - UIMessage, - FetchFunction, - PrepareSendMessagesRequest, - PrepareReconnectToStreamRequest, - Resolvable, -} from './types'; - -/** - * Abstract base class for HTTP-based chat transports. - */ -export abstract class HttpChatTransport - implements ChatTransport -{ - protected api: string; - protected credentials: Resolvable | undefined; - protected headers: Resolvable | Headers> | undefined; - protected body: Resolvable | undefined; - protected fetch?: FetchFunction; - protected prepareSendMessagesRequest?: PrepareSendMessagesRequest; - protected prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest; - - constructor({ - api = '/api/chat', - credentials, - headers, - body, - fetch: customFetch, - prepareSendMessagesRequest, - prepareReconnectToStreamRequest, - }: HttpChatTransportInitOptions) { - this.api = api; - this.credentials = credentials; - this.headers = headers; - this.body = body; - this.fetch = customFetch; - this.prepareSendMessagesRequest = prepareSendMessagesRequest; - this.prepareReconnectToStreamRequest = prepareReconnectToStreamRequest; - } - - sendMessages({ - abortSignal, - chatId, - messages, - requestMetadata, - trigger, - messageId, - headers: requestHeaders, - body: requestBody, - }: Parameters['sendMessages']>[0]): Promise< - ReadableStream> - > { - const fetchFn = this.fetch ?? fetch; - - // Resolve configurable values - return Promise.all([ - resolveValue(this.credentials), - resolveValue(this.headers), - resolveValue(this.body), - ]).then(([resolvedCredentials, resolvedHeaders, resolvedBody]) => { - // Build default request options - let api = this.api; - let body: object = { - id: chatId, - messages, - ...resolvedBody, - ...requestBody, - }; - let headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(resolvedHeaders instanceof Headers - ? Object.fromEntries(resolvedHeaders.entries()) - : resolvedHeaders), - ...(requestHeaders instanceof Headers - ? Object.fromEntries(requestHeaders.entries()) - : requestHeaders), - }; - let credentials: RequestCredentials | undefined = resolvedCredentials; - - // Apply custom preparation if provided - const prepareRequestBody: Record = { - ...resolvedBody, - ...requestBody, - }; - const preparePromise = this.prepareSendMessagesRequest - ? Promise.resolve( - this.prepareSendMessagesRequest({ - id: chatId, - messages, - requestMetadata, - body: prepareRequestBody, - credentials: resolvedCredentials, - headers: resolvedHeaders, - api: this.api, - trigger, - messageId, - }) - ) - : Promise.resolve(null); - - return preparePromise.then((prepared) => { - if (prepared) { - body = prepared.body; - if (prepared.api) api = prepared.api; - if (prepared.headers) { - headers = { - 'Content-Type': 'application/json', - ...(prepared.headers instanceof Headers - ? Object.fromEntries(prepared.headers.entries()) - : prepared.headers), - }; - } - if (prepared.credentials) credentials = prepared.credentials; - } - - return fetchFn(api, { - method: 'POST', - headers, - body: JSON.stringify(body), - signal: abortSignal, - credentials, - }).then((response) => { - if (!response.ok) { - throw new Error( - `HTTP error: ${response.status} ${response.statusText}` - ); - } - - if (!response.body) { - throw new Error('Response body is empty'); - } - - return this.processResponseStream(response.body); - }); - }); - }); - } - - reconnectToStream({ - chatId, - headers: requestHeaders, - body: requestBody, - }: Parameters< - ChatTransport['reconnectToStream'] - >[0]): Promise> | null> { - const fetchFn = this.fetch ?? fetch; - - // Resolve configurable values - return Promise.all([ - resolveValue(this.credentials), - resolveValue(this.headers), - resolveValue(this.body), - ]).then(([resolvedCredentials, resolvedHeaders, resolvedBody]) => { - // Build default request options - let api = this.api; - let headers: HeadersInit = { - ...(resolvedHeaders instanceof Headers - ? Object.fromEntries(resolvedHeaders.entries()) - : resolvedHeaders), - ...(requestHeaders instanceof Headers - ? Object.fromEntries(requestHeaders.entries()) - : requestHeaders), - }; - let credentials: RequestCredentials | undefined = resolvedCredentials; - - // Apply custom preparation if provided - const prepareRequestBody: Record = { - ...resolvedBody, - ...requestBody, - }; - const preparePromise = this.prepareReconnectToStreamRequest - ? Promise.resolve( - this.prepareReconnectToStreamRequest({ - id: chatId, - requestMetadata: undefined, - body: prepareRequestBody, - credentials: resolvedCredentials, - headers: resolvedHeaders, - api: this.api, - }) - ) - : Promise.resolve(null); - - return preparePromise.then((prepared) => { - if (prepared) { - if (prepared.api) api = prepared.api; - if (prepared.headers) { - headers = - prepared.headers instanceof Headers - ? Object.fromEntries(prepared.headers.entries()) - : prepared.headers; - } - if (prepared.credentials) credentials = prepared.credentials; - } - - // GET request for reconnection - return fetchFn(`${api}?chatId=${chatId}`, { - method: 'GET', - headers, - credentials, - }).then((response) => { - if (!response.ok) { - // 404 means no stream to reconnect to, which is not an error - if (response.status === 404) { - return null; - } - throw new Error( - `HTTP error: ${response.status} ${response.statusText}` - ); - } - - if (!response.body) { - return null; - } - - return this.processResponseStream(response.body); - }); - }); - }); - } - - protected abstract processResponseStream( - stream: ReadableStream - ): ReadableStream>; -} - -/** - * Default chat transport implementation using NDJSON streaming. - */ -export class DefaultChatTransport< - TUIMessage extends UIMessage -> extends HttpChatTransport { - constructor(options: HttpChatTransportInitOptions = {}) { - super(options); - } - - protected processResponseStream( - stream: ReadableStream - ): ReadableStream> { - return parseJsonEventStream>(stream); - } -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/ai-lite/types.ts b/packages/instantsearch.js/src/lib/ai-lite/types.ts index 335153bdd25..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/types.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/types.ts @@ -1,512 +1 @@ -/* eslint-disable instantsearch/naming-convention */ -/** - * Chat status: - * - `submitted`: The message has been sent to the API and we're awaiting the start of the response stream. - * - `streaming`: The response is actively streaming in from the API, receiving chunks of data. - * - `ready`: The full response has been received and processed; a new user message can be submitted. - * - `error`: An error occurred during the API request, preventing successful completion. - */ -export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error'; - -export type UIDataTypes = Record; - -export type UITool = { - input: unknown; - output: unknown | undefined; -}; - -export type UITools = Record; - -export type ProviderMetadata = Record>; - -type ValueOf = T[keyof T]; - -type DeepPartial = T extends object - ? { [P in keyof T]?: DeepPartial } - : T; - -export type TextUIPart = { - type: 'text'; - text: string; - state?: 'streaming' | 'done'; - providerMetadata?: ProviderMetadata; -}; - -export type ReasoningUIPart = { - type: 'reasoning'; - text: string; - state?: 'streaming' | 'done'; - providerMetadata?: ProviderMetadata; -}; - -export type SourceUrlUIPart = { - type: 'source-url'; - sourceId: string; - url: string; - title?: string; - providerMetadata?: ProviderMetadata; -}; - -export type SourceDocumentUIPart = { - type: 'source-document'; - sourceId: string; - mediaType: string; - title: string; - filename?: string; - providerMetadata?: ProviderMetadata; -}; - -export type FileUIPart = { - type: 'file'; - mediaType: string; - filename?: string; - url: string; - providerMetadata?: ProviderMetadata; -}; - -export type StepStartUIPart = { - type: 'step-start'; -}; - -export type DataUIPart = ValueOf<{ - [NAME in keyof DATA_TYPES & string]: { - type: `data-${NAME}`; - id?: string; - data: DATA_TYPES[NAME]; - }; -}>; - -export type ToolUIPart = ValueOf<{ - [NAME in keyof TOOLS & string]: { - type: `tool-${NAME}`; - toolCallId: string; - } & ( - | { - state: 'input-streaming'; - input: DeepPartial | undefined; - rawInput?: string; - providerExecuted?: boolean; - output?: never; - errorText?: never; - } - | { - state: 'input-available'; - input: TOOLS[NAME]['input']; - providerExecuted?: boolean; - output?: never; - errorText?: never; - callProviderMetadata?: ProviderMetadata; - } - | { - state: 'output-available'; - input: TOOLS[NAME]['input']; - output: TOOLS[NAME]['output']; - errorText?: never; - providerExecuted?: boolean; - callProviderMetadata?: ProviderMetadata; - preliminary?: boolean; - } - | { - state: 'output-error'; - input: TOOLS[NAME]['input'] | undefined; - rawInput?: unknown; - output?: never; - errorText: string; - providerExecuted?: boolean; - callProviderMetadata?: ProviderMetadata; - } - ); -}>; - -export type DynamicToolUIPart = { - type: 'dynamic-tool'; - toolName: string; - toolCallId: string; -} & ( - | { - state: 'input-streaming'; - input: unknown | undefined; - rawInput?: string; - output?: never; - errorText?: never; - } - | { - state: 'input-available'; - input: unknown; - output?: never; - errorText?: never; - callProviderMetadata?: ProviderMetadata; - } - | { - state: 'output-available'; - input: unknown; - output: unknown; - errorText?: never; - callProviderMetadata?: ProviderMetadata; - preliminary?: boolean; - } - | { - state: 'output-error'; - input: unknown; - output?: never; - errorText: string; - callProviderMetadata?: ProviderMetadata; - } -); - -export type UIMessagePart< - DATA_TYPES extends UIDataTypes = UIDataTypes, - TOOLS extends UITools = UITools -> = - | TextUIPart - | ReasoningUIPart - | ToolUIPart - | DynamicToolUIPart - | SourceUrlUIPart - | SourceDocumentUIPart - | FileUIPart - | DataUIPart - | StepStartUIPart; - -export interface UIMessage< - METADATA = unknown, - DATA_PARTS extends UIDataTypes = UIDataTypes, - TOOLS extends UITools = UITools -> { - id: string; - role: 'system' | 'user' | 'assistant'; - metadata?: METADATA; - parts: Array>; -} - -export type InferUIMessageMetadata = T extends UIMessage< - infer METADATA -> - ? METADATA - : unknown; - -export type InferUIMessageData = T extends UIMessage< - unknown, - infer DATA_TYPES -> - ? DATA_TYPES - : UIDataTypes; - -export type InferUIMessageTools = T extends UIMessage< - unknown, - any, - infer TOOLS -> - ? TOOLS - : UITools; - -export type InferUIMessageToolCall = - | ValueOf<{ - [NAME in keyof InferUIMessageTools]: { - toolName: NAME & string; - toolCallId: string; - input: InferUIMessageTools[NAME] extends { - input: infer INPUT; - } - ? INPUT - : never; - dynamic?: false; - }; - }> - | { - toolName: string; - toolCallId: string; - input: unknown; - dynamic: true; - }; - -type DataUIMessageChunk = ValueOf<{ - [NAME in keyof DATA_TYPES & string]: { - type: `data-${NAME}`; - id?: string; - data: DATA_TYPES[NAME]; - transient?: boolean; - }; -}>; - -type ToolUIMessageChunk = - | ValueOf<{ - [NAME in keyof TOOLS & string]: { - type: 'tool-input-available'; - toolName: NAME; - toolCallId: string; - input: TOOLS[NAME]['input']; - callProviderMetadata?: ProviderMetadata; - providerExecuted?: boolean; - }; - }> - | ValueOf<{ - [NAME in keyof TOOLS & string]: { - type: 'tool-input-start'; - toolName: NAME; - toolCallId: string; - input?: DeepPartial; - providerExecuted?: boolean; - }; - }> - | ValueOf<{ - [NAME in keyof TOOLS & string]: { - type: 'tool-input-delta'; - toolName: NAME; - toolCallId: string; - inputTextDelta: string; - }; - }> - | ValueOf<{ - [NAME in keyof TOOLS & string]: { - type: 'tool-output-available'; - toolName: NAME; - toolCallId: string; - output: TOOLS[NAME]['output']; - callProviderMetadata?: ProviderMetadata; - preliminary?: boolean; - }; - }> - | ValueOf<{ - [NAME in keyof TOOLS & string]: { - type: 'tool-error'; - toolName: NAME; - toolCallId: string; - errorText: string; - input?: TOOLS[NAME]['input']; - callProviderMetadata?: ProviderMetadata; - }; - }> - | { - type: 'tool-input-available'; - toolName: string; - toolCallId: string; - input: unknown; - callProviderMetadata?: ProviderMetadata; - providerExecuted?: boolean; - dynamic: true; - } - | { - type: 'tool-input-start'; - toolName: string; - toolCallId: string; - input?: unknown; - providerExecuted?: boolean; - dynamic: true; - } - | { - type: 'tool-input-delta'; - toolName: string; - toolCallId: string; - inputTextDelta: string; - dynamic: true; - } - | { - type: 'tool-output-available'; - toolName: string; - toolCallId: string; - output: unknown; - callProviderMetadata?: ProviderMetadata; - preliminary?: boolean; - dynamic: true; - } - | { - type: 'tool-error'; - toolName: string; - toolCallId: string; - errorText: string; - input?: unknown; - callProviderMetadata?: ProviderMetadata; - dynamic: true; - }; - -export type UIMessageChunk< - METADATA = unknown, - DATA_TYPES extends UIDataTypes = UIDataTypes, - TOOLS extends UITools = UITools -> = - | { type: 'text-start'; id: string; providerMetadata?: ProviderMetadata } - | { - type: 'text-delta'; - delta: string; - id: string; - providerMetadata?: ProviderMetadata; - } - | { type: 'text-end'; id: string; providerMetadata?: ProviderMetadata } - | { type: 'reasoning-start'; id: string; providerMetadata?: ProviderMetadata } - | { - type: 'reasoning-delta'; - id: string; - delta: string; - providerMetadata?: ProviderMetadata; - } - | { type: 'reasoning-end'; id: string; providerMetadata?: ProviderMetadata } - | { type: 'error'; errorText: string } - | ToolUIMessageChunk - | { - type: 'data-tool-output-delta'; - data: { - toolCallId: string; - toolName: string; - delta: string; - }; - transient?: boolean; - } - | { type: 'source-url'; sourceId: string; url: string; title?: string } - | { - type: 'source-document'; - sourceId: string; - mediaType: string; - title: string; - filename?: string; - providerMetadata?: ProviderMetadata; - } - | { type: 'file'; url: string; mediaType: string } - | DataUIMessageChunk - | { type: 'start-step' } - | { type: 'finish-step' } - | { type: 'start'; messageId?: string; messageMetadata?: METADATA } - | { type: 'finish'; messageMetadata?: METADATA } - | { type: 'abort' } - | { type: 'message-metadata'; messageMetadata: METADATA }; - -export type InferUIMessageChunk = UIMessageChunk< - InferUIMessageMetadata, - InferUIMessageData, - InferUIMessageTools ->; - -export interface ChatState { - status: ChatStatus; - error: Error | undefined; - messages: UI_MESSAGE[]; - pushMessage: (message: UI_MESSAGE) => void; - popMessage: () => void; - replaceMessage: (index: number, message: UI_MESSAGE) => void; - snapshot: (thing: T) => T; -} - -export type ChatRequestOptions = { - headers?: Record | Headers; - body?: object; - metadata?: unknown; -}; - -export interface ChatTransport { - sendMessages: ( - options: { - chatId: string; - messages: UI_MESSAGE[]; - abortSignal: AbortSignal; - requestMetadata?: unknown; - trigger: 'submit-message' | 'regenerate-message'; - messageId?: string; - } & ChatRequestOptions - ) => Promise>>; - - reconnectToStream: ( - options: { - chatId: string; - } & ChatRequestOptions - ) => Promise> | null>; -} - -export type PrepareSendMessagesRequest = ( - options: { - id: string; - messages: UI_MESSAGE[]; - requestMetadata: unknown; - body: Record | undefined; - credentials: RequestCredentials | undefined; - headers: HeadersInit | undefined; - api: string; - } & { - trigger: 'submit-message' | 'regenerate-message'; - messageId: string | undefined; - } -) => - | { - body: object; - headers?: HeadersInit; - credentials?: RequestCredentials; - api?: string; - } - | PromiseLike<{ - body: object; - headers?: HeadersInit; - credentials?: RequestCredentials; - api?: string; - }>; - -export type PrepareReconnectToStreamRequest = (options: { - id: string; - requestMetadata: unknown; - body: Record | undefined; - credentials: RequestCredentials | undefined; - headers: HeadersInit | undefined; - api: string; -}) => - | { headers?: HeadersInit; credentials?: RequestCredentials; api?: string } - | PromiseLike<{ - headers?: HeadersInit; - credentials?: RequestCredentials; - api?: string; - }>; - -export type Resolvable = T | (() => T) | (() => Promise); - -export type FetchFunction = typeof fetch; - -export type HttpChatTransportInitOptions = { - api?: string; - credentials?: Resolvable; - headers?: Resolvable | Headers>; - body?: Resolvable; - fetch?: FetchFunction; - prepareSendMessagesRequest?: PrepareSendMessagesRequest; - prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest; -}; - -export type IdGenerator = () => string; - -export type ChatOnErrorCallback = (error: Error) => void; - -export type ChatOnToolCallCallback = - (options: { - toolCall: InferUIMessageToolCall; - }) => void | PromiseLike; - -export type ChatOnFinishCallback = (options: { - message: UI_MESSAGE; - messages: UI_MESSAGE[]; - isAbort: boolean; - isDisconnect: boolean; - isError: boolean; -}) => void; - -export type ChatOnDataCallback = ( - dataPart: DataUIPart> -) => void; - -export interface ChatInit { - id?: string; - messages?: UI_MESSAGE[]; - generateId?: IdGenerator; - transport?: ChatTransport; - onError?: ChatOnErrorCallback; - onToolCall?: ChatOnToolCallCallback; - onFinish?: ChatOnFinishCallback; - onData?: ChatOnDataCallback; - sendAutomaticallyWhen?: (options: { - messages: UI_MESSAGE[]; - }) => boolean | PromiseLike; - shouldRepairToolInput?: (toolName: string) => boolean; -} - -export type CreateUIMessage = Omit< - UI_MESSAGE, - 'id' | 'role' -> & { - id?: UI_MESSAGE['id']; - role?: UI_MESSAGE['role']; -}; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/ai-lite/utils.ts b/packages/instantsearch.js/src/lib/ai-lite/utils.ts index 40fbd4931c8..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/ai-lite/utils.ts +++ b/packages/instantsearch.js/src/lib/ai-lite/utils.ts @@ -1,94 +1 @@ -import type { - UIMessage, - ToolUIPart, - DynamicToolUIPart, - UITools, -} from './types'; - -export function generateId(): string { - return Math.random().toString(36).substring(2, 9); -} - -function isToolOrDynamicToolUIPart( - part: unknown -): part is ToolUIPart | DynamicToolUIPart { - if (typeof part !== 'object' || part === null) return false; - const p = part as { type?: string }; - return ( - typeof p.type === 'string' && - (p.type.startsWith('tool-') || p.type === 'dynamic-tool') - ); -} - -export function lastAssistantMessageIsCompleteWithToolCalls({ - messages, -}: { - messages: UIMessage[]; -}): boolean { - if (messages.length === 0) return false; - - const lastMessage = messages[messages.length - 1]; - - if (lastMessage.role !== 'assistant') return false; - - if (!lastMessage.parts || lastMessage.parts.length === 0) return false; - - const toolParts = lastMessage.parts.filter(isToolOrDynamicToolUIPart); - - if (toolParts.length === 0) return false; - - return toolParts.every( - (part) => part.state === 'output-available' || part.state === 'output-error' - ); -} - -export class SerialJobExecutor { - private queue: Array<() => Promise> = []; - private isRunning = false; - - run(job: () => Promise): Promise { - return new Promise((resolve, reject) => { - this.queue.push(() => { - return job().then( - (result) => { - resolve(result); - }, - (error) => { - reject(error); - } - ); - }); - - this.processQueue(); - }); - } - - private processQueue(): void { - if (this.isRunning) return; - this.isRunning = true; - - const processNext = (): void => { - if (this.queue.length === 0) { - this.isRunning = false; - return; - } - - const job = this.queue.shift(); - if (job) { - job().then(processNext, processNext); - } - }; - - processNext(); - } -} - -export function resolveValue( - value: T | (() => T) | (() => Promise) | undefined -): Promise { - if (value === undefined) return Promise.resolve(undefined); - if (typeof value === 'function') { - return Promise.resolve((value as () => T | Promise)()); - } - return Promise.resolve(value); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/chat/chat.ts b/packages/instantsearch.js/src/lib/chat/chat.ts index a629a8886ab..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/chat/chat.ts +++ b/packages/instantsearch.js/src/lib/chat/chat.ts @@ -1,166 +1 @@ -import { AbstractChat } from '../ai-lite'; - -import type { - UIMessage, - ChatState as BaseChatState, - ChatStatus, - ChatInit, -} from '../ai-lite'; - -export type { UIMessage }; -export { AbstractChat }; -export { ChatInit }; - -export const CACHE_KEY = 'instantsearch-chat-initial-messages'; - -function getDefaultInitialMessages( - id?: string -): TUIMessage[] { - const initialMessages = sessionStorage.getItem( - CACHE_KEY + (id ? `-${id}` : '') - ); - return initialMessages ? JSON.parse(initialMessages) : []; -} - -export class ChatState - implements BaseChatState -{ - _messages: TUiMessage[]; - _status: ChatStatus = 'ready'; - _error: Error | undefined = undefined; - - _messagesCallbacks = new Set<() => void>(); - _statusCallbacks = new Set<() => void>(); - _errorCallbacks = new Set<() => void>(); - - constructor( - id: string | undefined = undefined, - initialMessages: TUiMessage[] = getDefaultInitialMessages(id) - ) { - this._messages = initialMessages; - const saveMessagesInLocalStorage = () => { - if (this.status === 'ready') { - try { - sessionStorage.setItem( - CACHE_KEY + (id ? `-${id}` : ''), - JSON.stringify(this.messages) - ); - } catch (e) { - // Do nothing if sessionStorage is not available or full - } - } - }; - this['~registerMessagesCallback'](saveMessagesInLocalStorage); - this['~registerStatusCallback'](saveMessagesInLocalStorage); - } - - get status(): ChatStatus { - return this._status; - } - - set status(newStatus: ChatStatus) { - this._status = newStatus; - this._callStatusCallbacks(); - } - - get error(): Error | undefined { - return this._error; - } - - set error(newError: Error | undefined) { - this._error = newError; - this._callErrorCallbacks(); - } - - get messages(): TUiMessage[] { - return this._messages; - } - - set messages(newMessages: TUiMessage[]) { - this._messages = [...newMessages]; - this._callMessagesCallbacks(); - } - - pushMessage = (message: TUiMessage) => { - this._messages = this._messages.concat(message); - this._callMessagesCallbacks(); - }; - - popMessage = () => { - this._messages = this._messages.slice(0, -1); - this._callMessagesCallbacks(); - }; - - replaceMessage = (index: number, message: TUiMessage) => { - this._messages = [ - ...this._messages.slice(0, index), - // We deep clone the message here to ensure the new React Compiler (currently in RC) detects deeply nested parts/metadata changes: - this.snapshot(message), - ...this._messages.slice(index + 1), - ]; - this._callMessagesCallbacks(); - }; - - snapshot = (thing: T): T => { - return JSON.parse(JSON.stringify(thing)) as T; - }; - - '~registerMessagesCallback' = (onChange: () => void): (() => void) => { - const callback = onChange; - this._messagesCallbacks.add(callback); - return () => { - this._messagesCallbacks.delete(callback); - }; - }; - - '~registerStatusCallback' = (onChange: () => void): (() => void) => { - this._statusCallbacks.add(onChange); - return () => { - this._statusCallbacks.delete(onChange); - }; - }; - - '~registerErrorCallback' = (onChange: () => void): (() => void) => { - this._errorCallbacks.add(onChange); - return () => { - this._errorCallbacks.delete(onChange); - }; - }; - - _callMessagesCallbacks = () => { - this._messagesCallbacks.forEach((callback) => callback()); - }; - - _callStatusCallbacks = () => { - this._statusCallbacks.forEach((callback) => callback()); - }; - - _callErrorCallbacks = () => { - this._errorCallbacks.forEach((callback) => callback()); - }; -} - -export class Chat< - TUiMessage extends UIMessage -> extends AbstractChat { - _state: ChatState; - - constructor({ - messages, - agentId, - ...init - }: ChatInit & { agentId?: string }) { - const state = new ChatState(agentId, messages); - super({ ...init, state }); - this._state = state; - } - - '~registerMessagesCallback' = (onChange: () => void): (() => void) => - this._state['~registerMessagesCallback'](onChange); - - '~registerStatusCallback' = (onChange: () => void): (() => void) => - this._state['~registerStatusCallback'](onChange); - - '~registerErrorCallback' = (onChange: () => void): (() => void) => - this._state['~registerErrorCallback'](onChange); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/chat/index.ts b/packages/instantsearch.js/src/lib/chat/index.ts index a2231db0db3..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/chat/index.ts +++ b/packages/instantsearch.js/src/lib/chat/index.ts @@ -1,12 +1 @@ -export type { UIMessage } from './chat'; -export type { ChatInit } from './chat'; -export { AbstractChat } from './chat'; -export { ChatState } from './chat'; -export { Chat } from './chat'; - -export const SearchIndexToolType = 'algolia_search_index'; -export const RecommendToolType = 'algolia_recommend'; -export const MemorizeToolType = 'algolia_memorize'; -export const MemorySearchToolType = 'algolia_memory_search'; -export const PonderToolType = 'algolia_ponder'; -export const DisplayResultsToolType = 'algolia_display_results'; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/infiniteHitsCache/index.ts b/packages/instantsearch.js/src/lib/infiniteHitsCache/index.ts index 6fd47d4f820..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/infiniteHitsCache/index.ts +++ b/packages/instantsearch.js/src/lib/infiniteHitsCache/index.ts @@ -1 +1 @@ -export { default as createInfiniteHitsSessionStorageCache } from './sessionStorage'; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/infiniteHitsCache/sessionStorage.ts b/packages/instantsearch.js/src/lib/infiniteHitsCache/sessionStorage.ts index af4d74d6fbd..b8b955c2596 100644 --- a/packages/instantsearch.js/src/lib/infiniteHitsCache/sessionStorage.ts +++ b/packages/instantsearch.js/src/lib/infiniteHitsCache/sessionStorage.ts @@ -1,74 +1,2 @@ -import { isEqual, safelyRunOnBrowser } from '../utils'; - -import type { InfiniteHitsCache } from '../../connectors/infinite-hits/connectInfiniteHits'; -import type { PlainSearchParameters } from 'algoliasearch-helper'; - -function getStateWithoutPage(state: PlainSearchParameters) { - const { page, ...rest } = state || {}; - return rest; -} - -export default function createInfiniteHitsSessionStorageCache({ - key, -}: { - /** - * If you display multiple instances of infiniteHits on the same page, - * you must provide a unique key for each instance. - */ - key?: string; -} = {}): InfiniteHitsCache { - const KEY = ['ais.infiniteHits', key].filter(Boolean).join(':'); - - return { - read({ state }) { - const sessionStorage = safelyRunOnBrowser( - ({ window }) => window.sessionStorage - ); - - if (!sessionStorage) { - return null; - } - - try { - const cache = JSON.parse( - // @ts-expect-error JSON.parse() requires a string, but it actually accepts null, too. - sessionStorage.getItem(KEY) - ); - - return cache && isEqual(cache.state, getStateWithoutPage(state)) - ? cache.hits - : null; - } catch (error) { - if (error instanceof SyntaxError) { - try { - sessionStorage.removeItem(KEY); - } catch (err) { - // do nothing - } - } - return null; - } - }, - write({ state, hits }) { - const sessionStorage = safelyRunOnBrowser( - ({ window }) => window.sessionStorage - ); - - if (!sessionStorage) { - return; - } - - try { - sessionStorage.setItem( - KEY, - JSON.stringify({ - state: getStateWithoutPage(state), - hits, - }) - ); - } catch (error) { - // do nothing - } - }, - }; -} +export { createInfiniteHitsSessionStorageCache as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/routers/history.ts b/packages/instantsearch.js/src/lib/routers/history.ts index 4c7f34baefa..2660a4cf6ea 100644 --- a/packages/instantsearch.js/src/lib/routers/history.ts +++ b/packages/instantsearch.js/src/lib/routers/history.ts @@ -1,371 +1,2 @@ -import qs from 'qs'; - -import { createDocumentationLink, safelyRunOnBrowser, warning } from '../utils'; - -import type { Router, UiState } from '../../types'; - -type CreateURL = (args: { - qsModule: typeof qs; - routeState: TRouteState; - location: Location; -}) => string; - -type ParseURL = (args: { - qsModule: typeof qs; - location: Location; -}) => TRouteState; - -export type BrowserHistoryArgs = { - windowTitle?: (routeState: TRouteState) => string; - writeDelay: number; - createURL: CreateURL; - parseURL: ParseURL; - // @MAJOR: The `Location` type is hard to simulate in non-browser environments - // so we should accept a subset of it that is easier to work with in any - // environments. - getLocation: () => Location; - start?: (onUpdate: () => void) => void; - dispose?: () => void; - push?: (url: string) => void; - /** - * Whether the URL should be cleaned up when the router is disposed. - * This can be useful when closing a modal containing InstantSearch, to - * remove active refinements from the URL. - * @default true - */ - // @MAJOR: Switch the default to `false` and remove the console info in the next major version. - cleanUrlOnDispose?: boolean; -}; - -const setWindowTitle = (title?: string): void => { - if (title) { - // This function is only executed on browsers so we can disable this check. - // eslint-disable-next-line no-restricted-globals - window.document.title = title; - } -}; - -class BrowserHistory implements Router { - public $$type = 'ais.browser'; - /** - * Transforms a UI state into a title for the page. - */ - private readonly windowTitle?: BrowserHistoryArgs['windowTitle']; - /** - * Time in milliseconds before performing a write in the history. - * It prevents from adding too many entries in the history and - * makes the back button more usable. - * - * @default 400 - */ - private readonly writeDelay: Required< - BrowserHistoryArgs - >['writeDelay']; - /** - * Creates a full URL based on the route state. - * The storage adaptor maps all syncable keys to the query string of the URL. - */ - private readonly _createURL: Required< - BrowserHistoryArgs - >['createURL']; - /** - * Parses the URL into a route state. - * It should be symmetrical to `createURL`. - */ - private readonly parseURL: Required< - BrowserHistoryArgs - >['parseURL']; - /** - * Returns the location to store in the history. - * @default () => window.location - */ - private readonly getLocation: Required< - BrowserHistoryArgs - >['getLocation']; - - private writeTimer?: ReturnType; - private _onPopState?: (event: PopStateEvent) => void; - - /** - * Indicates if last action was back/forward in the browser. - */ - private inPopState: boolean = false; - - /** - * Indicates whether the history router is disposed or not. - */ - protected isDisposed: boolean = false; - - /** - * Indicates the window.history.length before the last call to - * window.history.pushState (called in `write`). - * It allows to determine if a `pushState` has been triggered elsewhere, - * and thus to prevent the `write` method from calling `pushState`. - */ - private latestAcknowledgedHistory: number = 0; - - private _start?: (onUpdate: () => void) => void; - private _dispose?: () => void; - private _push?: (url: string) => void; - private _cleanUrlOnDispose: boolean; - - /** - * Initializes a new storage provider that syncs the search state to the URL - * using web APIs (`window.location.pushState` and `onpopstate` event). - */ - public constructor({ - windowTitle, - writeDelay = 400, - createURL, - parseURL, - getLocation, - start, - dispose, - push, - cleanUrlOnDispose, - }: BrowserHistoryArgs) { - this.windowTitle = windowTitle; - this.writeTimer = undefined; - this.writeDelay = writeDelay; - this._createURL = createURL; - this.parseURL = parseURL; - this.getLocation = getLocation; - this._start = start; - this._dispose = dispose; - this._push = push; - this._cleanUrlOnDispose = - typeof cleanUrlOnDispose === 'undefined' ? true : cleanUrlOnDispose; - - if (__DEV__ && typeof cleanUrlOnDispose === 'undefined') { - // eslint-disable-next-line no-console - console.info(`Starting from the next major version, InstantSearch will not clean up the URL from active refinements when it is disposed. - -We recommend setting \`cleanUrlOnDispose\` to false to adopt this change today. -To stay with the current behaviour and remove this warning, set the option to true. - -See documentation: ${createDocumentationLink({ - name: 'history-router', - })}#widget-param-cleanurlondispose`); - } - - safelyRunOnBrowser(({ window: browserWindow }) => { - const title = this.windowTitle && this.windowTitle(this.read()); - setWindowTitle(title); - - this.latestAcknowledgedHistory = browserWindow.history.length; - }); - } - - /** - * Reads the URL and returns a syncable UI search state. - */ - public read(): TRouteState { - return this.parseURL({ qsModule: qs, location: this.getLocation() }); - } - - /** - * Pushes a search state into the URL. - */ - public write(routeState: TRouteState): void { - safelyRunOnBrowser(({ window: browserWindow }) => { - const url = this.createURL(routeState); - const title = this.windowTitle && this.windowTitle(routeState); - - if (this.writeTimer) { - clearTimeout(this.writeTimer); - } - - this.writeTimer = setTimeout(() => { - setWindowTitle(title); - - if (this.shouldWrite(url)) { - if (this._push) { - this._push(url); - } else { - browserWindow.history.pushState(routeState, title || '', url); - } - this.latestAcknowledgedHistory = browserWindow.history.length; - } - this.inPopState = false; - this.writeTimer = undefined; - }, this.writeDelay); - }); - } - - /** - * Sets a callback on the `onpopstate` event of the history API of the current page. - * It enables the URL sync to keep track of the changes. - */ - public onUpdate(callback: (routeState: TRouteState) => void): void { - if (this._start) { - this._start(() => { - callback(this.read()); - }); - } - - this._onPopState = () => { - if (this.writeTimer) { - clearTimeout(this.writeTimer); - this.writeTimer = undefined; - } - - this.inPopState = true; - - // We always read the state from the URL because the state of the history - // can be incorect in some cases (e.g. using React Router). - callback(this.read()); - }; - - safelyRunOnBrowser(({ window: browserWindow }) => { - browserWindow.addEventListener('popstate', this._onPopState!); - }); - } - - /** - * Creates a complete URL from a given syncable UI state. - * - * It always generates the full URL, not a relative one. - * This allows to handle cases like using a . - * See: https://github.com/algolia/instantsearch/issues/790 - */ - public createURL(routeState: TRouteState): string { - const url = this._createURL({ - qsModule: qs, - routeState, - location: this.getLocation(), - }); - - if (__DEV__) { - try { - // We just want to check if the URL is valid. - // eslint-disable-next-line no-new - new URL(url); - } catch (e) { - warning( - false, - `The URL returned by the \`createURL\` function is invalid. -Please make sure it returns an absolute URL to avoid issues, e.g: \`https://algolia.com/search?query=iphone\`.` - ); - } - } - - return url; - } - - /** - * Removes the event listener and cleans up the URL. - */ - public dispose(): void { - if (this._dispose) { - this._dispose(); - } - - this.isDisposed = true; - - safelyRunOnBrowser(({ window: browserWindow }) => { - if (this._onPopState) { - browserWindow.removeEventListener('popstate', this._onPopState); - } - }); - - if (this.writeTimer) { - clearTimeout(this.writeTimer); - } - - if (this._cleanUrlOnDispose) { - this.write({} as TRouteState); - } - } - - public start() { - this.isDisposed = false; - } - - private shouldWrite(url: string): boolean { - return safelyRunOnBrowser(({ window: browserWindow }) => { - // When disposed and the cleanUrlOnDispose is set to false, we do not want to write the URL. - if (this.isDisposed && !this._cleanUrlOnDispose) { - return false; - } - - // We do want to `pushState` if: - // - the router is not disposed, IS.js needs to update the URL - // OR - // - the last write was from InstantSearch.js - // (unlike a SPA, where it would have last written) - const lastPushWasByISAfterDispose = !( - this.isDisposed && - this.latestAcknowledgedHistory !== browserWindow.history.length - ); - - return ( - // When the last state change was through popstate, the IS.js state changes, - // but that should not write the URL. - !this.inPopState && - // When the previous pushState after dispose was by IS.js, we want to write the URL. - lastPushWasByISAfterDispose && - // When the URL is the same as the current one, we do not want to write it. - url !== browserWindow.location.href - ); - }); - } -} - -export default function historyRouter({ - createURL = ({ qsModule, routeState, location }) => { - const { protocol, hostname, port = '', pathname, hash } = location; - const queryString = qsModule.stringify(routeState); - const portWithPrefix = port === '' ? '' : `:${port}`; - - // IE <= 11 has no proper `location.origin` so we cannot rely on it. - if (!queryString) { - return `${protocol}//${hostname}${portWithPrefix}${pathname}${hash}`; - } - - return `${protocol}//${hostname}${portWithPrefix}${pathname}?${queryString}${hash}`; - }, - parseURL = ({ qsModule, location }) => { - // `qs` by default converts arrays with more than 20 items to an object. - // We want to avoid this because the data structure manipulated can therefore vary. - // Setting the limit to `100` seems a good number because the engine's default is 100 - // (it can go up to 1000 but it is very unlikely to select more than 100 items in the UI). - // - // Using an `arrayLimit` of `n` allows `n + 1` items. - // - // See: - // - https://github.com/ljharb/qs#parsing-arrays - // - https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/ - return qsModule.parse(location.search.slice(1), { - arrayLimit: 99, - }) as unknown as TRouteState; - }, - writeDelay = 400, - windowTitle, - getLocation = () => { - return safelyRunOnBrowser( - ({ window: browserWindow }) => browserWindow.location, - { - fallback: () => { - throw new Error( - 'You need to provide `getLocation` to the `history` router in environments where `window` does not exist.' - ); - }, - }); - }, - start, - dispose, - push, - cleanUrlOnDispose, -}: Partial> = {}): BrowserHistory { - return new BrowserHistory({ - createURL, - parseURL, - writeDelay, - windowTitle, - getLocation, - start, - dispose, - push, - cleanUrlOnDispose, - }); -} +export { history as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/routers/index.ts b/packages/instantsearch.js/src/lib/routers/index.ts index 5742d94c8bc..3a16f3cada6 100644 --- a/packages/instantsearch.js/src/lib/routers/index.ts +++ b/packages/instantsearch.js/src/lib/routers/index.ts @@ -1 +1 @@ -export { default as history } from './history'; +export { history } from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/server.ts b/packages/instantsearch.js/src/lib/server.ts index 99cfce33fc4..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/server.ts +++ b/packages/instantsearch.js/src/lib/server.ts @@ -1,150 +1 @@ -import { walkIndex } from './utils'; - -import type { - SearchClient, - CompositionClient, - IndexWidget, - InitialResults, - InstantSearch, - SearchOptions, -} from '../types'; - -/** - * Waits for the results from the search instance to coordinate the next steps - * in `getServerState()`. - */ -export function waitForResults( - search: InstantSearch, - skipRecommend: boolean = false -): Promise { - const helper = search.mainHelper!; - - // Extract search parameters from the search client to use them - // later during hydration. - let requestParamsList: SearchOptions[]; - const client = helper.getClient(); - if (search.compositionID) { - helper.setClient({ - ...client, - search(query) { - requestParamsList = [query.requestBody.params]; - return (client as CompositionClient).search(query); - }, - } as CompositionClient); - } else { - helper.setClient({ - ...client, - search(queries) { - requestParamsList = queries.map(({ params }) => params); - return (client as SearchClient).search(queries); - }, - } as SearchClient); - } - - if (search._hasSearchWidget) { - if (search.compositionID) { - helper.searchWithComposition(); - } else { - helper.searchOnlyWithDerivedHelpers(); - } - } - !skipRecommend && search._hasRecommendWidget && helper.recommend(); - - return new Promise((resolve, reject) => { - let searchResultsReceived = !search._hasSearchWidget; - let recommendResultsReceived = !search._hasRecommendWidget || skipRecommend; - // All derived helpers resolve in the same tick so we're safe only relying - // on the first one. - helper.derivedHelpers[0].on('result', () => { - searchResultsReceived = true; - if (recommendResultsReceived) { - resolve(requestParamsList!); - } - }); - helper.derivedHelpers[0].on('recommend:result', () => { - recommendResultsReceived = true; - if (searchResultsReceived) { - resolve(requestParamsList!); - } - }); - - // However, we listen to errors that can happen on any derived helper because - // any error is critical. - helper.on('error', (error) => { - reject(error); - }); - search.on('error', (error) => { - reject(error); - }); - helper.derivedHelpers.forEach((derivedHelper) => - derivedHelper.on('error', (error) => { - reject(error); - }) - ); - }); -} - -/** - * Walks the InstantSearch root index to construct the initial results. - */ -export function getInitialResults( - rootIndex: IndexWidget, - /** - * Search parameters sent to the search client, - * returned by `waitForResults()`. - */ - requestParamsList?: SearchOptions[] -): InitialResults { - const initialResults: InitialResults = {}; - - let requestParamsIndex = 0; - walkIndex(rootIndex, (widget) => { - const searchResults = widget.getResults(); - const recommendResults = widget.getHelper()?.lastRecommendResults; - if (searchResults || recommendResults) { - const resultsCount = searchResults?._rawResults?.length || 0; - const requestParams = resultsCount - ? requestParamsList?.slice( - requestParamsIndex, - requestParamsIndex + resultsCount - ) - : []; - requestParamsIndex += resultsCount; - initialResults[widget.getIndexId()] = { - // We convert the Helper state to a plain object to pass parsable data - // structures from server to client. - ...(searchResults && { - state: { - ...searchResults._state, - clickAnalytics: requestParams?.[0]?.clickAnalytics, - userToken: requestParams?.[0]?.userToken, - }, - results: searchResults._rawResults, - ...(searchResults.feeds && - searchResults.feeds.length > 0 && { - compositionFeedsResults: searchResults.feeds.map((feed) => ({ - ...feed._rawResults[0], - feedID: feed.feedID, - })), - }), - }), - ...(recommendResults && { - recommendResults: { - // We have to stringify + parse because of some explicitly undefined values. - params: JSON.parse(JSON.stringify(recommendResults._state.params)), - results: recommendResults._rawResults, - }, - }), - ...(requestParams && { requestParams }), - }; - } - }); - - if (Object.keys(initialResults).length === 0) { - throw new Error( - 'The root index does not have any results. Make sure you have at least one widget that provides results.' - ); - } - - return initialResults; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/stateMappings/index.ts b/packages/instantsearch.js/src/lib/stateMappings/index.ts index 8691b834268..5919ba7e7e7 100644 --- a/packages/instantsearch.js/src/lib/stateMappings/index.ts +++ b/packages/instantsearch.js/src/lib/stateMappings/index.ts @@ -1,2 +1 @@ -export { default as simple } from './simple'; -export { default as singleIndex } from './singleIndex'; +export { simple, singleIndex } from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/stateMappings/simple.ts b/packages/instantsearch.js/src/lib/stateMappings/simple.ts index 3ddc3a21a3f..c3254d8cc06 100644 --- a/packages/instantsearch.js/src/lib/stateMappings/simple.ts +++ b/packages/instantsearch.js/src/lib/stateMappings/simple.ts @@ -1,45 +1,2 @@ -import type { UiState, IndexUiState, StateMapping } from '../../types'; - -function getIndexStateWithoutConfigure( - uiState: TIndexUiState -): Omit { - const { configure, ...trackedUiState } = uiState; - return trackedUiState; -} - -// technically a URL could contain any key, since users provide it, -// which is why the input to this function is UiState, not something -// which excludes "configure" as this function does. -export default function simpleStateMapping< - TUiState extends UiState = UiState ->(): StateMapping { - return { - $$type: 'ais.simple', - - stateToRoute(uiState) { - return Object.keys(uiState).reduce( - (state, indexId) => ({ - ...state, - [indexId]: getIndexStateWithoutConfigure(uiState[indexId]), - }), - {} as TUiState - ); - }, - - routeToState(routeState = {} as TUiState) { - return Object.keys(routeState).reduce( - (state, indexId) => { - const indexState = routeState[indexId]; - if (typeof indexState !== 'object' || indexState === null) { - return state; - } - return { - ...state, - [indexId]: getIndexStateWithoutConfigure(indexState), - }; - }, - {} as TUiState - ); - }, - }; -} +export { simple as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/stateMappings/singleIndex.ts b/packages/instantsearch.js/src/lib/stateMappings/singleIndex.ts index 0ad943877fb..01e36ba138f 100644 --- a/packages/instantsearch.js/src/lib/stateMappings/singleIndex.ts +++ b/packages/instantsearch.js/src/lib/stateMappings/singleIndex.ts @@ -1,26 +1,2 @@ -import type { StateMapping, IndexUiState, UiState } from '../../types'; - -function getIndexStateWithoutConfigure( - uiState: TIndexUiState -): TIndexUiState { - const { configure, ...trackedUiState } = uiState; - return trackedUiState as TIndexUiState; -} - -export default function singleIndexStateMapping< - TUiState extends UiState = UiState ->( - indexName: keyof TUiState -): StateMapping { - return { - $$type: 'ais.singleIndex', - stateToRoute(uiState) { - return getIndexStateWithoutConfigure(uiState[indexName] || {}); - }, - routeToState(routeState = {} as TUiState[typeof indexName]) { - return { - [indexName]: getIndexStateWithoutConfigure(routeState), - } as unknown as TUiState; - }, - }; -} +export { singleIndex as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/createSendEventForFacet-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/createSendEventForFacet-test.ts index fc9cdc1538e..50c0d0df151 100644 --- a/packages/instantsearch.js/src/lib/utils/__tests__/createSendEventForFacet-test.ts +++ b/packages/instantsearch.js/src/lib/utils/__tests__/createSendEventForFacet-test.ts @@ -7,11 +7,11 @@ import algoliasearchHelper from 'algoliasearch-helper'; import { createInstantSearch } from '../../../../test/createInstantSearch'; import { createSendEventForFacet } from '../createSendEventForFacet'; -import { isFacetRefined } from '../isFacetRefined'; +import { isFacetRefined } from '../../../../../instantsearch-core/src/lib/public/isFacetRefined'; import type { SearchClient } from '../../../types'; -jest.mock('../isFacetRefined', () => ({ isFacetRefined: jest.fn() })); +jest.mock('../../../../../instantsearch-core/src/lib/public/isFacetRefined', () => ({ isFacetRefined: jest.fn() })); const createTestEnvironment = () => { const instantSearchInstance = createInstantSearch(); diff --git a/packages/instantsearch.js/src/lib/utils/addWidgetId.ts b/packages/instantsearch.js/src/lib/utils/addWidgetId.ts index b19ec1a8753..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/addWidgetId.ts +++ b/packages/instantsearch.js/src/lib/utils/addWidgetId.ts @@ -1,15 +1 @@ -import type { Widget } from '../../types'; - -let id = 0; - -export function addWidgetId(widget: Widget) { - if (widget.dependsOn !== 'recommend') { - return; - } - - widget.$$id = id++; -} - -export function resetWidgetId() { - id = 0; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/capitalize.ts b/packages/instantsearch.js/src/lib/utils/capitalize.ts index f1e451b6449..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/capitalize.ts +++ b/packages/instantsearch.js/src/lib/utils/capitalize.ts @@ -1,3 +1 @@ -export function capitalize(text: string): string { - return text.toString().charAt(0).toUpperCase() + text.toString().slice(1); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/checkIndexUiState.ts b/packages/instantsearch.js/src/lib/utils/checkIndexUiState.ts index f8874d89021..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/checkIndexUiState.ts +++ b/packages/instantsearch.js/src/lib/utils/checkIndexUiState.ts @@ -1,198 +1 @@ -import { capitalize } from './capitalize'; -import { warning } from './logger'; -import { keys } from './typedObject'; - -import type { Widget, IndexUiState, IndexWidget } from '../../types'; - -// Some connectors are responsible for multiple widgets so we need -// to map them. -function getWidgetNames(connectorName: string): string[] { - switch (connectorName) { - case 'range': - return []; - - case 'menu': - return ['menu', 'menuSelect']; - - default: - return [connectorName]; - } -} - -type WidgetType = Required['$$type']; - -type StateDescription = { - connectors: string[]; - widgets: WidgetType[]; -}; - -type StateToWidgets = { - [TParameter in keyof IndexUiState]: StateDescription; -}; - -type WidgetDescription = { - connectors: string[]; - // no longer widget type, "ais." is stripped - widgets: string[]; -}; - -type MissingWidgets = Array<[string, WidgetDescription]>; - -const stateToWidgetsMap: StateToWidgets = { - query: { - connectors: ['connectSearchBox'], - widgets: ['ais.searchBox', 'ais.autocomplete', 'ais.voiceSearch'], - }, - refinementList: { - connectors: ['connectRefinementList'], - widgets: ['ais.refinementList'], - }, - menu: { - connectors: ['connectMenu'], - widgets: ['ais.menu'], - }, - hierarchicalMenu: { - connectors: ['connectHierarchicalMenu'], - widgets: ['ais.hierarchicalMenu'], - }, - numericMenu: { - connectors: ['connectNumericMenu'], - widgets: ['ais.numericMenu'], - }, - ratingMenu: { - connectors: ['connectRatingMenu'], - widgets: ['ais.ratingMenu'], - }, - range: { - connectors: ['connectRange'], - widgets: ['ais.rangeInput', 'ais.rangeSlider', 'ais.range'], - }, - toggle: { - connectors: ['connectToggleRefinement'], - widgets: ['ais.toggleRefinement'], - }, - geoSearch: { - connectors: ['connectGeoSearch'], - widgets: ['ais.geoSearch'], - }, - sortBy: { - connectors: ['connectSortBy'], - widgets: ['ais.sortBy'], - }, - page: { - connectors: ['connectPagination'], - widgets: ['ais.pagination', 'ais.infiniteHits'], - }, - hitsPerPage: { - connectors: ['connectHitsPerPage'], - widgets: ['ais.hitsPerPage'], - }, - configure: { - connectors: ['connectConfigure'], - widgets: ['ais.configure'], - }, - places: { - connectors: [], - widgets: ['ais.places'], - }, -}; - -type CheckIndexUiStateParams = { - index: IndexWidget; - indexUiState: IndexUiState; -}; - -export function checkIndexUiState({ - index, - indexUiState, -}: CheckIndexUiStateParams) { - const mountedWidgets = index - .getWidgets() - .map((widget) => widget.$$type) - .filter(Boolean); - - const missingWidgets = keys(indexUiState).reduce( - (acc, parameter) => { - const widgetUiState = stateToWidgetsMap[parameter]; - - if (!widgetUiState) { - return acc; - } - - const requiredWidgets = widgetUiState.widgets; - - if ( - requiredWidgets && - !requiredWidgets.some((requiredWidget) => - mountedWidgets.includes(requiredWidget) - ) - ) { - acc.push([ - parameter, - { - connectors: widgetUiState.connectors, - widgets: widgetUiState.widgets.map( - (widgetIdentifier) => widgetIdentifier.split('ais.')[1] - ), - }, - ]); - } - - return acc; - }, - [] - ); - - warning( - missingWidgets.length === 0, - `The UI state for the index "${index.getIndexId()}" is not consistent with the widgets mounted. - -This can happen when the UI state is specified via \`initialUiState\`, \`routing\` or \`setUiState\` but that the widgets responsible for this state were not added. This results in those query parameters not being sent to the API. - -To fully reflect the state, some widgets need to be added to the index "${index.getIndexId()}": - -${missingWidgets - .map(([stateParameter, { widgets }]) => { - return `- \`${stateParameter}\` needs one of these widgets: ${( - [] as string[] - ) - .concat(...widgets.map((name) => getWidgetNames(name))) - .map((name: string) => `"${name}"`) - .join(', ')}`; - }) - .join('\n')} - -If you do not wish to display widgets but still want to support their search parameters, you can mount "virtual widgets" that don't render anything: - -\`\`\` -${missingWidgets - .filter(([_stateParameter, { connectors }]) => { - return connectors.length > 0; - }) - .map(([_stateParameter, { connectors, widgets }]) => { - const capitalizedWidget = capitalize(widgets[0]); - const connectorName = connectors[0]; - - return `const virtual${capitalizedWidget} = ${connectorName}(() => null);`; - }) - .join('\n')} - -search.addWidgets([ - ${missingWidgets - .filter(([_stateParameter, { connectors }]) => { - return connectors.length > 0; - }) - .map(([_stateParameter, { widgets }]) => { - const capitalizedWidget = capitalize(widgets[0]); - - return `virtual${capitalizedWidget}({ /* ... */ })`; - }) - .join(',\n ')} -]); -\`\`\` - -If you're using custom widgets that do set these query parameters, we recommend using connectors instead. - -See https://www.algolia.com/doc/guides/building-search-ui/widgets/customize-an-existing-widget/js/#customize-the-complete-ui-of-the-widgets` - ); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/checkRendering.ts b/packages/instantsearch.js/src/lib/utils/checkRendering.ts index e6d91aceaa5..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/checkRendering.ts +++ b/packages/instantsearch.js/src/lib/utils/checkRendering.ts @@ -1,16 +1 @@ -import { getObjectType } from './getObjectType'; - -import type { Renderer } from '../../types/connector'; - -export function checkRendering( - rendering: any, - usage: string -): asserts rendering is Renderer { - if (rendering === undefined || typeof rendering !== 'function') { - throw new Error(`The render function is not valid (received type ${getObjectType( - rendering - )}). - -${usage}`); - } -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/clearRefinements.ts b/packages/instantsearch.js/src/lib/utils/clearRefinements.ts index 1a357f6df7a..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/clearRefinements.ts +++ b/packages/instantsearch.js/src/lib/utils/clearRefinements.ts @@ -1,43 +1 @@ -import type { - AlgoliaSearchHelper, - SearchParameters, -} from 'algoliasearch-helper'; - -/** - * Clears the refinements of a SearchParameters object based on rules provided. - * The included attributes list is applied before the excluded attributes list. If the list - * is not provided, this list of all the currently refined attributes is used as included attributes. - * @returns search parameters with refinements cleared - */ -export function clearRefinements({ - helper, - attributesToClear = [], -}: { - helper: AlgoliaSearchHelper; - attributesToClear?: string[]; -}): SearchParameters { - let finalState = helper.state.setPage(0); - - finalState = attributesToClear.reduce((state, attribute) => { - if (finalState.isNumericRefined(attribute)) { - return state.removeNumericRefinement(attribute); - } - if (finalState.isHierarchicalFacet(attribute)) { - return state.removeHierarchicalFacetRefinement(attribute); - } - if (finalState.isDisjunctiveFacet(attribute)) { - return state.removeDisjunctiveFacetRefinement(attribute); - } - if (finalState.isConjunctiveFacet(attribute)) { - return state.removeFacetRefinement(attribute); - } - - return state; - }, finalState); - - if (attributesToClear.indexOf('query') !== -1) { - finalState = finalState.setQuery(''); - } - - return finalState; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts index fd6d97fe6c2..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts +++ b/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts @@ -1,15 +1 @@ -import { TAG_REPLACEMENT } from './escape-highlight'; - -import type { HighlightedParts } from '../../types'; - -export function concatHighlightedParts(parts: HighlightedParts[]) { - const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT; - - return parts - .map((part) => - part.isHighlighted - ? highlightPreTag + part.value + highlightPostTag - : part.value - ) - .join(''); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/createConcurrentSafePromise.ts b/packages/instantsearch.js/src/lib/utils/createConcurrentSafePromise.ts index 200ccb5cc67..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/createConcurrentSafePromise.ts +++ b/packages/instantsearch.js/src/lib/utils/createConcurrentSafePromise.ts @@ -1,46 +1 @@ -export type MaybePromise = - | Readonly> - | Promise - | TResolution; - -// copied from -// https://github.com/algolia/autocomplete.js/blob/307a7acc4283e10a19cb7d067f04f1bea79dc56f/packages/autocomplete-core/src/utils/createConcurrentSafePromise.ts#L1:L1 -/** - * Creates a runner that executes promises in a concurrent-safe way. - * - * This is useful to prevent older promises to resolve after a newer promise, - * otherwise resulting in stale resolved values. - */ -export function createConcurrentSafePromise() { - let basePromiseId = -1; - let latestResolvedId = -1; - let latestResolvedValue: TValue | undefined = undefined; - - return function runConcurrentSafePromise(promise: MaybePromise) { - const currentPromiseId = ++basePromiseId; - - return Promise.resolve(promise).then((x) => { - // The promise might take too long to resolve and get outdated. This would - // result in resolving stale values. - // When this happens, we ignore the promise value and return the one - // coming from the latest resolved value. - // - // +----------------------------------+ - // | 100ms | - // | run(1) +---> R1 | - // | 300ms | - // | run(2) +-------------> R2 (SKIP) | - // | 200ms | - // | run(3) +--------> R3 | - // +----------------------------------+ - if (latestResolvedValue && currentPromiseId < latestResolvedId) { - return latestResolvedValue; - } - - latestResolvedId = currentPromiseId; - latestResolvedValue = x; - - return x; - }); - }; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/createSendEventForFacet.ts b/packages/instantsearch.js/src/lib/utils/createSendEventForFacet.ts index 952afbac1e8..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/createSendEventForFacet.ts +++ b/packages/instantsearch.js/src/lib/utils/createSendEventForFacet.ts @@ -1,67 +1 @@ -import { isFacetRefined } from './isFacetRefined'; - -import type { InstantSearch } from '../../types'; -import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; - -type BuiltInSendEventForFacet = ( - eventType: string, - facetValue: string, - eventName?: string, - additionalData?: Record -) => void; -type CustomSendEventForFacet = (customPayload: any) => void; - -export type SendEventForFacet = BuiltInSendEventForFacet & - CustomSendEventForFacet; - -type CreateSendEventForFacetOptions = { - instantSearchInstance: InstantSearch; - helper: AlgoliaSearchHelper; - attribute: string | ((facetValue: string) => string); - widgetType: string; -}; - -export function createSendEventForFacet({ - instantSearchInstance, - helper, - attribute: attr, - widgetType, -}: CreateSendEventForFacetOptions): SendEventForFacet { - const sendEventForFacet: SendEventForFacet = (...args: any[]) => { - const [, facetValue, eventName = 'Filter Applied', additionalData = {}] = - args; - const [eventType, eventModifier]: [string, string] = args[0].split(':'); - const attribute = typeof attr === 'string' ? attr : attr(facetValue); - - if (args.length === 1 && typeof args[0] === 'object') { - instantSearchInstance.sendEventToInsights(args[0]); - } else if (eventType === 'click' && args.length >= 2 && args.length <= 4) { - if (!isFacetRefined(helper, attribute, facetValue)) { - // send event only when the facet is being checked "ON" - instantSearchInstance.sendEventToInsights({ - insightsMethod: 'clickedFilters', - widgetType, - eventType, - eventModifier, - payload: { - eventName, - index: helper.lastResults?.index || helper.state.index, - filters: [`${attribute}:${facetValue}`], - ...additionalData, - }, - attribute, - }); - } - } else if (__DEV__) { - throw new Error( - `You need to pass between two and four arguments like: - sendEvent('click', facetValue, eventName?, additionalData?); - -If you want to send a custom payload, you can pass one object: sendEvent(customPayload); -` - ); - } - }; - - return sendEventForFacet; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts b/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts index adf5fcca9b7..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts +++ b/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts @@ -1,222 +1 @@ -import { serializePayload } from './serializer'; - -import type { InsightsEvent } from '../../middlewares/createInsightsMiddleware'; -import type { InstantSearch, Hit, EscapedHits } from '../../types'; -import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; - -type BuiltInSendEventForHits = ( - eventType: string, - hits: Hit | Hit[], - eventName?: string, - additionalData?: Record -) => void; -type CustomSendEventForHits = (customPayload: any) => void; -export type SendEventForHits = BuiltInSendEventForHits & CustomSendEventForHits; - -export type BuiltInBindEventForHits = ( - eventType: string, - hits: Hit | Hit[], - eventName?: string, - additionalData?: Record -) => string; -export type CustomBindEventForHits = (customPayload: any) => string; -export type BindEventForHits = BuiltInBindEventForHits & CustomBindEventForHits; - -function chunk(arr: TItem[], chunkSize: number = 20): TItem[][] { - const chunks: TItem[][] = []; - for (let i = 0; i < Math.ceil(arr.length / chunkSize); i++) { - chunks.push(arr.slice(i * chunkSize, (i + 1) * chunkSize)); - } - return chunks; -} - -export function _buildEventPayloadsForHits({ - helper, - widgetType, - methodName, - args, - instantSearchInstance, -}: { - widgetType: string; - helper: AlgoliaSearchHelper; - methodName: 'sendEvent' | 'bindEvent'; - args: any[]; - instantSearchInstance: InstantSearch; -}): InsightsEvent[] { - // when there's only one argument, that means it's custom - if (args.length === 1 && typeof args[0] === 'object') { - return [args[0]]; - } - const [eventType, eventModifier]: [string, string] = args[0].split(':'); - - const hits: Hit | Hit[] | EscapedHits = args[1]; - const eventName: string | undefined = args[2]; - const additionalData: Record = args[3] || {}; - - if (!hits) { - if (__DEV__) { - throw new Error( - `You need to pass hit or hits as the second argument like: - ${methodName}(eventType, hit); - ` - ); - } else { - return []; - } - } - if ((eventType === 'click' || eventType === 'conversion') && !eventName) { - if (__DEV__) { - throw new Error( - `You need to pass eventName as the third argument for 'click' or 'conversion' events like: - ${methodName}('click', hit, 'Product Purchased'); - - To learn more about event naming: https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/in-depth/clicks-conversions-best-practices/ - ` - ); - } else { - return []; - } - } - const hitsArray: Hit[] = Array.isArray(hits) ? hits : [hits]; - - if (hitsArray.length === 0) { - return []; - } - const queryID = hitsArray[0].__queryID; - const hitsChunks = chunk(hitsArray); - const objectIDsByChunk = hitsChunks.map((batch) => - batch.map((hit) => hit.objectID) - ); - const positionsByChunk = hitsChunks.map((batch) => - batch.map((hit) => hit.__position) - ); - - if (eventType === 'view') { - if (instantSearchInstance.status !== 'idle') { - return []; - } - return hitsChunks.map((batch, i) => { - return { - insightsMethod: 'viewedObjectIDs', - widgetType, - eventType, - payload: { - eventName: eventName || 'Hits Viewed', - index: helper.lastResults?.index || helper.state.index, - objectIDs: objectIDsByChunk[i], - ...additionalData, - }, - hits: batch, - eventModifier, - }; - }); - } else if (eventType === 'click') { - return hitsChunks.map((batch, i) => { - return { - insightsMethod: 'clickedObjectIDsAfterSearch', - widgetType, - eventType, - payload: { - eventName: eventName || 'Hit Clicked', - index: helper.lastResults?.index || helper.state.index, - queryID, - objectIDs: objectIDsByChunk[i], - positions: positionsByChunk[i], - ...additionalData, - }, - hits: batch, - eventModifier, - }; - }); - } else if (eventType === 'conversion') { - return hitsChunks.map((batch, i) => { - return { - insightsMethod: 'convertedObjectIDsAfterSearch', - widgetType, - eventType, - payload: { - eventName: eventName || 'Hit Converted', - index: helper.lastResults?.index || helper.state.index, - queryID, - objectIDs: objectIDsByChunk[i], - ...additionalData, - }, - hits: batch, - eventModifier, - }; - }); - } else if (__DEV__) { - throw new Error(`eventType("${eventType}") is not supported. - If you want to send a custom payload, you can pass one object: ${methodName}(customPayload); - `); - } else { - return []; - } -} - -export function createSendEventForHits({ - instantSearchInstance, - helper, - widgetType, -}: { - instantSearchInstance: InstantSearch; - helper: AlgoliaSearchHelper; - widgetType: string; -}): SendEventForHits { - let sentEvents: Record = {}; - let timer: ReturnType | undefined = undefined; - - const sendEventForHits: SendEventForHits = (...args: any[]) => { - const payloads = _buildEventPayloadsForHits({ - widgetType, - helper, - methodName: 'sendEvent', - args, - instantSearchInstance, - }); - - payloads.forEach((payload) => { - if ( - payload.eventType === 'click' && - payload.eventModifier === 'internal' && - sentEvents[payload.eventType] - ) { - return; - } - - sentEvents[payload.eventType] = true; - instantSearchInstance.sendEventToInsights(payload); - }); - - clearTimeout(timer); - timer = setTimeout(() => { - sentEvents = {}; - }, 0); - }; - return sendEventForHits; -} - -export function createBindEventForHits({ - helper, - widgetType, - instantSearchInstance, -}: { - helper: AlgoliaSearchHelper; - widgetType: string; - instantSearchInstance: InstantSearch; -}): BindEventForHits { - const bindEventForHits: BindEventForHits = (...args: any[]) => { - const payloads = _buildEventPayloadsForHits({ - widgetType, - helper, - methodName: 'bindEvent', - args, - instantSearchInstance, - }); - - return payloads.length - ? `data-insights-event=${serializePayload(payloads)}` - : ''; - }; - return bindEventForHits; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/debounce.ts b/packages/instantsearch.js/src/lib/utils/debounce.ts index ddb140242fb..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/debounce.ts +++ b/packages/instantsearch.js/src/lib/utils/debounce.ts @@ -1,30 +1 @@ -import type { Awaited } from '../../types'; - -type Func = (...args: any[]) => any; - -export type DebouncedFunction = ( - this: ThisParameterType, - ...args: Parameters -) => Promise>>; - -// Debounce a function call to the trailing edge. -// The debounced function returns a promise. -export function debounce( - func: TFunction, - wait: number -): DebouncedFunction { - let lastTimeout: ReturnType | null = null; - return function (...args) { - return new Promise((resolve, reject) => { - if (lastTimeout) { - clearTimeout(lastTimeout); - } - lastTimeout = setTimeout(() => { - lastTimeout = null; - Promise.resolve(func(...args)) - .then(resolve) - .catch(reject); - }, wait); - }); - }; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/defer.ts b/packages/instantsearch.js/src/lib/utils/defer.ts index 51eb56ae371..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/defer.ts +++ b/packages/instantsearch.js/src/lib/utils/defer.ts @@ -1,51 +1 @@ -const nextMicroTask = Promise.resolve(); - -type Callback = (...args: any[]) => void; -type Defer = { - wait: () => Promise; - cancel: () => void; -}; - -export function defer( - callback: TCallback -): TCallback & Defer { - let progress: Promise | null = null; - let cancelled = false; - - const fn = ((...args: Parameters) => { - if (progress !== null) { - return; - } - - progress = nextMicroTask.then(() => { - progress = null; - - if (cancelled) { - cancelled = false; - return; - } - - callback(...args); - }); - }) as TCallback & Defer; - - fn.wait = () => { - if (progress === null) { - throw new Error( - 'The deferred function should be called before calling `wait()`' - ); - } - - return progress; - }; - - fn.cancel = () => { - if (progress === null) { - return; - } - - cancelled = true; - }; - - return fn; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/documentation.ts b/packages/instantsearch.js/src/lib/utils/documentation.ts index 7261d385d0d..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/documentation.ts +++ b/packages/instantsearch.js/src/lib/utils/documentation.ts @@ -1,29 +1 @@ -type WidgetParam = { - name: string; - connector?: boolean; -}; - -export function createDocumentationLink({ - name, - connector = false, -}: WidgetParam): string { - return [ - 'https://www.algolia.com/doc/api-reference/widgets/', - name, - '/js/', - connector ? '#connector' : '', - ].join(''); -} - -type DocumentationMessageGenerator = (message?: string) => string; - -export function createDocumentationMessageGenerator( - ...widgets: WidgetParam[] -): DocumentationMessageGenerator { - const links = widgets - .map((widget) => createDocumentationLink(widget)) - .join(', '); - - return (message?: string) => - [message, `See documentation: ${links}`].filter(Boolean).join('\n\n'); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/escape-highlight.ts b/packages/instantsearch.js/src/lib/utils/escape-highlight.ts index d1e05d0a8a8..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/escape-highlight.ts +++ b/packages/instantsearch.js/src/lib/utils/escape-highlight.ts @@ -1,79 +1 @@ -import { escape } from './escape-html'; -import { isPlainObject } from './isPlainObject'; - -import type { Hit, FacetHit, EscapedHits } from '../../types'; - -export const TAG_PLACEHOLDER = { - highlightPreTag: '__ais-highlight__', - highlightPostTag: '__/ais-highlight__', -}; - -export const TAG_REPLACEMENT = { - highlightPreTag: '', - highlightPostTag: '', -}; - -// @MAJOR: in the future, this should only escape, not replace -function replaceTagsAndEscape(value: string): string { - return escape(value) - .replace( - new RegExp(TAG_PLACEHOLDER.highlightPreTag, 'g'), - TAG_REPLACEMENT.highlightPreTag - ) - .replace( - new RegExp(TAG_PLACEHOLDER.highlightPostTag, 'g'), - TAG_REPLACEMENT.highlightPostTag - ); -} - -function recursiveEscape(input: any): any { - if (isPlainObject(input) && typeof input.value !== 'string') { - return Object.keys(input).reduce( - (acc, key) => ({ - ...acc, - [key]: recursiveEscape(input[key]), - }), - {} - ); - } - - if (Array.isArray(input)) { - return input.map(recursiveEscape); - } - - return { - ...input, - value: replaceTagsAndEscape(input.value), - }; -} - -export function escapeHits( - hits: THit[] | EscapedHits -): EscapedHits { - if ((hits as EscapedHits).__escaped === undefined) { - // We don't override the value on hit because it will mutate the raw results - // instead we make a shallow copy and we assign the escaped values on it. - hits = hits.map(({ ...hit }) => { - if (hit._highlightResult) { - hit._highlightResult = recursiveEscape(hit._highlightResult); - } - - if (hit._snippetResult) { - hit._snippetResult = recursiveEscape(hit._snippetResult); - } - - return hit; - }); - - (hits as EscapedHits).__escaped = true; - } - - return hits as EscapedHits; -} - -export function escapeFacets(facetHits: FacetHit[]): FacetHit[] { - return facetHits.map((h) => ({ - ...h, - highlighted: replaceTagsAndEscape(h.highlighted), - })); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/escape-html.ts b/packages/instantsearch.js/src/lib/utils/escape-html.ts index ecaee5f031b..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/escape-html.ts +++ b/packages/instantsearch.js/src/lib/utils/escape-html.ts @@ -1,61 +1 @@ -/** - * This implementation is taken from Lodash implementation. - * See: https://github.com/lodash/lodash/blob/4.17.11-npm/escape.js - */ - -// Used to map characters to HTML entities. -const htmlEntities = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', -}; - -// Used to match HTML entities and HTML characters. -const regexUnescapedHtml = /[&<>"']/g; -const regexHasUnescapedHtml = RegExp(regexUnescapedHtml.source); - -/** - * Converts the characters "&", "<", ">", '"', and "'" in `string` to their - * corresponding HTML entities. - */ -export function escape(value: string): string { - return value && regexHasUnescapedHtml.test(value) - ? value.replace( - regexUnescapedHtml, - (character) => htmlEntities[character as keyof typeof htmlEntities] - ) - : value; -} - -/** - * This implementation is taken from Lodash implementation. - * See: https://github.com/lodash/lodash/blob/4.17.11-npm/unescape.js - */ - -// Used to map HTML entities to characters. -const htmlCharacters = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': "'", -}; - -// Used to match HTML entities and HTML characters. -const regexEscapedHtml = /&(amp|quot|lt|gt|#39);/g; -const regexHasEscapedHtml = RegExp(regexEscapedHtml.source); - -/** - * Converts the HTML entities "&", "<", ">", '"', and "'" in `string` to their - * characters. - */ -export function unescape(value: string): string { - return value && regexHasEscapedHtml.test(value) - ? value.replace( - regexEscapedHtml, - (character) => htmlCharacters[character as keyof typeof htmlCharacters] - ) - : value; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/escapeFacetValue.ts b/packages/instantsearch.js/src/lib/utils/escapeFacetValue.ts index 99065bc13e9..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/escapeFacetValue.ts +++ b/packages/instantsearch.js/src/lib/utils/escapeFacetValue.ts @@ -1,21 +1 @@ -type FacetValue = string | number | boolean | undefined; - -export function unescapeFacetValue( - value: TFacetValue -): TFacetValue { - if (typeof value === 'string') { - return value.replace(/^\\-/, '-') as TFacetValue; - } - - return value; -} - -export function escapeFacetValue( - value: TFacetValue -): TFacetValue { - if ((typeof value === 'number' && value < 0) || typeof value === 'string') { - return String(value).replace(/^-/, '\\-') as TFacetValue; - } - - return value; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/find.ts b/packages/instantsearch.js/src/lib/utils/find.ts index 33a9ebb3c1e..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/find.ts +++ b/packages/instantsearch.js/src/lib/utils/find.ts @@ -1,21 +1 @@ -// We aren't using the native `Array.prototype.find` because the refactor away from Lodash is not -// published as a major version. -// Relying on the `find` polyfill on user-land, which before was only required for niche use-cases, -// was decided as too risky. -// @MAJOR Replace with the native `Array.prototype.find` method -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find -export function find( - items: TItem[], - predicate: (value: TItem, index: number, obj: TItem[]) => boolean -): TItem | undefined { - let value: TItem; - for (let i = 0; i < items.length; i++) { - value = items[i]; - // inlined for performance: if (Call(predicate, thisArg, [value, i, list])) { - if (predicate(value, i, items)) { - return value; - } - } - - return undefined; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/findIndex.ts b/packages/instantsearch.js/src/lib/utils/findIndex.ts index efc19ad5c4f..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/findIndex.ts +++ b/packages/instantsearch.js/src/lib/utils/findIndex.ts @@ -1,21 +1 @@ -// We aren't using the native `Array.prototype.findIndex` because the refactor away from Lodash is not -// published as a major version. -// Relying on the `findIndex` polyfill on user-land, which before was only required for niche use-cases, -// was decided as too risky. -// @MAJOR Replace with the native `Array.prototype.findIndex` method -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex -export function findIndex( - array: TItem[], - comparator: (value: TItem) => boolean -): number { - if (!Array.isArray(array)) { - return -1; - } - - for (let i = 0; i < array.length; i++) { - if (comparator(array[i])) { - return i; - } - } - return -1; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/flat.ts b/packages/instantsearch.js/src/lib/utils/flat.ts index 81ed14dcf4c..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/flat.ts +++ b/packages/instantsearch.js/src/lib/utils/flat.ts @@ -1,3 +1 @@ -export function flat(arr: T[][]): T[] { - return arr.reduce((acc, array) => acc.concat(array), []); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/geo-search.ts b/packages/instantsearch.js/src/lib/utils/geo-search.ts index ec3e0b0a927..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/geo-search.ts +++ b/packages/instantsearch.js/src/lib/utils/geo-search.ts @@ -1,74 +1 @@ -const latLngRegExp = /^(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)$/; - -export function aroundLatLngToPosition(value: string) { - const pattern = value.match(latLngRegExp); - - // Since the value provided is the one send with the request, the API should - // throw an error due to the wrong format. So throw an error should be safe. - if (!pattern) { - throw new Error(`Invalid value for "aroundLatLng" parameter: "${value}"`); - } - - return { - lat: parseFloat(pattern[1]), - lng: parseFloat(pattern[2]), - }; -} - -export type LatLng = Array<[number, number, number, number]>; - -function insideBoundingBoxArrayToBoundingBox(value: LatLng) { - const [ - [neLat, neLng, swLat, swLng] = [undefined, undefined, undefined, undefined], - ] = value; - - // Since the value provided is the one send with the request, the API should - // throw an error due to the wrong format. So throw an error should be safe. - if (!neLat || !neLng || !swLat || !swLng) { - throw new Error( - `Invalid value for "insideBoundingBox" parameter: [${value}]` - ); - } - - return { - northEast: { - lat: neLat, - lng: neLng, - }, - southWest: { - lat: swLat, - lng: swLng, - }, - }; -} - -function insideBoundingBoxStringToBoundingBox(value: string) { - const [neLat, neLng, swLat, swLng] = value.split(',').map(parseFloat); - - // Since the value provided is the one send with the request, the API should - // throw an error due to the wrong format. So throw an error should be safe. - if (!neLat || !neLng || !swLat || !swLng) { - throw new Error( - `Invalid value for "insideBoundingBox" parameter: "${value}"` - ); - } - - return { - northEast: { - lat: neLat, - lng: neLng, - }, - southWest: { - lat: swLat, - lng: swLng, - }, - }; -} - -export function insideBoundingBoxToBoundingBox(value: string | LatLng) { - if (Array.isArray(value)) { - return insideBoundingBoxArrayToBoundingBox(value); - } - - return insideBoundingBoxStringToBoundingBox(value); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getAlgoliaAgent.ts b/packages/instantsearch.js/src/lib/utils/getAlgoliaAgent.ts index 8437d22b30f..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getAlgoliaAgent.ts +++ b/packages/instantsearch.js/src/lib/utils/getAlgoliaAgent.ts @@ -1,10 +1 @@ -type v4 = { transporter?: { userAgent: { value: string } } }; -type v3 = { _ua: string }; -type AnySearchClient = v4 & v3; - -export function getAlgoliaAgent(client: unknown): string { - const clientTyped = client as AnySearchClient; - return clientTyped.transporter && clientTyped.transporter.userAgent - ? clientTyped.transporter.userAgent.value - : clientTyped._ua; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getAppIdAndApiKey.ts b/packages/instantsearch.js/src/lib/utils/getAppIdAndApiKey.ts index 707656fbd1d..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getAppIdAndApiKey.ts +++ b/packages/instantsearch.js/src/lib/utils/getAppIdAndApiKey.ts @@ -1,23 +1 @@ -// typed as any, since it accepts the _real_ js clients, not the interface we otherwise expect -export function getAppIdAndApiKey( - searchClient: any -): [appId: string, apiKey: string] | [appId: undefined, apiKey: undefined] { - if (searchClient.appId && searchClient.apiKey) { - // searchClient v5 - return [searchClient.appId, searchClient.apiKey]; - } else if (searchClient.transporter) { - // searchClient v4 or v5 - const transporter = searchClient.transporter; - const headers = transporter.headers || transporter.baseHeaders; - const queryParameters = - transporter.queryParameters || transporter.baseQueryParameters; - const APP_ID = 'x-algolia-application-id'; - const API_KEY = 'x-algolia-api-key'; - const appId = headers[APP_ID] || queryParameters[APP_ID]; - const apiKey = headers[API_KEY] || queryParameters[API_KEY]; - return [appId, apiKey]; - } else { - // searchClient v3 - return [searchClient.applicationID, searchClient.apiKey]; - } -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts b/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts index 03aba197eb2..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts +++ b/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts @@ -1,20 +1 @@ -import { unescape } from './escape-html'; - -import type { HighlightedParts } from '../../types'; - -const hasAlphanumeric = new RegExp(/\w/i); - -export function getHighlightFromSiblings(parts: HighlightedParts[], i: number) { - const current = parts[i]; - const isNextHighlighted = parts[i + 1]?.isHighlighted || true; - const isPreviousHighlighted = parts[i - 1]?.isHighlighted || true; - - if ( - !hasAlphanumeric.test(unescape(current.value)) && - isPreviousHighlighted === isNextHighlighted - ) { - return isPreviousHighlighted; - } - - return current.isHighlighted; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts index c95f2269fb5..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts +++ b/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts @@ -1,30 +1 @@ -import { TAG_REPLACEMENT } from './escape-highlight'; - -export function getHighlightedParts(highlightedValue: string) { - // @MAJOR: this should use TAG_PLACEHOLDER - const { highlightPostTag, highlightPreTag } = TAG_REPLACEMENT; - - const splitByPreTag = highlightedValue.split(highlightPreTag); - const firstValue = splitByPreTag.shift(); - const elements = !firstValue - ? [] - : [{ value: firstValue, isHighlighted: false }]; - - splitByPreTag.forEach((split) => { - const splitByPostTag = split.split(highlightPostTag); - - elements.push({ - value: splitByPostTag[0], - isHighlighted: true, - }); - - if (splitByPostTag[1] !== '') { - elements.push({ - value: splitByPostTag[1], - isHighlighted: false, - }); - } - }); - - return elements; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getObjectType.ts b/packages/instantsearch.js/src/lib/utils/getObjectType.ts index cb6707fddb5..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getObjectType.ts +++ b/packages/instantsearch.js/src/lib/utils/getObjectType.ts @@ -1,3 +1 @@ -export function getObjectType(object: unknown): string { - return Object.prototype.toString.call(object).slice(8, -1); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getPropertyByPath.ts b/packages/instantsearch.js/src/lib/utils/getPropertyByPath.ts index 5c86b33be58..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getPropertyByPath.ts +++ b/packages/instantsearch.js/src/lib/utils/getPropertyByPath.ts @@ -1,8 +1 @@ -export function getPropertyByPath( - object: Record | undefined, - path: string | string[] -): any { - const parts = Array.isArray(path) ? path : path.split('.'); - - return parts.reduce((current, key) => current && current[key], object); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getRefinements.ts b/packages/instantsearch.js/src/lib/utils/getRefinements.ts index caca0f47a6e..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getRefinements.ts +++ b/packages/instantsearch.js/src/lib/utils/getRefinements.ts @@ -1,217 +1 @@ -import { unescapeFacetValue, escapeFacetValue } from './escapeFacetValue'; -import { find } from './find'; - -import type { SearchParameters, SearchResults } from 'algoliasearch-helper'; - -export type FacetRefinement = { - type: 'facet' | 'disjunctive' | 'hierarchical'; - attribute: string; - name: string; - escapedValue: string; - count?: number; - exhaustive?: boolean; -}; - -export type TagRefinement = { - type: 'tag'; - attribute: string; - name: string; -}; - -export type QueryRefinement = { - type: 'query'; - attribute: 'query'; - query: string; - name: string; -}; - -export type NumericRefinement = { - type: 'numeric'; - numericValue: number; - operator: '<' | '<=' | '=' | '!=' | '>=' | '>'; - attribute: string; - name: string; - count?: number; - exhaustive?: boolean; -}; - -export type FacetExcludeRefinement = { - type: 'exclude'; - exclude: boolean; - attribute: string; - name: string; - count?: number; - exhaustive?: boolean; -}; - -export type Refinement = - | FacetRefinement - | QueryRefinement - | NumericRefinement - | FacetExcludeRefinement - | TagRefinement; - -function getRefinement( - state: SearchParameters, - type: FacetRefinement['type'], - attribute: FacetRefinement['attribute'], - name: FacetRefinement['name'], - resultsFacets: SearchResults['facets' | 'hierarchicalFacets'] = [] -): FacetRefinement { - const res: FacetRefinement = { - type, - attribute, - name, - escapedValue: escapeFacetValue(name), - }; - let facet: any = find( - resultsFacets, - (resultsFacet) => resultsFacet.name === attribute - ); - let count: number; - - if (type === 'hierarchical') { - const facetDeclaration = state.getHierarchicalFacetByName(attribute); - const nameParts = name.split(facetDeclaration.separator); - - const getFacetRefinement = - (facetData: any): ((refinementKey: string) => any) => - (refinementKey: string): any => - facetData[refinementKey]; - - for (let i = 0; facet !== undefined && i < nameParts.length; ++i) { - facet = - facet && - facet.data && - find( - Object.keys(facet.data).map(getFacetRefinement(facet.data)), - (refinement) => refinement.name === nameParts[i] - ); - } - - count = facet && facet.count; - } else { - count = facet && facet.data && facet.data[res.name]; - } - - if (count !== undefined) { - res.count = count; - } - - if (facet && facet.exhaustive !== undefined) { - res.exhaustive = facet.exhaustive; - } - - return res; -} - -export function getRefinements( - _results: SearchResults | Record | null, - state: SearchParameters, - includesQuery: boolean = false -): Refinement[] { - const results = _results || {}; - const refinements: Refinement[] = []; - const { - facetsRefinements = {}, - facetsExcludes = {}, - disjunctiveFacetsRefinements = {}, - hierarchicalFacetsRefinements = {}, - numericRefinements = {}, - tagRefinements = [], - } = state; - - Object.keys(facetsRefinements).forEach((attribute) => { - const refinementNames = facetsRefinements[attribute]; - - refinementNames.forEach((refinementName) => { - refinements.push( - getRefinement(state, 'facet', attribute, refinementName, results.facets) - ); - }); - }); - - Object.keys(facetsExcludes).forEach((attribute) => { - const refinementNames = facetsExcludes[attribute]; - - refinementNames.forEach((refinementName) => { - refinements.push({ - type: 'exclude', - attribute, - name: refinementName, - exclude: true, - }); - }); - }); - - Object.keys(disjunctiveFacetsRefinements).forEach((attribute) => { - const refinementNames = disjunctiveFacetsRefinements[attribute]; - - refinementNames.forEach((refinementName) => { - refinements.push( - getRefinement( - state, - 'disjunctive', - attribute, - // We unescape any disjunctive refined values with `unescapeFacetValue` because - // they can be escaped on negative numeric values with `escapeFacetValue`. - unescapeFacetValue(refinementName), - results.disjunctiveFacets - ) - ); - }); - }); - - Object.keys(hierarchicalFacetsRefinements).forEach((attribute) => { - const refinementNames = hierarchicalFacetsRefinements[attribute]; - - refinementNames.forEach((refinement) => { - refinements.push( - getRefinement( - state, - 'hierarchical', - attribute, - refinement, - results.hierarchicalFacets - ) - ); - }); - }); - - Object.keys(numericRefinements).forEach((attribute) => { - const operators = numericRefinements[attribute]; - - Object.keys(operators).forEach((operatorOriginal) => { - const operator = operatorOriginal as SearchParameters.Operator; - const valueOrValues = operators[operator]; - const refinementNames = Array.isArray(valueOrValues) - ? valueOrValues - : [valueOrValues]; - - refinementNames.forEach((refinementName: any) => { - refinements.push({ - type: 'numeric', - attribute, - name: `${refinementName}`, - numericValue: refinementName, - operator: operator as NumericRefinement['operator'], - }); - }); - }); - }); - - tagRefinements.forEach((refinementName) => { - refinements.push({ type: 'tag', attribute: '_tags', name: refinementName }); - }); - - if (includesQuery && state.query && state.query.trim()) { - refinements.push({ - attribute: 'query', - type: 'query', - name: state.query, - query: state.query, - }); - } - - return refinements; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/getWidgetAttribute.ts b/packages/instantsearch.js/src/lib/utils/getWidgetAttribute.ts index 153c99bc4e1..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/getWidgetAttribute.ts +++ b/packages/instantsearch.js/src/lib/utils/getWidgetAttribute.ts @@ -1,31 +1 @@ -import type { InitOptions, Widget, IndexWidget } from '../../types'; - -export function getWidgetAttribute( - widget: Widget | IndexWidget, - initOptions: InitOptions -): string { - const renderState = widget.getWidgetRenderState?.(initOptions); - - let attribute = null; - - if (renderState && renderState.widgetParams) { - // casting as widgetParams is checked just before - const widgetParams = renderState.widgetParams as Record; - - if (widgetParams.attribute) { - attribute = widgetParams.attribute; - } else if (Array.isArray(widgetParams.attributes)) { - attribute = widgetParams.attributes[0]; - } - } - - if (typeof attribute !== 'string') { - throw new Error(`Could not find the attribute of the widget: - -${JSON.stringify(widget)} - -Please check whether the widget's getWidgetRenderState returns widgetParams.attribute correctly.`); - } - - return attribute; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/hits-absolute-position.ts b/packages/instantsearch.js/src/lib/utils/hits-absolute-position.ts index 48e83f471b6..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/hits-absolute-position.ts +++ b/packages/instantsearch.js/src/lib/utils/hits-absolute-position.ts @@ -1,12 +1 @@ -import type { AlgoliaHit } from '../../types'; - -export function addAbsolutePosition( - hits: THit[], - page: number, - hitsPerPage: number -): Array { - return hits.map((hit, idx) => ({ - ...hit, - __position: hitsPerPage * page + idx + 1, - })); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/hits-query-id.ts b/packages/instantsearch.js/src/lib/utils/hits-query-id.ts index 3dc61f63f0d..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/hits-query-id.ts +++ b/packages/instantsearch.js/src/lib/utils/hits-query-id.ts @@ -1,14 +1 @@ -import type { AlgoliaHit } from '../../types'; - -export function addQueryID( - hits: THit[], - queryID?: string -): Array { - if (!queryID) { - return hits; - } - return hits.map((hit) => ({ - ...hit, - __queryID: queryID, - })); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/hydrateRecommendCache.ts b/packages/instantsearch.js/src/lib/utils/hydrateRecommendCache.ts index f91d7658bc7..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/hydrateRecommendCache.ts +++ b/packages/instantsearch.js/src/lib/utils/hydrateRecommendCache.ts @@ -1,20 +1 @@ -import type { InitialResults } from '../../types'; -import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; - -export function hydrateRecommendCache( - helper: AlgoliaSearchHelper, - initialResults: InitialResults -) { - const recommendCache = Object.keys(initialResults).reduce( - (acc, indexName) => { - const initialResult = initialResults[indexName]; - if (initialResult.recommendResults) { - // @MAJOR: Use `Object.assign` instead of spread operator - return { ...acc, ...initialResult.recommendResults.results }; - } - return acc; - }, - {} - ); - helper._recommendCache = recommendCache; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts b/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts index 2d1bf55daf6..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts +++ b/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts @@ -1,190 +1 @@ -import type { - SearchClient, - InitialResults, - ClientV3_4, - SearchOptions, - SearchResponse, - CompositionClient, -} from '../../types'; - -type ClientWithCache = SearchClient & { cache: Record }; -type ClientWithTransporter = ClientV3_4 & { - transporter: { responsesCache: any }; - search: (requests: any, ...args: any[]) => any; -}; - -function getServerResults(entry: InitialResults[string]) { - return entry.compositionFeedsResults?.length - ? entry.compositionFeedsResults - : entry.results || []; -} - -export function hydrateSearchClient( - client: (SearchClient | CompositionClient) & { - _cacheHydrated?: boolean; - _useCache?: boolean; - }, - results?: InitialResults -) { - if (!results) { - return; - } - - // Disable cache hydration on: - // - Algoliasearch API Client < v4 with cache disabled - // - Third party clients (detected by the `addAlgoliaAgent` function missing) - - if ( - (!('transporter' in client) || client._cacheHydrated) && - (!client._useCache || typeof client.addAlgoliaAgent !== 'function') - ) { - return; - } - - const cachedRequest = [ - Object.keys(results).reduce< - Array<{ - params?: string; - indexName?: string; - }> - >((acc, key) => { - const entry = results[key]; - const { state, requestParams } = entry; - const serverResults = getServerResults(entry); - const mappedResults = - serverResults && state - ? serverResults.map((result, idx) => ({ - indexName: state.index || result.index, - // We normalize the params received from the server as they can - // be serialized differently depending on the engine. - // We use search parameters from the server request to craft the cache - // if possible, and fallback to those from results if not. - ...(requestParams?.[idx] || result.params - ? { - params: serializeQueryParameters( - requestParams?.[idx] || - deserializeQueryParameters(result.params) - ), - } - : {}), - })) - : []; - return acc.concat(mappedResults); - }, []), - ]; - - const cachedResults = Object.keys(results).reduce>>( - (acc, key) => { - const res = getServerResults(results[key]); - if (!res) { - return acc; - } - return acc.concat(res); - }, - [] - ); - - // Algoliasearch API Client >= v4 - // To hydrate the client we need to populate the cache with the data from - // the server (done in `hydrateSearchClientWithMultiIndexRequest` or - // `hydrateSearchClientWithSingleIndexRequest`). But since there is no way - // for us to compute the key the same way as `algoliasearch-client` we need - // to populate it on a custom key and override the `search` method to - // search on it first. - if ('transporter' in client && !client._cacheHydrated) { - client._cacheHydrated = true; - - const baseMethod = client.search.bind(client) as unknown as ( - query: any, - ...args: any[] - ) => any; - client.search = ( - requests: Parameters<(SearchClient | CompositionClient)['search']>[0], - // @ts-ignore wanting type checks for v3 on this would make this too complex - ...methodArgs - ) => { - const requestsWithSerializedParams = Array.isArray(requests) - ? // search client - requests.map((request) => ({ - ...request, - params: serializeQueryParameters(request.params), - })) - : // composition client - serializeQueryParameters(requests.requestBody.params); - - return (client as ClientWithTransporter).transporter.responsesCache.get( - { - method: 'search', - args: [requestsWithSerializedParams, ...methodArgs], - }, - () => { - return baseMethod(requests, ...methodArgs); - } - ); - }; - - (client as ClientWithTransporter).transporter.responsesCache.set( - { - method: 'search', - args: cachedRequest, - }, - { - results: cachedResults, - } - ); - } - - // Algoliasearch API Client < v4 - // Prior to client v4 we didn't have a proper API to hydrate the client - // cache from the outside. The following code populates the cache with - // a single-index result. You can find more information about the - // computation of the key inside the client (see link below). - // https://github.com/algolia/algoliasearch-client-javascript/blob/c27e89ff92b2a854ae6f40dc524bffe0f0cbc169/src/AlgoliaSearchCore.js#L232-L240 - if (!('transporter' in client)) { - const cacheKey = `/1/indexes/*/queries_body_${JSON.stringify({ - requests: cachedRequest, - })}`; - - (client as ClientWithCache).cache = { - ...(client as ClientWithCache).cache, - [cacheKey]: JSON.stringify({ - results: Object.keys(results).map((key) => - getServerResults(results[key]) - ), - }), - }; - } -} - -function deserializeQueryParameters(parameters: string) { - return parameters.split('&').reduce>((acc, parameter) => { - const [key, value] = parameter.split('='); - acc[key] = value ? decodeURIComponent(value) : ''; - return acc; - }, {}); -} - -// This function is copied from the algoliasearch v4 API Client. If modified, -// consider updating it also in `serializeQueryParameters` from `@algolia/transporter`. -function serializeQueryParameters(parameters: SearchOptions) { - const isObjectOrArray = (value: any) => - Object.prototype.toString.call(value) === '[object Object]' || - Object.prototype.toString.call(value) === '[object Array]'; - - const encode = (format: string, ...args: [string, any]) => { - let i = 0; - return format.replace(/%s/g, () => encodeURIComponent(args[i++])); - }; - - return Object.keys(parameters) - .map((key) => - encode( - '%s=%s', - key, - isObjectOrArray(parameters[key as keyof SearchOptions]) - ? JSON.stringify(parameters[key as keyof SearchOptions]) - : parameters[key as keyof SearchOptions] - ) - ) - .join('&'); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/index.ts b/packages/instantsearch.js/src/lib/utils/index.ts index 9e0c9b2ff9b..d59cbccc73f 100644 --- a/packages/instantsearch.js/src/lib/utils/index.ts +++ b/packages/instantsearch.js/src/lib/utils/index.ts @@ -1,54 +1,3 @@ -export * from './addWidgetId'; -export * from './capitalize'; -export * from './checkIndexUiState'; -export * from './checkRendering'; -export * from './clearRefinements'; -export * from './concatHighlightedParts'; -export * from './createConcurrentSafePromise'; -export * from './createSendEventForFacet'; -export * from './createSendEventForHits'; -export * from './setIndexHelperState'; -export * from './isIndexWidget'; -export * from './debounce'; -export * from './defer'; -export * from './documentation'; -export * from './escape-highlight'; -export * from './sendChatMessageFeedback'; -export * from './escape-html'; -export * from './escapeFacetValue'; -export * from './find'; -export * from './findIndex'; -export * from './geo-search'; -export * from './getAlgoliaAgent'; -export * from './getAppIdAndApiKey'; +export * from 'instantsearch-core'; export * from './getContainerNode'; -export * from './getHighlightedParts'; -export * from './getHighlightFromSiblings'; -export * from './getObjectType'; -export * from './getPropertyByPath'; -export * from './getRefinements'; -export * from './getWidgetAttribute'; -export * from './hits-absolute-position'; -export * from './hits-query-id'; -export * from './hydrateRecommendCache'; -export * from './hydrateSearchClient'; export * from './isDomElement'; -export * from './isEqual'; -export * from './isFacetRefined'; -export * from './isFiniteNumber'; -export * from './isPlainObject'; -export * from './isSpecialClick'; -export * from './walkIndex'; -export * from './isTwoPassWidget'; -export * from './logger'; -export * from './mergeSearchParameters'; -export * from './omit'; -export * from './noop'; -export * from './range'; -export * from './render-args'; -export * from './resolveSearchParameters'; -export * from './reverseHighlightedParts'; -export * from './safelyRunOnBrowser'; -export * from './serializer'; -export * from './toArray'; -export * from './uniq'; diff --git a/packages/instantsearch.js/src/lib/utils/isEqual.ts b/packages/instantsearch.js/src/lib/utils/isEqual.ts index 88f496fc769..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/isEqual.ts +++ b/packages/instantsearch.js/src/lib/utils/isEqual.ts @@ -1,36 +1 @@ -function isPrimitive(obj: any): boolean { - return obj !== Object(obj); -} - -export function isEqual(first: any, second: any): boolean { - if (first === second) { - return true; - } - - if ( - isPrimitive(first) || - isPrimitive(second) || - typeof first === 'function' || - typeof second === 'function' - ) { - return first === second; - } - - if (Object.keys(first).length !== Object.keys(second).length) { - return false; - } - - // @TODO avoid for..of because of the large polyfill - // eslint-disable-next-line instantsearch/no-for-of - for (const key of Object.keys(first)) { - if (!(key in second)) { - return false; - } - - if (!isEqual(first[key], second[key])) { - return false; - } - } - - return true; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/isFacetRefined.ts b/packages/instantsearch.js/src/lib/utils/isFacetRefined.ts index c28ebb75ed3..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/isFacetRefined.ts +++ b/packages/instantsearch.js/src/lib/utils/isFacetRefined.ts @@ -1,15 +1 @@ -import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; - -export function isFacetRefined( - helper: AlgoliaSearchHelper, - facet: string, - value: string -) { - if (helper.state.isHierarchicalFacet(facet)) { - return helper.state.isHierarchicalFacetRefined(facet, value); - } else if (helper.state.isConjunctiveFacet(facet)) { - return helper.state.isFacetRefined(facet, value); - } else { - return helper.state.isDisjunctiveFacetRefined(facet, value); - } -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/isFiniteNumber.ts b/packages/instantsearch.js/src/lib/utils/isFiniteNumber.ts index fb6c2fd47d5..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/isFiniteNumber.ts +++ b/packages/instantsearch.js/src/lib/utils/isFiniteNumber.ts @@ -1,7 +1 @@ -// This is the `Number.isFinite()` polyfill recommended by MDN. -// We do not provide any tests for this function. -// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isFinite#Polyfill -// @MAJOR Replace with the native `Number.isFinite` method -export function isFiniteNumber(value: any): value is number { - return typeof value === 'number' && isFinite(value); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts b/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts index 0cfa1af05eb..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts +++ b/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts @@ -1,9 +1 @@ -import { indexWidgetTypes } from '../../types'; - -import type { Widget, IndexWidget } from '../../types'; - -export function isIndexWidget( - widget: Widget | IndexWidget -): widget is IndexWidget { - return indexWidgetTypes.includes(widget.$$type as (typeof indexWidgetTypes)[number]); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/isPlainObject.ts b/packages/instantsearch.js/src/lib/utils/isPlainObject.ts index e54c40355f2..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/isPlainObject.ts +++ b/packages/instantsearch.js/src/lib/utils/isPlainObject.ts @@ -1,40 +1 @@ -/** - * This implementation is taken from Lodash implementation. - * See: https://github.com/lodash/lodash/blob/master/isPlainObject.js - */ - -function getTag(value: any): string { - if (value === null) { - return value === undefined ? '[object Undefined]' : '[object Null]'; - } - - return Object.prototype.toString.call(value); -} - -function isObjectLike(value: any): boolean { - return typeof value === 'object' && value !== null; -} - -/** - * Checks if `value` is a plain object. - * - * A plain object is an object created by the `Object` - * constructor or with a `[[Prototype]]` of `null`. - */ -export function isPlainObject(value: any): boolean { - if (!isObjectLike(value) || getTag(value) !== '[object Object]') { - return false; - } - - if (Object.getPrototypeOf(value) === null) { - return true; - } - - let proto = value; - - while (Object.getPrototypeOf(proto) !== null) { - proto = Object.getPrototypeOf(proto); - } - - return Object.getPrototypeOf(value) === proto; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/isSpecialClick.ts b/packages/instantsearch.js/src/lib/utils/isSpecialClick.ts index e4877da9816..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/isSpecialClick.ts +++ b/packages/instantsearch.js/src/lib/utils/isSpecialClick.ts @@ -1,11 +1 @@ -export function isSpecialClick(event: MouseEvent): boolean { - const isMiddleClick = event.button === 1; - - return ( - isMiddleClick || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey - ); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/isTwoPassWidget.ts b/packages/instantsearch.js/src/lib/utils/isTwoPassWidget.ts index 93911389fe5..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/isTwoPassWidget.ts +++ b/packages/instantsearch.js/src/lib/utils/isTwoPassWidget.ts @@ -1,11 +1 @@ -import type { Widget, IndexWidget } from '../../types'; - -/** - * Returns true if the widget requires a second SSR pass to discover and - * mount child widgets (e.g. DynamicWidgets, Feeds). - */ -export function isTwoPassWidget(widget: Widget | IndexWidget): boolean { - return ( - widget.$$type === 'ais.dynamicWidgets' || widget.$$type === 'ais.feeds' - ); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/logger.ts b/packages/instantsearch.js/src/lib/utils/logger.ts index 512789672a9..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/logger.ts +++ b/packages/instantsearch.js/src/lib/utils/logger.ts @@ -1,69 +1 @@ -import { noop } from './noop'; - -type Warn = (message: string) => void; - -type Warning = { - (condition: boolean, message: string): void; - cache: { [message: string]: boolean }; -}; - -/** - * Logs a warning when this function is called, in development environment only. - */ -let deprecate = any>( - fn: TCallback, - // @ts-ignore this parameter is used in the __DEV__ branch - // eslint-disable-next-line no-unused-vars - message: string -) => fn; - -/** - * Logs a warning - * This is used to log issues in development environment only. - */ -let warn: Warn = noop; - -/** - * Logs a warning if the condition is not met. - * This is used to log issues in development environment only. - */ -let warning = noop as Warning; - -if (__DEV__) { - warn = (message) => { - // eslint-disable-next-line no-console - console.warn(`[InstantSearch.js]: ${message.trim()}`); - }; - - deprecate = (fn, message) => { - let hasAlreadyPrinted = false; - - return function (...args) { - if (!hasAlreadyPrinted) { - hasAlreadyPrinted = true; - - warn(message); - } - - return fn(...args); - } as typeof fn; - }; - - warning = ((condition, message) => { - if (condition) { - return; - } - - const hasAlreadyPrinted = warning.cache[message]; - - if (!hasAlreadyPrinted) { - warning.cache[message] = true; - - warn(message); - } - }) as Warning; - - warning.cache = {}; -} - -export { warn, deprecate, warning }; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/mergeSearchParameters.ts b/packages/instantsearch.js/src/lib/utils/mergeSearchParameters.ts index 16309a425fa..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/mergeSearchParameters.ts +++ b/packages/instantsearch.js/src/lib/utils/mergeSearchParameters.ts @@ -1,155 +1 @@ -import { findIndex } from './findIndex'; -import { uniq } from './uniq'; - -import type { SearchParameters } from 'algoliasearch-helper'; - -type Merger = ( - left: SearchParameters, - right: SearchParameters -) => SearchParameters; - -const mergeWithRest: Merger = (left, right) => { - const { - facets, - disjunctiveFacets, - facetsRefinements, - facetsExcludes, - disjunctiveFacetsRefinements, - numericRefinements, - tagRefinements, - hierarchicalFacets, - hierarchicalFacetsRefinements, - ruleContexts, - ...rest - } = right; - - return left.setQueryParameters(rest); -}; - -// Merge facets -const mergeFacets: Merger = (left, right) => - right.facets!.reduce((_, name) => _.addFacet(name), left); - -const mergeDisjunctiveFacets: Merger = (left, right) => - right.disjunctiveFacets.reduce( - (_, name) => _.addDisjunctiveFacet(name), - left - ); - -const mergeHierarchicalFacets: Merger = (left, right) => - left.setQueryParameters({ - hierarchicalFacets: right.hierarchicalFacets.reduce((facets, facet) => { - const index = findIndex(facets, (_) => _.name === facet.name); - - if (index === -1) { - return facets.concat(facet); - } - - const nextFacets = facets.slice(); - nextFacets.splice(index, 1, facet); - - return nextFacets; - }, left.hierarchicalFacets), - }); - -// Merge facet refinements -const mergeTagRefinements: Merger = (left, right) => - right.tagRefinements.reduce((_, value) => _.addTagRefinement(value), left); - -const mergeFacetRefinements: Merger = (left, right) => - left.setQueryParameters({ - facetsRefinements: { - ...left.facetsRefinements, - ...right.facetsRefinements, - }, - }); - -const mergeFacetsExcludes: Merger = (left, right) => - left.setQueryParameters({ - facetsExcludes: { - ...left.facetsExcludes, - ...right.facetsExcludes, - }, - }); - -const mergeDisjunctiveFacetsRefinements: Merger = (left, right) => - left.setQueryParameters({ - disjunctiveFacetsRefinements: { - ...left.disjunctiveFacetsRefinements, - ...right.disjunctiveFacetsRefinements, - }, - }); - -const mergeNumericRefinements: Merger = (left, right) => - left.setQueryParameters({ - numericRefinements: { - ...left.numericRefinements, - ...right.numericRefinements, - }, - }); - -const mergeHierarchicalFacetsRefinements: Merger = (left, right) => - left.setQueryParameters({ - hierarchicalFacetsRefinements: { - ...left.hierarchicalFacetsRefinements, - ...right.hierarchicalFacetsRefinements, - }, - }); - -const mergeRuleContexts: Merger = (left, right) => { - const ruleContexts: string[] = uniq( - ([] as any) - .concat(left.ruleContexts) - .concat(right.ruleContexts) - .filter(Boolean) - ); - - if (ruleContexts.length > 0) { - return left.setQueryParameters({ - ruleContexts, - }); - } - - return left; -}; - -export const mergeSearchParameters = ( - ...parameters: SearchParameters[] -): SearchParameters => - parameters.reduce((left, right) => { - const hierarchicalFacetsRefinementsMerged = - mergeHierarchicalFacetsRefinements(left, right); - const hierarchicalFacetsMerged = mergeHierarchicalFacets( - hierarchicalFacetsRefinementsMerged, - right - ); - const tagRefinementsMerged = mergeTagRefinements( - hierarchicalFacetsMerged, - right - ); - const numericRefinementsMerged = mergeNumericRefinements( - tagRefinementsMerged, - right - ); - const disjunctiveFacetsRefinementsMerged = - mergeDisjunctiveFacetsRefinements(numericRefinementsMerged, right); - const facetsExcludesMerged = mergeFacetsExcludes( - disjunctiveFacetsRefinementsMerged, - right - ); - const facetRefinementsMerged = mergeFacetRefinements( - facetsExcludesMerged, - right - ); - const disjunctiveFacetsMerged = mergeDisjunctiveFacets( - facetRefinementsMerged, - right - ); - const ruleContextsMerged = mergeRuleContexts( - disjunctiveFacetsMerged, - right - ); - const facetsMerged = mergeFacets(ruleContextsMerged, right); - - return mergeWithRest(facetsMerged, right); - }); +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/noop.ts b/packages/instantsearch.js/src/lib/utils/noop.ts index cadb2cd0f59..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/noop.ts +++ b/packages/instantsearch.js/src/lib/utils/noop.ts @@ -1 +1 @@ -export function noop(..._args: any[]): void {} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/omit.ts b/packages/instantsearch.js/src/lib/utils/omit.ts index 81e58b4ce8a..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/omit.ts +++ b/packages/instantsearch.js/src/lib/utils/omit.ts @@ -1,26 +1 @@ -/** - * Creates a new object with the same keys as the original object, but without the excluded keys. - * @param source original object - * @param excluded keys to remove from the original object - * @returns the new object - */ -export function omit< - TSource extends Record, - TExcluded extends keyof TSource ->(source: TSource, excluded: TExcluded[]): Omit { - if (source === null || source === undefined) { - return source; - } - - type Output = Omit; - return Object.keys(source).reduce((target, key) => { - if ((excluded as Array).indexOf(key) >= 0) { - return target; - } - - const validKey = key as keyof Output; - target[validKey] = source[validKey]; - - return target; - }, {} as unknown as Output); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/range.ts b/packages/instantsearch.js/src/lib/utils/range.ts index c724bbd88fe..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/range.ts +++ b/packages/instantsearch.js/src/lib/utils/range.ts @@ -1,21 +1 @@ -type RangeOptions = { - start?: number; - end: number; - step?: number; -}; - -export function range({ start = 0, end, step = 1 }: RangeOptions): number[] { - // We can't divide by 0 so we re-assign the step to 1 if it happens. - const limitStep = step === 0 ? 1 : step; - - // In some cases the array to create has a decimal length. - // We therefore need to round the value. - // Example: - // { start: 1, end: 5000, step: 500 } - // => Array length = (5000 - 1) / 500 = 9.998 - const arrayLength = Math.round((end - start) / limitStep); - - return [...Array(arrayLength)].map( - (_, current) => start + current * limitStep - ); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/render-args.ts b/packages/instantsearch.js/src/lib/utils/render-args.ts index 7a3bb6eafbc..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/render-args.ts +++ b/packages/instantsearch.js/src/lib/utils/render-args.ts @@ -1,79 +1 @@ -import type { - InstantSearch, - UiState, - Widget, - IndexWidget, - IndexRenderState, -} from '../../types'; - -export function createInitArgs( - instantSearchInstance: InstantSearch, - parent: IndexWidget, - uiState: UiState -) { - const helper = parent.getHelper()!; - return { - uiState, - helper, - parent, - instantSearchInstance, - state: helper.state, - renderState: instantSearchInstance.renderState, - templatesConfig: instantSearchInstance.templatesConfig, - createURL: parent.createURL, - scopedResults: [], - searchMetadata: { - isSearchStalled: instantSearchInstance.status === 'stalled', - }, - status: instantSearchInstance.status, - error: instantSearchInstance.error, - }; -} - -export function createRenderArgs( - instantSearchInstance: InstantSearch, - parent: IndexWidget, - widget: IndexWidget | Widget -) { - const results = parent.getResultsForWidget(widget); - const helper = parent.getHelper()!; - - return { - helper, - parent, - instantSearchInstance, - results, - scopedResults: parent.getScopedResults(), - state: results && '_state' in results ? results._state : helper.state, - renderState: instantSearchInstance.renderState, - templatesConfig: instantSearchInstance.templatesConfig, - createURL: parent.createURL, - searchMetadata: { - isSearchStalled: instantSearchInstance.status === 'stalled', - }, - status: instantSearchInstance.status, - error: instantSearchInstance.error, - }; -} - -export function storeRenderState({ - renderState, - instantSearchInstance, - parent, -}: { - renderState: IndexRenderState; - instantSearchInstance: InstantSearch; - parent?: IndexWidget; -}) { - const parentIndexName = parent - ? parent.getIndexId() - : instantSearchInstance.mainIndex.getIndexId(); - - instantSearchInstance.renderState = { - ...instantSearchInstance.renderState, - [parentIndexName]: { - ...instantSearchInstance.renderState[parentIndexName], - ...renderState, - }, - }; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/resolveSearchParameters.ts b/packages/instantsearch.js/src/lib/utils/resolveSearchParameters.ts index 025febb2281..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/resolveSearchParameters.ts +++ b/packages/instantsearch.js/src/lib/utils/resolveSearchParameters.ts @@ -1,16 +1 @@ -import type { IndexWidget } from '../../types'; -import type { SearchParameters } from 'algoliasearch-helper'; - -export function resolveSearchParameters( - current: IndexWidget -): SearchParameters[] { - let parent = current.getParent(); - let states = [current.getHelper()!.state]; - - while (parent !== null) { - states = [parent.getHelper()!.state].concat(states); - parent = parent.getParent(); - } - - return states; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts index 559c6bd5dad..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts +++ b/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts @@ -1,14 +1 @@ -import { getHighlightFromSiblings } from './getHighlightFromSiblings'; - -import type { HighlightedParts } from '../../types'; - -export function reverseHighlightedParts(parts: HighlightedParts[]) { - if (!parts.some((part) => part.isHighlighted)) { - return parts.map((part) => ({ ...part, isHighlighted: false })); - } - - return parts.map((part, i) => ({ - ...part, - isHighlighted: !getHighlightFromSiblings(parts, i), - })); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/safelyRunOnBrowser.ts b/packages/instantsearch.js/src/lib/utils/safelyRunOnBrowser.ts index 02cc21e537e..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/safelyRunOnBrowser.ts +++ b/packages/instantsearch.js/src/lib/utils/safelyRunOnBrowser.ts @@ -1,26 +1 @@ -// eslint-disable-next-line no-restricted-globals -type BrowserCallback = (params: { window: typeof window }) => TReturn; -type SafelyRunOnBrowserOptions = { - /** - * Fallback to run on server environments. - */ - fallback: () => TReturn; -}; - -/** - * Runs code on browser environments safely. - */ -export function safelyRunOnBrowser( - callback: BrowserCallback, - { fallback }: SafelyRunOnBrowserOptions = { - fallback: () => undefined as unknown as TReturn, - } -): TReturn { - // eslint-disable-next-line no-restricted-globals - if (typeof window === 'undefined') { - return fallback(); - } - - // eslint-disable-next-line no-restricted-globals - return callback({ window }); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/sendChatMessageFeedback.ts b/packages/instantsearch.js/src/lib/utils/sendChatMessageFeedback.ts index 40ead65e0b8..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/sendChatMessageFeedback.ts +++ b/packages/instantsearch.js/src/lib/utils/sendChatMessageFeedback.ts @@ -1,33 +1 @@ -export function sendChatMessageFeedback({ - agentId, - vote, - messageId, - appId, - apiKey, -}: { - agentId: string; - vote: 0 | 1; - messageId: string; - appId: string; - apiKey: string; -}): Promise { - return fetch(`https://${appId}.algolia.net/agent-studio/1/feedback`, { - method: 'POST', - body: JSON.stringify({ messageId, agentId, vote }), - headers: { - 'x-algolia-application-id': appId, - 'x-algolia-api-key': apiKey, - 'content-type': 'application/json', - }, - }).then((response) => { - if (response.status >= 300) { - return response.json().then((data) => { - throw new Error( - `Feedback request failed with status ${response.status}: ${data.message}` - ); - }); - } - - return response.json(); - }); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/serializer.ts b/packages/instantsearch.js/src/lib/utils/serializer.ts index 2697881950f..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/serializer.ts +++ b/packages/instantsearch.js/src/lib/utils/serializer.ts @@ -1,7 +1 @@ -export function serializePayload(payload: TPayload): string { - return btoa(encodeURIComponent(JSON.stringify(payload))); -} - -export function deserializePayload(serialized: string): TPayload { - return JSON.parse(decodeURIComponent(atob(serialized))); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/setIndexHelperState.ts b/packages/instantsearch.js/src/lib/utils/setIndexHelperState.ts index 70894f87d72..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/setIndexHelperState.ts +++ b/packages/instantsearch.js/src/lib/utils/setIndexHelperState.ts @@ -1,29 +1 @@ -import { checkIndexUiState } from './checkIndexUiState'; -import { isIndexWidget } from './isIndexWidget'; - -import type { UiState, IndexWidget } from '../../types'; - -export function setIndexHelperState( - finalUiState: TUiState, - indexWidget: IndexWidget -) { - const nextIndexUiState = finalUiState[indexWidget.getIndexId()] || {}; - - if (__DEV__) { - checkIndexUiState({ - index: indexWidget, - indexUiState: nextIndexUiState, - }); - } - - indexWidget.getHelper()!.setState( - indexWidget.getWidgetSearchParameters(indexWidget.getHelper()!.state, { - uiState: nextIndexUiState, - }) - ); - - indexWidget - .getWidgets() - .filter(isIndexWidget) - .forEach((widget) => setIndexHelperState(finalUiState, widget)); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/toArray.ts b/packages/instantsearch.js/src/lib/utils/toArray.ts index 7ca13382713..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/toArray.ts +++ b/packages/instantsearch.js/src/lib/utils/toArray.ts @@ -1,5 +1 @@ -type ToArray = T extends unknown[] ? T : T[]; - -export function toArray(value: T): ToArray { - return (Array.isArray(value) ? value : [value]) as ToArray; -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/typedObject.ts b/packages/instantsearch.js/src/lib/utils/typedObject.ts index 2fca5961da5..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/typedObject.ts +++ b/packages/instantsearch.js/src/lib/utils/typedObject.ts @@ -1,7 +1 @@ -/** - * A typed version of Object.keys, to use when looping over a static object - * inspired from https://stackoverflow.com/a/65117465/3185307 - */ -export const keys = Object.keys as >( - yourObject: TObject -) => Array; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/uniq.ts b/packages/instantsearch.js/src/lib/utils/uniq.ts index c465f9894e6..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/uniq.ts +++ b/packages/instantsearch.js/src/lib/utils/uniq.ts @@ -1,3 +1 @@ -export function uniq(array: TItem[]): TItem[] { - return array.filter((value, index, self) => self.indexOf(value) === index); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/uuid.ts b/packages/instantsearch.js/src/lib/utils/uuid.ts index a2737070d36..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/uuid.ts +++ b/packages/instantsearch.js/src/lib/utils/uuid.ts @@ -1,15 +1 @@ -/** - * Create UUID according to - * https://www.ietf.org/rfc/rfc4122.txt. - * - * @returns Generated UUID. - */ -export function createUUID(): string { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - /* eslint-disable no-bitwise */ - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; - /* eslint-enable */ - return v.toString(16); - }); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/utils/walkIndex.ts b/packages/instantsearch.js/src/lib/utils/walkIndex.ts index bda2b982e35..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/utils/walkIndex.ts +++ b/packages/instantsearch.js/src/lib/utils/walkIndex.ts @@ -1,19 +1 @@ -import { isIndexWidget } from './isIndexWidget'; - -import type { IndexWidget } from '../../types'; - -/** - * Recurse over all child indices - */ -export function walkIndex( - indexWidget: IndexWidget, - callback: (widget: IndexWidget) => void -) { - callback(indexWidget); - - indexWidget.getWidgets().forEach((widget) => { - if (isIndexWidget(widget)) { - walkIndex(widget, callback); - } - }); -} +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/voiceSearchHelper/index.ts b/packages/instantsearch.js/src/lib/voiceSearchHelper/index.ts index f33514b4a93..fe3212b3aab 100644 --- a/packages/instantsearch.js/src/lib/voiceSearchHelper/index.ts +++ b/packages/instantsearch.js/src/lib/voiceSearchHelper/index.ts @@ -1,131 +1,2 @@ -// `SpeechRecognition` is an API used on the browser so we can safely disable -// the `window` check. -/* eslint-disable no-restricted-globals */ -/* global SpeechRecognition SpeechRecognitionEvent */ -import type { - CreateVoiceSearchHelper, - Status, - VoiceListeningState, -} from './types'; - -const createVoiceSearchHelper: CreateVoiceSearchHelper = - function createVoiceSearchHelper({ - searchAsYouSpeak, - language, - onQueryChange, - onStateChange, - }) { - const SpeechRecognitionAPI: new () => SpeechRecognition = - (window as any).webkitSpeechRecognition || - (window as any).SpeechRecognition; - const getDefaultState = (status: Status): VoiceListeningState => ({ - status, - transcript: '', - isSpeechFinal: false, - errorCode: undefined, - }); - let state: VoiceListeningState = getDefaultState('initial'); - let recognition: SpeechRecognition | undefined; - - const isBrowserSupported = (): boolean => Boolean(SpeechRecognitionAPI); - - const isListening = (): boolean => - state.status === 'askingPermission' || - state.status === 'waiting' || - state.status === 'recognizing'; - - const setState = (newState: Partial = {}): void => { - state = { ...state, ...newState }; - onStateChange(); - }; - - const getState = (): VoiceListeningState => state; - - const resetState = (status: Status = 'initial'): void => { - setState(getDefaultState(status)); - }; - - const onStart = (): void => { - setState({ - status: 'waiting', - }); - }; - - const onError = (event: Event): void => { - setState({ status: 'error', errorCode: (event as any).error }); - }; - - const onResult = (event: SpeechRecognitionEvent): void => { - setState({ - status: 'recognizing', - transcript: - (event.results[0] && - event.results[0][0] && - event.results[0][0].transcript) || - '', - isSpeechFinal: event.results[0] && event.results[0].isFinal, - }); - if (searchAsYouSpeak && state.transcript) { - onQueryChange(state.transcript); - } - }; - - const onEnd = (): void => { - if (!state.errorCode && state.transcript && !searchAsYouSpeak) { - onQueryChange(state.transcript); - } - if (state.status !== 'error') { - setState({ status: 'finished' }); - } - }; - - const startListening = (): void => { - recognition = new SpeechRecognitionAPI(); - if (!recognition) { - return; - } - resetState('askingPermission'); - recognition.interimResults = true; - - if (language) { - recognition.lang = language; - } - - recognition.addEventListener('start', onStart); - recognition.addEventListener('error', onError); - recognition.addEventListener('result', onResult); - recognition.addEventListener('end', onEnd); - recognition.start(); - }; - - const dispose = (): void => { - if (!recognition) { - return; - } - recognition.stop(); - recognition.removeEventListener('start', onStart); - recognition.removeEventListener('error', onError); - recognition.removeEventListener('result', onResult); - recognition.removeEventListener('end', onEnd); - recognition = undefined; - }; - - const stopListening = (): void => { - dispose(); - // Because `dispose` removes event listeners, `end` listener is not called. - // So we're setting the `status` as `finished` here. - // If we don't do it, it will be still `waiting` or `recognizing`. - resetState('finished'); - }; - - return { - getState, - isBrowserSupported, - isListening, - startListening, - stopListening, - dispose, - }; - }; - -export default createVoiceSearchHelper; +export { createVoiceSearchHelper as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/lib/voiceSearchHelper/types.ts b/packages/instantsearch.js/src/lib/voiceSearchHelper/types.ts index 936a243c3f7..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/lib/voiceSearchHelper/types.ts +++ b/packages/instantsearch.js/src/lib/voiceSearchHelper/types.ts @@ -1,34 +1 @@ -export type Status = - | 'initial' - | 'askingPermission' - | 'waiting' - | 'recognizing' - | 'finished' - | 'error'; - -export type VoiceListeningState = { - status: Status; - transcript: string; - isSpeechFinal: boolean; - errorCode?: string; -}; - -export type VoiceSearchHelperParams = { - searchAsYouSpeak: boolean; - language?: string; - onQueryChange: (query: string) => void; - onStateChange: () => void; -}; - -export type VoiceSearchHelper = { - getState: () => VoiceListeningState; - isBrowserSupported: () => boolean; - isListening: () => boolean; - startListening: () => void; - stopListening: () => void; - dispose: () => void; -}; - -export type CreateVoiceSearchHelper = ( - params: VoiceSearchHelperParams -) => VoiceSearchHelper; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts b/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts index 3d7b64ef2e2..fe21d5e098f 100644 --- a/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createInsightsMiddleware.ts @@ -1,487 +1,2 @@ -import { getInsightsAnonymousUserTokenInternal } from '../helpers'; -import { - warning, - noop, - getAppIdAndApiKey, - find, - safelyRunOnBrowser, -} from '../lib/utils'; -import { createUUID } from '../lib/utils/uuid'; - -import type { - InsightsClient, - InsightsEvent as _InsightsEvent, - InsightsMethod, - InsightsMethodMap, - InternalMiddleware, - InstantSearch, -} from '../types'; -import type { - AlgoliaSearchHelper, - PlainSearchParameters, -} from 'algoliasearch-helper'; - -type ProvidedInsightsClient = InsightsClient | null | undefined; - -export type InsightsEvent = - _InsightsEvent; - -export type InsightsProps< - TInsightsClient extends ProvidedInsightsClient = ProvidedInsightsClient -> = { - insightsClient?: TInsightsClient; - insightsInitParams?: Partial; - onEvent?: (event: InsightsEvent, insightsClient: TInsightsClient) => void; - /** - * @internal indicator for the default insights middleware - */ - $$internal?: boolean; - /** - * @internal indicator for sending the `clickAnalytics` search parameter - */ - $$automatic?: boolean; -}; - -const ALGOLIA_INSIGHTS_VERSION = '2.17.2'; -const ALGOLIA_INSIGHTS_SRC = `https://cdn.jsdelivr.net/npm/search-insights@${ALGOLIA_INSIGHTS_VERSION}/dist/search-insights.min.js`; - -export type InsightsClientWithGlobals = InsightsClient & { - shouldAddScript?: boolean; - version?: string; -}; - -export type CreateInsightsMiddleware = typeof createInsightsMiddleware; - -export function createInsightsMiddleware< - TInsightsClient extends ProvidedInsightsClient ->(props: InsightsProps = {}): InternalMiddleware { - const { - insightsClient: _insightsClient, - insightsInitParams, - onEvent, - $$internal = false, - $$automatic = false, - } = props; - - let potentialInsightsClient: ProvidedInsightsClient = _insightsClient; - - if (!_insightsClient && _insightsClient !== null) { - safelyRunOnBrowser(({ window }: { window: any }) => { - const pointer = window.AlgoliaAnalyticsObject || 'aa'; - - if (typeof pointer === 'string') { - potentialInsightsClient = window[pointer]; - } - - if (!potentialInsightsClient) { - window.AlgoliaAnalyticsObject = pointer; - - if (!window[pointer]) { - window[pointer] = (...args: any[]) => { - if (!window[pointer].queue) { - window[pointer].queue = []; - } - window[pointer].queue.push(args); - }; - window[pointer].version = ALGOLIA_INSIGHTS_VERSION; - window[pointer].shouldAddScript = true; - } - - potentialInsightsClient = window[pointer]; - } - }); - } - // if still no insightsClient was found, we use a noop - const insightsClient: InsightsClientWithGlobals = - potentialInsightsClient || noop; - - return ({ instantSearchInstance }) => { - // remove existing default insights middleware - // user-provided insights middleware takes precedence - const existingInsightsMiddlewares = instantSearchInstance.middleware - .filter( - (m) => m.instance.$$type === 'ais.insights' && m.instance.$$internal - ) - .map((m) => m.creator); - instantSearchInstance.unuse(...existingInsightsMiddlewares); - - const [appId, apiKey] = getAppIdAndApiKey(instantSearchInstance.client); - - // search-insights.js also throws an error so dev-only clarification is sufficient - warning( - Boolean(appId && apiKey), - 'could not extract Algolia credentials from searchClient in insights middleware.' - ); - - let queuedInitParams: Partial | undefined = - undefined; - let queuedUserToken: string | undefined = undefined; - let userTokenBeforeInit: string | undefined = undefined; - - const { queue } = insightsClient; - - if (Array.isArray(queue)) { - // Context: The umd build of search-insights is asynchronously loaded by the snippet. - // - // When user calls `aa('setUserToken', 'my-user-token')` before `search-insights` is loaded, - // ['setUserToken', 'my-user-token'] gets stored in `aa.queue`. - // Whenever `search-insights` is finally loaded, it will process the queue. - // - // But here's the reason why we handle it here: - // At this point, even though `search-insights` is not loaded yet, - // we still want to read the token from the queue. - // Otherwise, the first search call will be fired without the token. - [queuedUserToken, queuedInitParams] = ['setUserToken', 'init'].map( - (key) => { - const [, value] = - find(queue.slice().reverse(), ([method]) => method === key) || []; - - return value as any as NonNullable; - } - ); - } - - // If user called `aa('setUserToken')` before creating the Insights middleware, - // we temporarily store the token and set it later on. - // - // Otherwise, the `init` call might override them with anonymous user token. - insightsClient('getUserToken', null, (_error, userToken) => { - userTokenBeforeInit = normalizeUserToken(userToken); - }); - - // Only `init` if the `insightsInitParams` option is passed or - // if the `insightsClient` version doesn't supports optional `init` calling. - if (insightsInitParams || !isModernInsightsClient(insightsClient)) { - insightsClient('init', { - appId, - apiKey, - partial: true, - ...insightsInitParams, - }); - } - - let initialParameters: PlainSearchParameters; - let helper: AlgoliaSearchHelper; - - return { - $$type: 'ais.insights', - $$internal, - $$automatic, - onStateChange() {}, - subscribe() { - if (!insightsClient.shouldAddScript) return; - - const errorMessage = - '[insights middleware]: could not load search-insights.js. Please load it manually following https://alg.li/insights-init'; - - try { - const script = document.createElement('script'); - script.async = true; - script.src = ALGOLIA_INSIGHTS_SRC; - script.onerror = () => { - instantSearchInstance.emit('error', new Error(errorMessage)); - }; - document.body.appendChild(script); - insightsClient.shouldAddScript = false; - } catch (cause) { - insightsClient.shouldAddScript = false; - instantSearchInstance.emit('error', new Error(errorMessage)); - } - }, - started() { - insightsClient('addAlgoliaAgent', 'insights-middleware'); - - helper = instantSearchInstance.mainHelper!; - - const { queue: queueAtStart } = insightsClient; - - if (Array.isArray(queueAtStart)) { - [queuedUserToken, queuedInitParams] = ['setUserToken', 'init'].map( - (key) => { - const [, value] = - find( - queueAtStart.slice().reverse(), - ([method]) => method === key - ) || []; - - return value; - } - ); - } - - initialParameters = getInitialParameters(instantSearchInstance); - - // We don't want to force clickAnalytics when the insights is enabled from the search response. - // This means we don't enable insights for indices that don't opt in - if (!$$automatic) { - helper.overrideStateWithoutTriggeringChangeEvent({ - ...helper.state, - clickAnalytics: true, - }); - } - - if (!$$internal) { - instantSearchInstance.scheduleSearch(); - } - - const setUserTokenToSearch = ( - userToken?: string | number, - immediate = false - ) => { - const normalizedUserToken = normalizeUserToken(userToken); - - if (!normalizedUserToken) { - return; - } - - const existingToken = (helper.state as PlainSearchParameters) - .userToken; - - function applyToken() { - helper.overrideStateWithoutTriggeringChangeEvent({ - ...helper.state, - userToken: normalizedUserToken, - }); - - if (existingToken && existingToken !== userToken) { - instantSearchInstance.scheduleSearch(); - } - } - - // Delay the token application to the next render cycle - if (!immediate) { - setTimeout(applyToken, 0); - } else { - applyToken(); - } - }; - - function setUserToken(token: string | number) { - setUserTokenToSearch(token, true); - insightsClient('setUserToken', token); - } - - let anonymousUserToken: string | undefined = undefined; - const anonymousTokenFromInsights = - getInsightsAnonymousUserTokenInternal(); - if (anonymousTokenFromInsights) { - // When `aa('init', { ... })` is called, it creates an anonymous user token in cookie. - // We can set it as userToken on instantsearch and insights. If it's not set as an insights - // userToken before a sendEvent, insights automatically generates a new anonymous token, - // causing a state change and an unnecessary query on instantsearch. - anonymousUserToken = anonymousTokenFromInsights; - } else { - const token = `anonymous-${createUUID()}`; - anonymousUserToken = token; - } - - let userTokenFromInit: string | undefined; - - // With SSR, the token could be be set on the state. We make sure - // that insights is in sync with that token since, there is no - // insights lib on the server. - const tokenFromSearchParameters = initialParameters.userToken; - - // When the first query is sent, the token is possibly not yet set by - // the insights onChange callbacks (if insights isn't yet loaded). - // It is explicitly being set here so that the first query has the - // initial tokens set and ensure a second query isn't automatically - // made when the onChange callback actually changes the state. - if (insightsInitParams?.userToken) { - userTokenFromInit = insightsInitParams.userToken; - } - - if (userTokenFromInit) { - setUserToken(userTokenFromInit); - } else if (tokenFromSearchParameters) { - setUserToken(tokenFromSearchParameters); - } else if (userTokenBeforeInit) { - setUserToken(userTokenBeforeInit); - } else if (queuedUserToken) { - setUserToken(queuedUserToken); - } else if (anonymousUserToken) { - setUserToken(anonymousUserToken); - - if (insightsInitParams?.useCookie || queuedInitParams?.useCookie) { - saveTokenAsCookie( - anonymousUserToken, - insightsInitParams?.cookieDuration || - queuedInitParams?.cookieDuration - ); - } - } - - // This updates userToken which is set explicitly by `aa('setUserToken', userToken)` - insightsClient( - 'onUserTokenChange', - (token) => setUserTokenToSearch(token, true), - { - immediate: true, - } - ); - - type InsightsClientWithLocalCredentials = < - TMethod extends InsightsMethod - >( - method: TMethod, - payload: InsightsMethodMap[TMethod][0][0] - ) => void; - - let insightsClientWithLocalCredentials = - insightsClient as InsightsClientWithLocalCredentials; - - if (isModernInsightsClient(insightsClient)) { - insightsClientWithLocalCredentials = (method, payload) => { - const [latestAppId, latestApiKey] = getAppIdAndApiKey( - instantSearchInstance.client - ); - const extraParams = { - headers: { - 'X-Algolia-Application-Id': latestAppId, - 'X-Algolia-API-Key': latestApiKey, - }, - }; - - // @ts-ignore we are calling this only when we know that the client actually is correct - return insightsClient(method, payload, extraParams); - }; - } - - const viewedObjectIDs = new Set(); - let lastQueryId: string | undefined; - instantSearchInstance.mainHelper!.derivedHelpers[0].on( - 'result', - ({ results }) => { - if ( - results && - (!results.queryID || results.queryID !== lastQueryId) - ) { - lastQueryId = results.queryID; - viewedObjectIDs.clear(); - } - } - ); - - instantSearchInstance.sendEventToInsights = (event: InsightsEvent) => { - if (onEvent) { - onEvent( - event, - insightsClientWithLocalCredentials as TInsightsClient - ); - } else if (event.insightsMethod) { - if (event.insightsMethod === 'viewedObjectIDs') { - const payload = event.payload as { - objectIDs: string[]; - }; - const difference = payload.objectIDs.filter( - (objectID) => !viewedObjectIDs.has(objectID) - ); - if (difference.length === 0) { - return; - } - difference.forEach((objectID) => viewedObjectIDs.add(objectID)); - payload.objectIDs = difference; - } - - // Source is used to differentiate events sent by instantsearch from those sent manually. - (event.payload as any).algoliaSource = ['instantsearch']; - if ($$automatic) { - (event.payload as any).algoliaSource.push( - 'instantsearch-automatic' - ); - } - if (event.eventModifier === 'internal') { - (event.payload as any).algoliaSource.push( - 'instantsearch-internal' - ); - } - - insightsClientWithLocalCredentials( - event.insightsMethod, - event.payload - ); - - warning( - Boolean((helper.state as PlainSearchParameters).userToken), - ` -Cannot send event to Algolia Insights because \`userToken\` is not set. - -See documentation: https://www.algolia.com/doc/guides/building-search-ui/going-further/send-insights-events/js/#setting-the-usertoken -` - ); - } else { - warning( - false, - 'Cannot send event to Algolia Insights because `insightsMethod` option is missing.' - ); - } - }; - }, - unsubscribe() { - insightsClient('onUserTokenChange', undefined); - instantSearchInstance.sendEventToInsights = noop; - if (helper && initialParameters) { - helper.overrideStateWithoutTriggeringChangeEvent({ - ...helper.state, - ...initialParameters, - }); - - instantSearchInstance.scheduleSearch(); - } - }, - }; - }; -} - -function getInitialParameters( - instantSearchInstance: InstantSearch -): PlainSearchParameters { - // in SSR, the initial state we use in this domain is set on the main index - const stateFromInitialResults = - instantSearchInstance._initialResults?.[instantSearchInstance.indexName] - ?.state || {}; - - const stateFromHelper = instantSearchInstance.mainHelper!.state; - - return { - userToken: stateFromInitialResults.userToken || stateFromHelper.userToken, - clickAnalytics: - stateFromInitialResults.clickAnalytics || stateFromHelper.clickAnalytics, - }; -} - -function saveTokenAsCookie(token: string, cookieDuration?: number) { - const MONTH = 30 * 24 * 60 * 60 * 1000; - const d = new Date(); - d.setTime(d.getTime() + (cookieDuration || MONTH * 6)); - const expires = `expires=${d.toUTCString()}`; - document.cookie = `_ALGOLIA=${token};${expires};path=/`; -} - -/** - * Determines if a given insights `client` supports the optional call to `init` - * and the ability to set credentials via extra parameters when sending events. - */ -function isModernInsightsClient(client: InsightsClientWithGlobals): boolean { - const [major, minor] = (client.version || '').split('.').map(Number); - - /* eslint-disable instantsearch/naming-convention */ - const v3 = major >= 3; - const v2_6 = major === 2 && minor >= 6; - const v1_10 = major === 1 && minor >= 10; - /* eslint-enable instantsearch/naming-convention */ - - return v3 || v2_6 || v1_10; -} - -/** - * While `search-insights` supports both string and number user tokens, - * the Search API only accepts strings. This function normalizes the user token. - */ -function normalizeUserToken(userToken?: string | number): string | undefined { - if (!userToken) { - return undefined; - } - - return typeof userToken === 'number' ? userToken.toString() : userToken; -} +export { createInsightsMiddleware } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts b/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts index 9f41696b349..d459022b617 100644 --- a/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts @@ -1,139 +1,2 @@ -import { - createInitArgs, - getAlgoliaAgent, - isIndexWidget, - safelyRunOnBrowser, -} from '../lib/utils'; - -import type { - InstantSearch, - InternalMiddleware, - Widget, - IndexWidget, -} from '../types'; - -type WidgetMetadata = - | { - type: string | undefined; - widgetType: string | undefined; - params: string[]; - } - | { - type: string; - middleware: true; - internal: boolean; - }; - -type Payload = { - widgets: WidgetMetadata[]; - ua?: string; -}; - -function extractWidgetPayload( - widgets: Array, - instantSearchInstance: InstantSearch, - payload: Payload -) { - const initOptions = createInitArgs( - instantSearchInstance, - instantSearchInstance.mainIndex, - instantSearchInstance._initialUiState - ); - - widgets.forEach((widget) => { - let widgetParams: Record = {}; - - if (widget.getWidgetRenderState) { - const renderState = widget.getWidgetRenderState(initOptions); - - if (renderState && renderState.widgetParams) { - // casting, as we just earlier checked widgetParams exists, and thus an object - widgetParams = renderState.widgetParams as Record; - } - } - - // 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, - widgetType: widget.$$widgetType, - params, - }); - - if (isIndexWidget(widget)) { - extractWidgetPayload( - widget.getWidgets(), - instantSearchInstance, - payload - ); - } - }); -} - -export function isMetadataEnabled() { - return safelyRunOnBrowser( - ({ window }) => - window.navigator?.userAgent?.indexOf('Algolia Crawler') > -1, - { fallback: () => false } - ); -} - -/** - * Exposes the metadata of mounted widgets in a custom - * `` tag. The metadata per widget is: - * - applied parameters - * - widget name - * - connector name - */ -export function createMetadataMiddleware({ - $$internal = false, -}: { - $$internal?: boolean; -} = {}): InternalMiddleware { - return ({ instantSearchInstance }) => { - const payload: Payload = { - widgets: [], - }; - const payloadContainer = document.createElement('meta'); - const refNode = document.querySelector('head')!; - payloadContainer.name = 'instantsearch:widgets'; - - return { - $$type: 'ais.metadata', - $$internal, - onStateChange() {}, - subscribe() { - // using setTimeout here to delay extraction until widgets have been added in a tick (e.g. Vue) - setTimeout(() => { - payload.ua = getAlgoliaAgent(instantSearchInstance.client); - - extractWidgetPayload( - instantSearchInstance.mainIndex.getWidgets(), - instantSearchInstance, - payload - ); - - instantSearchInstance.middleware.forEach((middleware) => - payload.widgets.push({ - middleware: true, - type: middleware.instance.$$type, - internal: middleware.instance.$$internal, - }) - ); - - payloadContainer.content = JSON.stringify(payload); - refNode.appendChild(payloadContainer); - }, 0); - }, - - started() {}, - - unsubscribe() { - payloadContainer.remove(); - }, - }; - }; -} +export { createMetadataMiddleware, isMetadataEnabled } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/middlewares/createRouterMiddleware.ts b/packages/instantsearch.js/src/middlewares/createRouterMiddleware.ts index acb88a46215..4341a246db2 100644 --- a/packages/instantsearch.js/src/middlewares/createRouterMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createRouterMiddleware.ts @@ -1,126 +1,2 @@ -import historyRouter from '../lib/routers/history'; -import simpleStateMapping from '../lib/stateMappings/simple'; -import { isEqual, warning } from '../lib/utils'; - -import type { - Router, - StateMapping, - UiState, - InternalMiddleware, - CreateURL, -} from '../types'; - -export type RouterProps< - TUiState extends UiState = UiState, - TRouteState = TUiState -> = { - router?: Router; - // ideally stateMapping should be required if TRouteState is given, - // but there's no way to check if a generic is provided or the default value. - stateMapping?: StateMapping; - /** - * @internal indicator for the default middleware - */ - $$internal?: boolean; -}; - -export const createRouterMiddleware = < - TUiState extends UiState = UiState, - TRouteState = TUiState ->( - props: RouterProps = {} -): InternalMiddleware => { - const { - router = historyRouter(), - // We have to cast simpleStateMapping as a StateMapping. - // this is needed because simpleStateMapping is StateMapping. - // While it's only used when UiState and RouteState are the same, unfortunately - // TypeScript still considers them separate types. - stateMapping = simpleStateMapping() as unknown as StateMapping< - TUiState, - TRouteState - >, - $$internal = false, - } = props; - - return ({ instantSearchInstance }) => { - function topLevelCreateURL(nextState: TUiState) { - const previousUiState = - // If only the mainIndex is initialized, we don't yet know what other - // index widgets are used. Therefore we fall back to the initialUiState. - // We can't indiscriminately use the initialUiState because then we - // reintroduce state that was changed by the user. - // When there are no widgets, we are sure the user can't yet have made - // any changes. - instantSearchInstance.mainIndex.getWidgets().length === 0 - ? (instantSearchInstance._initialUiState as TUiState) - : instantSearchInstance.mainIndex.getWidgetUiState( - {} as TUiState - ); - - const uiState: TUiState = Object.keys(nextState).reduce( - (acc, indexId) => ({ - ...acc, - [indexId]: nextState[indexId], - }), - previousUiState - ); - - const route = stateMapping.stateToRoute(uiState); - - return router.createURL(route); - } - - // casting to UiState here to keep createURL unaware of custom UiState - // (as long as it's an object, it's ok) - instantSearchInstance._createURL = topLevelCreateURL as CreateURL; - - let lastRouteState: TRouteState | undefined = undefined; - - const initialUiState = instantSearchInstance._initialUiState; - - return { - $$type: `ais.router({router:${ - router.$$type || '__unknown__' - }, stateMapping:${stateMapping.$$type || '__unknown__'}})`, - $$internal, - onStateChange({ uiState }) { - const routeState = stateMapping.stateToRoute(uiState); - - if ( - lastRouteState === undefined || - !isEqual(lastRouteState, routeState) - ) { - router.write(routeState); - lastRouteState = routeState; - } - }, - - subscribe() { - warning( - Object.keys(initialUiState).length === 0, - 'Using `initialUiState` together with routing is not recommended. The `initialUiState` will be overwritten by the URL parameters.' - ); - - instantSearchInstance._initialUiState = { - ...initialUiState, - ...stateMapping.routeToState(router.read()), - }; - - router.onUpdate((route) => { - if (instantSearchInstance.mainIndex.getWidgets().length > 0) { - instantSearchInstance.setUiState(stateMapping.routeToState(route)); - } - }); - }, - - started() { - router.start?.(); - }, - - unsubscribe() { - router.dispose(); - }, - }; - }; -}; +export { createRouterMiddleware } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/middlewares/index.ts b/packages/instantsearch.js/src/middlewares/index.ts index a44215a78bc..ef53583fdb7 100644 --- a/packages/instantsearch.js/src/middlewares/index.ts +++ b/packages/instantsearch.js/src/middlewares/index.ts @@ -1,3 +1,15 @@ -export * from './createInsightsMiddleware'; -export * from './createRouterMiddleware'; -export * from './createMetadataMiddleware'; +export { + createInsightsMiddleware, + createMetadataMiddleware, + createRouterMiddleware, + isMetadataEnabled, +} from 'instantsearch-core'; +export type { + CreateInsightsMiddleware, + InsightsClient, + InsightsClientWithGlobals, + InsightsEvent, + InsightsMethod, + InsightsProps, + RouterProps, +} from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/algoliasearch.ts b/packages/instantsearch.js/src/types/algoliasearch.ts index ac04a682a88..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/algoliasearch.ts +++ b/packages/instantsearch.js/src/types/algoliasearch.ts @@ -1,4 +1 @@ -// eslint-disable-next-line import/extensions -export * from 'algoliasearch-helper/types/algoliasearch.js'; - -export {}; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/connector.ts b/packages/instantsearch.js/src/types/connector.ts index 2e285219adf..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/connector.ts +++ b/packages/instantsearch.js/src/types/connector.ts @@ -1,83 +1 @@ -import type { InsightsClient } from './insights'; -import type { InstantSearch } from './instantsearch'; -import type { Hit } from './results'; -import type { UnknownWidgetParams, Widget, WidgetDescription } from './widget'; -import type { SearchResults } from 'algoliasearch-helper'; - -/** - * The base renderer options. All render functions receive - * the options below plus the specific options per connector. - */ -export type RendererOptions = { - /** - * The original widget params. Useful as you may - * need them while using the render function. - */ - widgetParams: TWidgetParams; - - /** - * The current instant search instance. - */ - instantSearchInstance: InstantSearch; - - /** - * The original search results. - */ - results?: SearchResults; - - /** - * The mutable list of hits. The may change depending - * of the given transform items function. - */ - hits?: Hit[]; - - /** - * The current insights client, if any. - */ - insights?: InsightsClient; -}; - -/** - * The render function. - */ -export type Renderer = ( - /** - * The base render options plus the specific options of the widget. - */ - renderState: TRenderState & RendererOptions, - - /** - * If is the first run. - */ - isFirstRender: boolean -) => void; - -/** - * The called function when unmounting a widget. - */ -export type Unmounter = () => void; - -/** - * The connector handles the business logic and exposes - * a simplified API to the rendering function. - */ -export type Connector< - TWidgetDescription extends WidgetDescription, - TConnectorParams extends UnknownWidgetParams -> = ( - /** - * The render function. - */ - renderFn: Renderer< - TWidgetDescription['renderState'], - TConnectorParams & TWidgetParams - >, - /** - * The called function when unmounting a widget. - */ - unmountFn?: Unmounter -) => (widgetParams: TConnectorParams & TWidgetParams) => Widget< - TWidgetDescription & { - widgetParams: typeof widgetParams; - } ->; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/index.ts b/packages/instantsearch.js/src/types/index.ts index 0faeb9de897..c5945f71e45 100644 --- a/packages/instantsearch.js/src/types/index.ts +++ b/packages/instantsearch.js/src/types/index.ts @@ -1,25 +1,6 @@ -// internal -export * from './utils'; +// shared InstantSearch types +export * from 'instantsearch-core'; -// Algolia-related -// eslint-disable-next-line import/export -export * from './algoliasearch'; -export * from './results'; -export * from './recommend'; - -// component-related +// InstantSearch.js-specific types export * from './component'; - -// instantsearch-related -export * from './instantsearch'; -export * from './middleware'; -export * from './router'; -export * from './insights'; - -// widget-related -export * from './connector'; -export * from './widget-factory'; -export * from './widget'; -export * from './ui-state'; -export * from './render-state'; export * from './templates'; diff --git a/packages/instantsearch.js/src/types/insights.ts b/packages/instantsearch.js/src/types/insights.ts index 424bdf124f5..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/insights.ts +++ b/packages/instantsearch.js/src/types/insights.ts @@ -1,63 +1 @@ -import type { Hit } from './results'; -import type { - InsightsMethodMap as _InsightsMethodMap, - InsightsClient as _InsightsClient, -} from 'search-insights'; - -export type { - Init as InsightsInit, - AddAlgoliaAgent as InsightsAddAlgoliaAgent, - SetUserToken as InsightsSetUserToken, - GetUserToken as InsightsGetUserToken, - OnUserTokenChange as InsightsOnUserTokenChange, -} from 'search-insights'; - -export type InsightsMethodMap = _InsightsMethodMap; -export type InsightsClientMethod = keyof InsightsMethodMap; - -/** - * Method allowed by the insights middleware. - */ -export type InsightsMethod = - | 'clickedObjectIDsAfterSearch' - | 'clickedObjectIDs' - | 'clickedFilters' - | 'convertedObjectIDsAfterSearch' - | 'convertedObjectIDs' - | 'convertedFilters' - | 'viewedObjectIDs' - | 'viewedFilters'; - -/** - * The event sent to the insights middleware. - */ -export type InsightsEvent = { - insightsMethod?: TMethod; - payload: InsightsMethodMap[TMethod][0][0]; - widgetType: string; - eventType: string; // 'view' | 'click' | 'conversion', but we're not restricting. - eventModifier?: string; // 'internal', but we're not restricting. - hits?: Hit[]; - attribute?: string; -}; - -export type InsightsClientPayload = { - eventName: string; - queryID: string; - index: string; - objectIDs: string[]; - positions?: number[]; -}; - -type QueueItemMap = { - [MethodName in keyof InsightsMethodMap]: [ - methodName: MethodName, - ...args: InsightsMethodMap[MethodName][0][0] - ]; -}; - -export type QueueItem = QueueItemMap[keyof QueueItemMap]; - -export type InsightsClient = _InsightsClient & { - queue?: QueueItem[]; -}; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/middleware.ts b/packages/instantsearch.js/src/types/middleware.ts index 4806a7494e1..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/middleware.ts +++ b/packages/instantsearch.js/src/types/middleware.ts @@ -1,42 +1 @@ -import type InstantSearch from '../lib/InstantSearch'; -import type { UiState } from './ui-state'; -import type { AtLeastOne } from './utils'; - -export type MiddlewareDefinition = { - /** - * string to identify the middleware - */ - $$type: string; - /** - * @internal indicator for the default middleware - */ - $$internal: boolean; - /** - * Change handler called on every UiState change - */ - onStateChange: (options: { uiState: TUiState }) => void; - /** - * Called when the middleware is added to InstantSearch - */ - subscribe: () => void; - /** - * Called when InstantSearch is started - */ - started: () => void; - /** - * Called when the middleware is removed from InstantSearch - */ - unsubscribe: () => void; -}; - -export type MiddlewareOptions = { - instantSearchInstance: InstantSearch; -}; - -export type InternalMiddleware = ( - options: MiddlewareOptions -) => MiddlewareDefinition; - -export type Middleware = ( - options: MiddlewareOptions -) => AtLeastOne>; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/recommend.ts b/packages/instantsearch.js/src/types/recommend.ts index c37fa958e26..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/recommend.ts +++ b/packages/instantsearch.js/src/types/recommend.ts @@ -1,12 +1 @@ -/** - * A trending facet value returned by the Recommend API. - * NOT a Hit — no objectID, __position, or __queryID. - */ -export type TrendingFacetItem = { - /** The facet attribute name (e.g., "brand"). */ - facetName: string; - /** The facet value (e.g., "Nike"). */ - facetValue: string; - /** Trending score from the Recommend API (0-100). */ - _score: number; -}; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/render-state.ts b/packages/instantsearch.js/src/types/render-state.ts index 18cabd39d44..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/render-state.ts +++ b/packages/instantsearch.js/src/types/render-state.ts @@ -1,73 +1 @@ -import type { AnswersWidgetDescription } from '../connectors/answers/connectAnswers'; -import type { AutocompleteWidgetDescription } from '../connectors/autocomplete/connectAutocomplete'; -import type { BreadcrumbWidgetDescription } from '../connectors/breadcrumb/connectBreadcrumb'; -import type { ChatWidgetDescription } from '../connectors/chat/connectChat'; -import type { ClearRefinementsWidgetDescription } from '../connectors/clear-refinements/connectClearRefinements'; -import type { ConfigureWidgetDescription } from '../connectors/configure/connectConfigure'; -import type { CurrentRefinementsWidgetDescription } from '../connectors/current-refinements/connectCurrentRefinements'; -import type { FeedsWidgetDescription } from '../connectors/feeds/connectFeeds'; -import type { GeoSearchWidgetDescription } from '../connectors/geo-search/connectGeoSearch'; -import type { HierarchicalMenuWidgetDescription } from '../connectors/hierarchical-menu/connectHierarchicalMenu'; -import type { HitsPerPageWidgetDescription } from '../connectors/hits-per-page/connectHitsPerPage'; -import type { HitsWidgetDescription } from '../connectors/hits/connectHits'; -import type { InfiniteHitsWidgetDescription } from '../connectors/infinite-hits/connectInfiniteHits'; -import type { MenuWidgetDescription } from '../connectors/menu/connectMenu'; -import type { NumericMenuWidgetDescription } from '../connectors/numeric-menu/connectNumericMenu'; -import type { PaginationWidgetDescription } from '../connectors/pagination/connectPagination'; -import type { PoweredByWidgetDescription } from '../connectors/powered-by/connectPoweredBy'; -import type { QueryRulesWidgetDescription } from '../connectors/query-rules/connectQueryRules'; -import type { RangeWidgetDescription } from '../connectors/range/connectRange'; -import type { RatingMenuWidgetDescription } from '../connectors/rating-menu/connectRatingMenu'; -import type { RefinementListWidgetDescription } from '../connectors/refinement-list/connectRefinementList'; -import type { RelevantSortWidgetDescription } from '../connectors/relevant-sort/connectRelevantSort'; -import type { SearchBoxWidgetDescription } from '../connectors/search-box/connectSearchBox'; -import type { SortByWidgetDescription } from '../connectors/sort-by/connectSortBy'; -import type { StatsWidgetDescription } from '../connectors/stats/connectStats'; -import type { ToggleRefinementWidgetDescription } from '../connectors/toggle-refinement/connectToggleRefinement'; -import type { VoiceSearchWidgetDescription } from '../connectors/voice-search/connectVoiceSearch'; -import type { AnalyticsWidgetDescription } from '../widgets/analytics/analytics'; -import type { PlacesWidgetDescription } from '../widgets/places/places'; - -type ConnectorRenderStates = AnswersWidgetDescription['indexRenderState'] & - AutocompleteWidgetDescription['indexRenderState'] & - BreadcrumbWidgetDescription['indexRenderState'] & - ChatWidgetDescription['indexRenderState'] & - ClearRefinementsWidgetDescription['indexRenderState'] & - ConfigureWidgetDescription['indexRenderState'] & - CurrentRefinementsWidgetDescription['indexRenderState'] & - FeedsWidgetDescription['indexRenderState'] & - GeoSearchWidgetDescription['indexRenderState'] & - HierarchicalMenuWidgetDescription['indexRenderState'] & - HitsWidgetDescription['indexRenderState'] & - HitsPerPageWidgetDescription['indexRenderState'] & - InfiniteHitsWidgetDescription['indexRenderState'] & - MenuWidgetDescription['indexRenderState'] & - NumericMenuWidgetDescription['indexRenderState'] & - PaginationWidgetDescription['indexRenderState'] & - PoweredByWidgetDescription['indexRenderState'] & - QueryRulesWidgetDescription['indexRenderState'] & - RangeWidgetDescription['indexRenderState'] & - RatingMenuWidgetDescription['indexRenderState'] & - RefinementListWidgetDescription['indexRenderState'] & - RelevantSortWidgetDescription['indexRenderState'] & - SearchBoxWidgetDescription['indexRenderState'] & - SortByWidgetDescription['indexRenderState'] & - StatsWidgetDescription['indexRenderState'] & - ToggleRefinementWidgetDescription['indexRenderState'] & - VoiceSearchWidgetDescription['indexRenderState']; - -type WidgetRenderStates = AnalyticsWidgetDescription['indexRenderState'] & - PlacesWidgetDescription['indexRenderState']; - -export type IndexRenderState = Partial< - ConnectorRenderStates & WidgetRenderStates ->; - -export type RenderState = { - [indexId: string]: IndexRenderState; -}; - -export type WidgetRenderState = - TWidgetRenderState & { - widgetParams: TWidgetParams; - }; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/results.ts b/packages/instantsearch.js/src/types/results.ts index e8850505408..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/results.ts +++ b/packages/instantsearch.js/src/types/results.ts @@ -1,118 +1 @@ -import type { SearchOptions } from './algoliasearch'; -import type { - PlainSearchParameters, - RecommendParametersOptions, - RecommendResults, - SearchForFacetValues, - SearchResults, -} from 'algoliasearch-helper'; - -export type HitAttributeHighlightResult = { - value: string; - matchLevel: 'none' | 'partial' | 'full'; - matchedWords: string[]; - fullyHighlighted?: boolean; -}; - -export type HitHighlightResult = { - [attribute: string]: - | HitAttributeHighlightResult - | HitAttributeHighlightResult[] - | HitHighlightResult[] - | HitHighlightResult; -}; - -export type HitAttributeSnippetResult = Pick< - HitAttributeHighlightResult, - 'value' | 'matchLevel' ->; - -export type HitSnippetResult = { - [attribute: string]: - | HitAttributeSnippetResult[] - | HitSnippetResult[] - | HitAttributeSnippetResult - | HitSnippetResult; -}; - -export type GeoLoc = { - lat: number; - lng: number; -}; - -export type AlgoliaHit = Record> = - { - objectID: string; - _highlightResult?: HitHighlightResult; - _snippetResult?: HitSnippetResult; - _rankingInfo?: { - promoted: boolean; - nbTypos: number; - firstMatchedWord: number; - proximityDistance?: number; - geoDistance: number; - geoPrecision?: number; - nbExactWords: number; - words: number; - filters: number; - userScore: number; - matchedGeoLocation?: { - lat: number; - lng: number; - distance: number; - }; - }; - _distinctSeqID?: number; - _geoloc?: GeoLoc; - } & THit; - -export type BaseHit = Record; - -export type Hit = Record> = { - __position: number; - __queryID?: string; -} & AlgoliaHit; - -export type GeoHit = BaseHit> = Hit & - Required>; - -/** - * @deprecated use Hit[] directly instead - */ -export type Hits = Hit[]; - -export type EscapedHits = THit[] & { __escaped: boolean }; - -export type FacetHit = SearchForFacetValues.Hit; - -export type FacetRefinement = { - value: string; - type: 'conjunctive' | 'disjunctive' | 'exclude'; -}; - -export type NumericRefinement = { - value: number[]; - type: 'numeric'; - operator: string; -}; - -export type Refinement = FacetRefinement | NumericRefinement; - -export type CompositionFeedResult = NonNullable< - SearchResults['_rawResults'] ->[number] & { - feedID: string; -}; - -type InitialResult = { - state?: PlainSearchParameters; - results?: SearchResults['_rawResults']; - compositionFeedsResults?: CompositionFeedResult[]; - recommendResults?: { - params: NonNullable; - results: RecommendResults['_rawResults']; - }; - requestParams?: SearchOptions[]; -}; - -export type InitialResults = Record; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/router.ts b/packages/instantsearch.js/src/types/router.ts index 326b47e0b77..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/router.ts +++ b/packages/instantsearch.js/src/types/router.ts @@ -1,75 +1 @@ -import type { UiState } from './ui-state'; - -/** - * The router is the part that saves and reads the object from the storage. - * Usually this is the URL. - */ -export type Router = { - /** - * onUpdate Sets an event listener that is triggered when the storage is updated. - * The function should accept a callback to trigger when the update happens. - * In the case of the history / URL in a browser, the callback will be called - * by `onPopState`. - */ - onUpdate: (callback: (route: TRouteState) => void) => void; - - /** - * Reads the storage and gets a route object. It does not take parameters, - * and should return an object - */ - read: () => TRouteState; - - /** - * Pushes a route object into a storage. Takes the UI state mapped by the state - * mapping configured in the mapping - */ - write: (route: TRouteState) => void; - - /** - * Transforms a route object into a URL. It receives an object and should - * return a string. It may return an empty string. - */ - createURL: (state: TRouteState) => string; - - /** - * Called when InstantSearch is disposed. Used to remove subscriptions. - */ - dispose: () => void; - - /** - * Called when InstantSearch is started. - */ - start?: () => void; - - /** - * Identifier for this router. Used to differentiate between routers. - */ - $$type?: string; -}; - -/** - * The state mapping is a way to customize the structure before sending it to the router. - * It can transform and filter out the properties. To work correctly, the following - * should be valid for any UiState: - * `UiState = routeToState(stateToRoute(UiState))`. - */ -export type StateMapping = { - /** - * Transforms a UI state representation into a route object. - * It receives an object that contains the UI state of all the widgets in the page. - * It should return an object of any form as long as this form can be read by - * the `routeToState` function. - */ - stateToRoute: (uiState: TUiState) => TRouteState; - /** - * Transforms route object into a UI state representation. - * It receives an object that contains the UI state stored by the router. - * The format is the output of `stateToRoute`. - */ - routeToState: (routeState: TRouteState) => TUiState; - - /** - * Identifier for this stateMapping. Used to differentiate between stateMappings. - */ - $$type?: string; -}; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/ui-state.ts b/packages/instantsearch.js/src/types/ui-state.ts index 13f0d029eac..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/ui-state.ts +++ b/packages/instantsearch.js/src/types/ui-state.ts @@ -1,44 +1 @@ -import type { AutocompleteWidgetDescription } from '../connectors/autocomplete/connectAutocomplete'; -import type { ConfigureWidgetDescription } from '../connectors/configure/connectConfigure'; -import type { GeoSearchWidgetDescription } from '../connectors/geo-search/connectGeoSearch'; -import type { HierarchicalMenuWidgetDescription } from '../connectors/hierarchical-menu/connectHierarchicalMenu'; -import type { HitsPerPageWidgetDescription } from '../connectors/hits-per-page/connectHitsPerPage'; -import type { InfiniteHitsWidgetDescription } from '../connectors/infinite-hits/connectInfiniteHits'; -import type { MenuWidgetDescription } from '../connectors/menu/connectMenu'; -import type { NumericMenuWidgetDescription } from '../connectors/numeric-menu/connectNumericMenu'; -import type { PaginationWidgetDescription } from '../connectors/pagination/connectPagination'; -import type { RangeWidgetDescription } from '../connectors/range/connectRange'; -import type { RatingMenuWidgetDescription } from '../connectors/rating-menu/connectRatingMenu'; -import type { RefinementListWidgetDescription } from '../connectors/refinement-list/connectRefinementList'; -import type { RelevantSortWidgetDescription } from '../connectors/relevant-sort/connectRelevantSort'; -import type { SearchBoxWidgetDescription } from '../connectors/search-box/connectSearchBox'; -import type { SortByWidgetDescription } from '../connectors/sort-by/connectSortBy'; -import type { ToggleRefinementWidgetDescription } from '../connectors/toggle-refinement/connectToggleRefinement'; -import type { VoiceSearchWidgetDescription } from '../connectors/voice-search/connectVoiceSearch'; -import type { PlacesWidgetDescription } from '../widgets/places/places'; - -type ConnectorUiStates = AutocompleteWidgetDescription['indexUiState'] & - ConfigureWidgetDescription['indexUiState'] & - GeoSearchWidgetDescription['indexUiState'] & - HierarchicalMenuWidgetDescription['indexUiState'] & - HitsPerPageWidgetDescription['indexUiState'] & - InfiniteHitsWidgetDescription['indexUiState'] & - MenuWidgetDescription['indexUiState'] & - NumericMenuWidgetDescription['indexUiState'] & - PaginationWidgetDescription['indexUiState'] & - RangeWidgetDescription['indexUiState'] & - RatingMenuWidgetDescription['indexUiState'] & - RefinementListWidgetDescription['indexUiState'] & - RelevantSortWidgetDescription['indexUiState'] & - SearchBoxWidgetDescription['indexUiState'] & - SortByWidgetDescription['indexUiState'] & - ToggleRefinementWidgetDescription['indexUiState'] & - VoiceSearchWidgetDescription['indexUiState']; - -type WidgetUiStates = PlacesWidgetDescription['indexUiState']; - -export type IndexUiState = Partial; - -export type UiState = { - [indexId: string]: IndexUiState; -}; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/utils.ts b/packages/instantsearch.js/src/types/utils.ts index 7a2b73f97ff..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/utils.ts +++ b/packages/instantsearch.js/src/types/utils.ts @@ -1,26 +1 @@ -export type HighlightedParts = { - value: string; - isHighlighted: boolean; -}; - -// https://stackoverflow.com/questions/48230773/how-to-create-a-partial-like-that-requires-a-single-property-to-be-set/48244432#48244432 -export type AtLeastOne< - TTarget, - TMapped = { [Key in keyof TTarget]: Pick } -> = Partial & TMapped[keyof TMapped]; - -// removes intermediary composed types in IntelliSense -export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; - -// Make certain keys in an object required -export type RequiredKeys = Expand< - Required> & Omit ->; - -export type Awaited = T extends PromiseLike ? Awaited : T; - -/** - * Make certain keys of an object optional. - */ -export type PartialKeys = Omit & - Partial>; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/widget-factory.ts b/packages/instantsearch.js/src/types/widget-factory.ts index f55c13f7aee..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/widget-factory.ts +++ b/packages/instantsearch.js/src/types/widget-factory.ts @@ -1,21 +1 @@ -import type { UnknownWidgetParams, Widget, WidgetDescription } from './widget'; - -/** - * The function that creates a new widget. - */ -export type WidgetFactory< - TWidgetDescription extends WidgetDescription, - TConnectorParams extends UnknownWidgetParams, - TWidgetParams extends UnknownWidgetParams -> = ( - /** - * The params of the widget. - */ - widgetParams: TWidgetParams & TConnectorParams -) => Widget< - TWidgetDescription & { - widgetParams: TConnectorParams; - } ->; - -export type UnknownWidgetFactory = WidgetFactory<{ $$type: string }, any, any>; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index 8271e5397f9..1b4fd8425dc 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -1,388 +1 @@ -import type { IndexWidget } from '../widgets'; -import type { RecommendResponse } from './algoliasearch'; -import type { InstantSearch } from './instantsearch'; -import type { IndexRenderState, WidgetRenderState } from './render-state'; -import type { IndexUiState, UiState } from './ui-state'; -import type { Expand, RequiredKeys } from './utils'; -import type { - AlgoliaSearchHelper as Helper, - SearchParameters, - SearchResults, - RecommendParameters, -} from 'algoliasearch-helper'; - -export type ScopedResult = { - indexId: string; - results: SearchResults | null; - helper: Helper; -}; - -type SharedRenderOptions = { - instantSearchInstance: InstantSearch; - parent: IndexWidget; - templatesConfig: Record; - scopedResults: ScopedResult[]; - state: SearchParameters; - renderState: IndexRenderState; - helper: Helper; - /** @deprecated use `status` instead */ - searchMetadata: { - /** @deprecated use `status === "stalled"` instead */ - isSearchStalled: boolean; - }; - status: InstantSearch['status']; - error: InstantSearch['error']; - createURL: ( - nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) - ) => string; -}; - -export type InitOptions = SharedRenderOptions & { - uiState: UiState; - results?: undefined; -}; - -export type ShouldRenderOptions = { instantSearchInstance: InstantSearch }; - -export type RenderOptions = SharedRenderOptions & { - results: SearchResults | null; -}; - -export type DisposeOptions = { - helper: Helper; - state: SearchParameters; - recommendState: RecommendParameters; - parent: IndexWidget; -}; - -export const indexWidgetTypes = ['ais.index', 'ais.feedContainer'] as const; -export type IndexWidgetType = (typeof indexWidgetTypes)[number]; - -// @MAJOR: Remove these exported types if we don't need them -export type BuiltinTypes = - | 'ais.analytics' - | 'ais.answers' - | 'ais.autocomplete' - | 'ais.breadcrumb' - | 'ais.clearRefinements' - | 'ais.chat' - | 'ais.configure' - | 'ais.configureRelatedItems' - | 'ais.currentRefinements' - | 'ais.dynamicWidgets' - | 'ais.feedContainer' - | 'ais.feeds' - | 'ais.frequentlyBoughtTogether' - | 'ais.geoSearch' - | 'ais.hierarchicalMenu' - | 'ais.hits' - | 'ais.hitsPerPage' - | 'ais.index' - | 'ais.infiniteHits' - | 'ais.lookingSimilar' - | 'ais.menu' - | 'ais.numericMenu' - | 'ais.pagination' - | 'ais.places' - | 'ais.poweredBy' - | 'ais.queryRules' - // @TODO: remove individual types for rangeSlider & rangeInput once updating checkIndexUiState - | 'ais.range' - | 'ais.rangeSlider' - | 'ais.rangeInput' - | 'ais.ratingMenu' - | 'ais.refinementList' - | 'ais.relatedProducts' - | 'ais.searchBox' - | 'ais.relevantSort' - | 'ais.sortBy' - | 'ais.stats' - | 'ais.toggleRefinement' - | 'ais.trendingFacets' - | 'ais.trendingItems' - | 'ais.voiceSearch'; - -export type BuiltinWidgetTypes = - | 'ais.analytics' - | 'ais.answers' - | 'ais.autocomplete' - | 'ais.breadcrumb' - | 'ais.chat' - | 'ais.clearRefinements' - | 'ais.configure' - | 'ais.configureRelatedItems' - | 'ais.currentRefinements' - | 'ais.dynamicWidgets' - | 'ais.feedContainer' - | 'ais.feeds' - | 'ais.frequentlyBoughtTogether' - | 'ais.geoSearch' - | 'ais.hierarchicalMenu' - | 'ais.hits' - | 'ais.hitsPerPage' - | 'ais.index' - | 'ais.infiniteHits' - | 'ais.lookingSimilar' - | 'ais.menu' - | 'ais.menuSelect' - | 'ais.numericMenu' - | 'ais.pagination' - | 'ais.places' - | 'ais.poweredBy' - | 'ais.queryRuleCustomData' - | 'ais.queryRuleContext' - | 'ais.rangeInput' - | 'ais.rangeSlider' - | 'ais.ratingMenu' - | 'ais.refinementList' - | 'ais.relatedProducts' - | 'ais.searchBox' - | 'ais.relevantSort' - | 'ais.sortBy' - | 'ais.stats' - | 'ais.toggleRefinement' - | 'ais.trendingFacets' - | 'ais.trendingItems' - | 'ais.voiceSearch'; - -export type UnknownWidgetParams = NonNullable; - -export type WidgetParams = { - widgetParams?: UnknownWidgetParams; -}; - -export type WidgetDescription = { - $$type: string; - $$widgetType?: string; - renderState?: Record; - indexRenderState?: Record; - indexUiState?: Record; -}; - -type SearchWidget = { - dependsOn?: 'search'; - getWidgetParameters?: ( - state: SearchParameters, - widgetParametersOptions: { - uiState: Expand< - Partial - >; - } - ) => SearchParameters; -}; - -type RecommendRenderOptions = SharedRenderOptions & { - results: RecommendResponse; -}; - -type RecommendWidget< - TWidgetDescription extends WidgetDescription & WidgetParams -> = { - dependsOn: 'recommend'; - $$id?: number; - getWidgetParameters: ( - state: RecommendParameters, - widgetParametersOptions: { - uiState: Expand< - Partial - >; - } - ) => RecommendParameters; - getRenderState: ( - renderState: Expand< - IndexRenderState & Partial - >, - renderOptions: InitOptions | RecommendRenderOptions - ) => IndexRenderState & TWidgetDescription['indexRenderState']; - getWidgetRenderState: ( - renderOptions: InitOptions | RecommendRenderOptions - ) => Expand< - WidgetRenderState< - TWidgetDescription['renderState'], - TWidgetDescription['widgetParams'] - > - >; -}; - -type Parent = { - /** - * This gets dynamically added by the `index` widget. - * If the widget has gone through `addWidget`, it will have a parent. - */ - parent?: IndexWidget; -}; - -type RequiredWidgetLifeCycle = { - /** - * Identifier for connectors and widgets. - */ - $$type: TWidgetDescription['$$type']; - - /** - * Called once before the first search. - */ - init?: (options: InitOptions) => void; - /** - * Whether `render` should be called - */ - shouldRender?: (options: ShouldRenderOptions) => boolean; - /** - * Called after each search response has been received. - */ - render?: (options: RenderOptions) => void; - /** - * Called when this widget is unmounted. Used to remove refinements set by - * during this widget's initialization and life time. - */ - dispose?: ( - options: DisposeOptions - ) => SearchParameters | RecommendParameters | void; -}; - -type RequiredWidgetType = { - /** - * Identifier for widgets. - */ - $$widgetType: TWidgetDescription['$$widgetType']; -}; - -type WidgetType = - TWidgetDescription extends RequiredKeys - ? RequiredWidgetType - : { - /** - * Identifier for widgets. - */ - $$widgetType?: string; - }; - -type RequiredUiStateLifeCycle = { - /** - * This function is required for a widget to be taken in account for routing. - * It will derive a uiState for this widget based on the existing uiState and - * the search parameters applied. - * - * @param uiState - Current state. - * @param widgetStateOptions - Extra information to calculate uiState. - */ - getWidgetUiState: ( - uiState: Expand>, - widgetUiStateOptions: { - searchParameters: SearchParameters; - helper: Helper; - } - ) => Partial; - - /** - * This function is required for a widget to be taken in account for routing. - * It will derive a uiState for this widget based on the existing uiState and - * the search parameters applied. - * - * @deprecated Use `getWidgetUiState` instead. - * @param uiState - Current state. - * @param widgetStateOptions - Extra information to calculate uiState. - */ - getWidgetState?: RequiredUiStateLifeCycle['getWidgetUiState']; - - /** - * This function is required for a widget to behave correctly when a URL is - * loaded via e.g. Routing. It receives the current UiState and applied search - * parameters, and is expected to return a new search parameters. - * - * @param state - Applied search parameters. - * @param widgetSearchParametersOptions - Extra information to calculate next searchParameters. - */ - getWidgetSearchParameters: ( - state: SearchParameters, - widgetSearchParametersOptions: { - uiState: Expand< - Partial - >; - } - ) => SearchParameters; -}; - -type UiStateLifeCycle = - TWidgetDescription extends RequiredKeys - ? RequiredUiStateLifeCycle - : Partial>; - -type RequiredRenderStateLifeCycle< - TWidgetDescription extends WidgetDescription & WidgetParams -> = { - /** - * Returns the render state of the current widget to pass to the render function. - */ - getWidgetRenderState: ( - renderOptions: InitOptions | RenderOptions - ) => Expand< - WidgetRenderState< - TWidgetDescription['renderState'], - TWidgetDescription['widgetParams'] - > - >; - /** - * Returns IndexRenderState of the current index component tree - * to build the render state of the whole app. - */ - getRenderState: ( - renderState: Expand< - IndexRenderState & Partial - >, - renderOptions: InitOptions | RenderOptions - ) => IndexRenderState & TWidgetDescription['indexRenderState']; -}; - -type RenderStateLifeCycle< - TWidgetDescription extends WidgetDescription & WidgetParams -> = TWidgetDescription extends RequiredKeys< - WidgetDescription, - 'renderState' | 'indexRenderState' -> & - WidgetParams - ? RequiredRenderStateLifeCycle - : Partial>; - -export type Widget< - TWidgetDescription extends WidgetDescription & WidgetParams = { - $$type: string; - } -> = Expand< - Parent & - RequiredWidgetLifeCycle & - WidgetType & - UiStateLifeCycle & - RenderStateLifeCycle -> & - (SearchWidget | RecommendWidget); - -export type { IndexWidget } from '../widgets'; - -export type TransformItemsMetadata = { - results: SearchResults | undefined | null; -}; - -/** - * Transforms the given items. - */ -export type TransformItems = ( - items: TItem[], - metadata: TMetadata -) => TItem[]; - -type SortByDirection = - | TCriterion - | `${TCriterion}:asc` - | `${TCriterion}:desc`; - -/** - * Transforms the given items. - */ -export type SortBy = - | ((a: TItem, b: TItem) => number) - | Array>; - -/** - * Creates the URL for the given value. - */ -export type CreateURL = (value: TValue) => string; +export * from 'instantsearch-core'; diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index acde8c475bb..6c9d6d639e9 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -817,7 +817,7 @@ function AutocompleteWrapper({ query={localQuery} inputProps={{ ...inputProps, - onInput: (event) => { + onInput: (event: any) => { const query = event.currentTarget.value; setLocalQuery(query); refineAutocomplete(query); diff --git a/packages/instantsearch.js/src/widgets/index.ts b/packages/instantsearch.js/src/widgets/index.ts index 9b2144d826a..4166b5b3706 100644 --- a/packages/instantsearch.js/src/widgets/index.ts +++ b/packages/instantsearch.js/src/widgets/index.ts @@ -51,6 +51,7 @@ export { default as rangeInput } from './range-input/range-input'; export { default as rangeSlider } from './range-slider/range-slider'; export { default as ratingMenu } from './rating-menu/rating-menu'; export { default as refinementList } from './refinement-list/refinement-list'; +export type { RefinementListWidgetParams } from './refinement-list/refinement-list'; export { default as relevantSort } from './relevant-sort/relevant-sort'; export { default as searchBox } from './search-box/search-box'; export { default as sortBy } from './sort-by/sort-by'; diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index b02a1ce3720..9df9117b355 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -1,1083 +1,2 @@ -import algoliasearchHelper from 'algoliasearch-helper'; - -import { - checkIndexUiState, - createDocumentationMessageGenerator, - resolveSearchParameters, - mergeSearchParameters, - warning, - isIndexWidget, - createInitArgs, - createRenderArgs, - storeRenderState, - defer, -} from '../../lib/utils'; -import { addWidgetId } from '../../lib/utils/addWidgetId'; - -import type { - InstantSearch, - UiState, - IndexUiState, - Widget, - ScopedResult, - RenderOptions, - RecommendResponse, - SearchClient, - IndexWidgetType, -} from '../../types'; -import type { - AlgoliaSearchHelper as Helper, - DerivedHelper, - SearchParameters, - SearchResults, - AlgoliaSearchHelper, - RecommendParameters, -} from 'algoliasearch-helper'; - -const withUsage = createDocumentationMessageGenerator({ - name: 'index-widget', -}); - -export type IndexWidgetParams = - | { - /** - * The index or composition id to target. - */ - indexName: string; - /** - * Id to use for the index if there are multiple indices with the same name. - * This will be used to create the URL and the render state. - */ - indexId?: string; - /** - * If `true`, the index will not be merged with the main helper's state. - * This means that the index will not be part of the main search request. - * - * @default false - */ - EXPERIMENTAL_isolated?: false; - } - | { - /** - * If `true`, the index will not be merged with the main helper's state. - * This means that the index will not be part of the main search request. - * - * This option is EXPERIMENTAL, and implementation details may change in the future. - * Things that could change are: - * - which widgets get rendered when a change happens - * - whether the index searches automatically - * - whether the index is included in the URL / UiState - * - whether the index is included in server-side rendering - * - * @default false - */ - EXPERIMENTAL_isolated: true; - /** - * The index or composition id to target. - */ - indexName?: string; - /** - * Id to use for the index if there are multiple indices with the same name. - * This will be used to create the URL and the render state. - */ - indexId?: string; - }; - -export type IndexInitOptions = { - instantSearchInstance: InstantSearch; - parent: IndexWidget | null; - uiState: UiState; -}; - -export type IndexRenderOptions = { - instantSearchInstance: InstantSearch; -}; - -type WidgetSearchParametersOptions = Parameters< - NonNullable ->[1]; -type LocalWidgetSearchParametersOptions = WidgetSearchParametersOptions & { - initialSearchParameters: SearchParameters; -}; -type LocalWidgetRecommendParametersOptions = WidgetSearchParametersOptions & { - initialRecommendParameters: RecommendParameters; -}; - -export type IndexWidgetDescription = { - $$type: IndexWidgetType; - $$widgetType: IndexWidgetType; -}; - -export type IndexWidget = Omit< - Widget, - 'getWidgetUiState' | 'getWidgetState' -> & { - getIndexName: () => string; - getIndexId: () => string; - getHelper: () => Helper | null; - getResults: () => SearchResults | null; - getResultsForWidget: ( - widget: IndexWidget | Widget - ) => SearchResults | RecommendResponse | null; - getPreviousState: () => SearchParameters | null; - getScopedResults: () => ScopedResult[]; - getParent: () => IndexWidget | null; - getWidgets: () => Array; - createURL: ( - nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) - ) => string; - - addWidgets: ( - widgets: Array> - ) => IndexWidget; - removeWidgets: ( - widgets: Array - ) => IndexWidget; - - init: (options: IndexInitOptions) => void; - render: (options: IndexRenderOptions) => void; - dispose: () => void; - /** - * @deprecated - */ - getWidgetState: (uiState: UiState) => UiState; - getWidgetUiState: ( - uiState: TSpecificUiState - ) => TSpecificUiState; - getWidgetSearchParameters: ( - searchParameters: SearchParameters, - searchParametersOptions: { uiState: IndexUiState } - ) => SearchParameters; - /** - * Set this index' UI state back to the state defined by the widgets. - * Can only be called after `init`. - */ - refreshUiState: () => void; - /** - * Set this index' UI state and search. This is the equivalent of calling - * a spread `setUiState` on the InstantSearch instance. - * Can only be called after `init`. - */ - setIndexUiState: ( - indexUiState: - | TUiState[string] - | ((previousIndexUiState: TUiState[string]) => TUiState[string]) - ) => void; - /** - * This index is isolated, meaning it will not be merged with the main - * helper's state. - * @private - */ - _isolated: boolean; - /** - * Schedules a search for this index only. - * @private - */ - scheduleLocalSearch: () => void; -}; - -/** - * This is the same content as helper._change / setState, but allowing for extra - * UiState to be synchronized. - * see: https://github.com/algolia/algoliasearch-helper-js/blob/6b835ffd07742f2d6b314022cce6848f5cfecd4a/src/algoliasearch.helper.js#L1311-L1324 - */ -function privateHelperSetState( - helper: AlgoliaSearchHelper, - { - state, - recommendState, - isPageReset, - _uiState, - }: { - state: SearchParameters; - recommendState: RecommendParameters; - isPageReset?: boolean; - _uiState?: IndexUiState; - } -) { - if (state !== helper.state) { - helper.state = state; - - helper.emit('change', { - state: helper.state, - results: helper.lastResults, - isPageReset, - _uiState, - }); - } - - if (recommendState !== helper.recommendState) { - helper.recommendState = recommendState; - - // eslint-disable-next-line no-warning-comments - // TODO: emit "change" event when events for Recommend are implemented - } -} - -type WidgetUiStateOptions = Parameters< - NonNullable ->[1]; - -function getLocalWidgetsUiState( - widgets: Array, - widgetStateOptions: WidgetUiStateOptions, - initialUiState: IndexUiState = {} -) { - return widgets.reduce((uiState, widget) => { - if (isIndexWidget(widget)) { - return uiState; - } - - if (!widget.getWidgetUiState && !widget.getWidgetState) { - return uiState; - } - - if (widget.getWidgetUiState) { - return widget.getWidgetUiState(uiState, widgetStateOptions); - } - - return widget.getWidgetState!(uiState, widgetStateOptions); - }, initialUiState); -} - -function getLocalWidgetsSearchParameters( - widgets: Array, - widgetSearchParametersOptions: LocalWidgetSearchParametersOptions -): SearchParameters { - const { initialSearchParameters, ...rest } = widgetSearchParametersOptions; - - return widgets.reduce((state, widget) => { - if (!widget.getWidgetSearchParameters || isIndexWidget(widget)) { - return state; - } - - if (widget.dependsOn === 'search' && widget.getWidgetParameters) { - return widget.getWidgetParameters(state, rest); - } - - return widget.getWidgetSearchParameters(state, rest); - }, initialSearchParameters); -} - -function getLocalWidgetsRecommendParameters( - widgets: Array, - widgetRecommendParametersOptions: LocalWidgetRecommendParametersOptions -): RecommendParameters { - const { initialRecommendParameters, ...rest } = - widgetRecommendParametersOptions; - - return widgets.reduce((state, widget) => { - if ( - !isIndexWidget(widget) && - widget.dependsOn === 'recommend' && - widget.getWidgetParameters - ) { - return widget.getWidgetParameters(state, rest); - } - return state; - }, initialRecommendParameters); -} - -function resetPageFromWidgets(widgets: Array): void { - const indexWidgets = widgets.filter(isIndexWidget); - - if (indexWidgets.length === 0) { - return; - } - - indexWidgets.forEach((widget) => { - const widgetHelper = widget.getHelper()!; - - privateHelperSetState(widgetHelper, { - state: widgetHelper.state.resetPage(), - recommendState: widgetHelper.recommendState, - isPageReset: true, - }); - - resetPageFromWidgets(widget.getWidgets()); - }); -} - -function resolveScopedResultsFromWidgets( - widgets: Array -): ScopedResult[] { - const indexWidgets = widgets.filter(isIndexWidget); - - return indexWidgets.reduce((scopedResults, current) => { - return scopedResults.concat( - { - indexId: current.getIndexId(), - results: current.getResults()!, - helper: current.getHelper()!, - }, - ...resolveScopedResultsFromWidgets(current.getWidgets()) - ); - }, []); -} - -const index = (widgetParams: IndexWidgetParams): IndexWidget => { - if ( - widgetParams === undefined || - (widgetParams.indexName === undefined && - !widgetParams.EXPERIMENTAL_isolated) - ) { - throw new Error(withUsage('The `indexName` option is required.')); - } - - // When isolated=true, we use an empty string as the default indexName. - // This is intentional: isolated indices do not require a real index name. - const { - indexName = '', - indexId = indexName, - EXPERIMENTAL_isolated: isolated = false, - } = widgetParams; - - let localWidgets: Array = []; - let localUiState: IndexUiState = {}; - let localInstantSearchInstance: InstantSearch | null = null; - let localParent: IndexWidget | null = null; - let helper: Helper | null = null; - let derivedHelper: DerivedHelper | null = null; - let lastValidSearchParameters: SearchParameters | null = null; - let hasRecommendWidget: boolean = false; - let hasSearchWidget: boolean = false; - - return { - $$type: 'ais.index', - $$widgetType: 'ais.index', - - _isolated: isolated, - - getIndexName() { - return indexName; - }, - - getIndexId() { - return indexId; - }, - - getHelper() { - return helper; - }, - - getResults() { - if (!derivedHelper?.lastResults) return null; - - // To make the UI optimistic, we patch the state to display to the current - // one instead of the one associated with the latest results. - // This means user-driven UI changes (e.g., checked checkbox) are reflected - // immediately instead of waiting for Algolia to respond, regardless of - // the status of the network request. - derivedHelper.lastResults._state = helper!.state; - - return derivedHelper.lastResults; - }, - - getResultsForWidget(widget) { - if ( - widget.dependsOn !== 'recommend' || - isIndexWidget(widget) || - widget.$$id === undefined - ) { - return this.getResults(); - } - - if (!helper?.lastRecommendResults) { - return null; - } - - return helper.lastRecommendResults[widget.$$id]; - }, - - getPreviousState() { - return lastValidSearchParameters; - }, - - getScopedResults() { - const widgetParent = this.getParent(); - let widgetSiblings; - - if (widgetParent) { - widgetSiblings = widgetParent.getWidgets(); - } else if (indexName.length === 0) { - // The widget is the root but has no index name: - // we resolve results from its children index widgets - widgetSiblings = this.getWidgets(); - } else { - // The widget is the root and has an index name: - // we consider itself as the only sibling - widgetSiblings = [this]; - } - - return resolveScopedResultsFromWidgets(widgetSiblings); - }, - - getParent() { - return isolated ? null : localParent; - }, - - createURL( - nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) - ) { - if (typeof nextState === 'function') { - return localInstantSearchInstance!._createURL({ - [indexId]: nextState(localUiState), - }); - } - return localInstantSearchInstance!._createURL({ - [indexId]: getLocalWidgetsUiState(localWidgets, { - searchParameters: nextState, - helper: helper!, - }), - }); - }, - - scheduleLocalSearch: defer(() => { - if (isolated) { - helper?.search(); - } - }), - - getWidgets() { - return localWidgets; - }, - - addWidgets(widgets) { - if (!Array.isArray(widgets)) { - throw new Error( - withUsage('The `addWidgets` method expects an array of widgets.') - ); - } - const flatWidgets = widgets.reduce>( - (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), - [] - ); - - if ( - flatWidgets.some( - (widget) => - typeof widget.init !== 'function' && - typeof widget.render !== 'function' - ) - ) { - throw new Error( - withUsage( - 'The widget definition expects a `render` and/or an `init` method.' - ) - ); - } - - flatWidgets.forEach((widget) => { - widget.parent = this; - if (isIndexWidget(widget)) { - return; - } - - if (localInstantSearchInstance && widget.dependsOn === 'recommend') { - localInstantSearchInstance._hasRecommendWidget = true; - } else if (localInstantSearchInstance) { - localInstantSearchInstance._hasSearchWidget = true; - } else if (widget.dependsOn === 'recommend') { - hasRecommendWidget = true; - } else { - hasSearchWidget = true; - } - - addWidgetId(widget); - }); - - localWidgets = localWidgets.concat(flatWidgets); - if (localInstantSearchInstance && Boolean(flatWidgets.length)) { - privateHelperSetState(helper!, { - state: getLocalWidgetsSearchParameters(localWidgets, { - uiState: localUiState, - initialSearchParameters: helper!.state, - }), - recommendState: getLocalWidgetsRecommendParameters(localWidgets, { - uiState: localUiState, - initialRecommendParameters: helper!.recommendState, - }), - _uiState: localUiState, - }); - - // 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`. - flatWidgets.forEach((widget) => { - if (widget.getRenderState) { - const renderState = widget.getRenderState( - localInstantSearchInstance!.renderState[this.getIndexId()] || {}, - createInitArgs( - localInstantSearchInstance!, - this, - localInstantSearchInstance!._initialUiState - ) - ); - - storeRenderState({ - renderState, - instantSearchInstance: localInstantSearchInstance!, - parent: this, - }); - } - }); - - flatWidgets.forEach((widget) => { - if (widget.init) { - widget.init( - createInitArgs( - localInstantSearchInstance!, - this, - localInstantSearchInstance!._initialUiState - ) - ); - } - }); - - if (isolated) { - this.scheduleLocalSearch(); - } else { - localInstantSearchInstance.scheduleSearch(); - } - } - - return this; - }, - - removeWidgets(widgets) { - if (!Array.isArray(widgets)) { - throw new Error( - withUsage('The `removeWidgets` method expects an array of widgets.') - ); - } - const flatWidgets = widgets.reduce>( - (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), - [] - ); - - if (flatWidgets.some((widget) => typeof widget.dispose !== 'function')) { - throw new Error( - withUsage('The widget definition expects a `dispose` method.') - ); - } - - localWidgets = localWidgets.filter( - (widget) => flatWidgets.indexOf(widget) === -1 - ); - - localWidgets.forEach((widget) => { - widget.parent = undefined; - if (isIndexWidget(widget)) { - return; - } - - if (localInstantSearchInstance && widget.dependsOn === 'recommend') { - localInstantSearchInstance._hasRecommendWidget = true; - } else if (localInstantSearchInstance) { - localInstantSearchInstance._hasSearchWidget = true; - } else if (widget.dependsOn === 'recommend') { - hasRecommendWidget = true; - } else { - hasSearchWidget = true; - } - }); - - if (localInstantSearchInstance && Boolean(flatWidgets.length)) { - const { cleanedSearchState, cleanedRecommendState } = - flatWidgets.reduce( - (states, widget) => { - // the `dispose` method exists at this point we already assert it - const next = widget.dispose!({ - helper: helper!, - state: states.cleanedSearchState, - recommendState: states.cleanedRecommendState, - parent: this, - }); - - if (next instanceof algoliasearchHelper.RecommendParameters) { - states.cleanedRecommendState = next; - } else if (next) { - states.cleanedSearchState = next; - } - - return states; - }, - { - cleanedSearchState: helper!.state, - cleanedRecommendState: helper!.recommendState, - } - ); - - const newState = localInstantSearchInstance.future - .preserveSharedStateOnUnmount - ? getLocalWidgetsSearchParameters(localWidgets, { - uiState: localUiState, - initialSearchParameters: new algoliasearchHelper.SearchParameters( - { - index: this.getIndexName(), - } - ), - }) - : getLocalWidgetsSearchParameters(localWidgets, { - uiState: getLocalWidgetsUiState(localWidgets, { - searchParameters: cleanedSearchState, - helper: helper!, - }), - initialSearchParameters: cleanedSearchState, - }); - - localUiState = getLocalWidgetsUiState(localWidgets, { - searchParameters: newState, - helper: helper!, - }); - - helper!.setState(newState); - helper!.recommendState = cleanedRecommendState; - - if (localWidgets.length) { - if (isolated) { - this.scheduleLocalSearch(); - } else { - localInstantSearchInstance.scheduleSearch(); - } - } - } - - return this; - }, - - init({ instantSearchInstance, parent, uiState }: IndexInitOptions) { - if (helper !== null) { - // helper is already initialized, therefore we do not need to set up - // any listeners - return; - } - - localInstantSearchInstance = instantSearchInstance; - localParent = parent; - localUiState = uiState[indexId] || {}; - - // The `mainHelper` is already defined at this point. The instance is created - // inside InstantSearch at the `start` method, which occurs before the `init` - // step. - const mainHelper = instantSearchInstance.mainHelper!; - const parameters = getLocalWidgetsSearchParameters(localWidgets, { - uiState: localUiState, - initialSearchParameters: new algoliasearchHelper.SearchParameters({ - index: indexName, - }), - }); - const recommendParameters = getLocalWidgetsRecommendParameters( - localWidgets, - { - uiState: localUiState, - initialRecommendParameters: - new algoliasearchHelper.RecommendParameters(), - } - ); - - // This Helper is only used for state management we do not care about the - // `searchClient`. Only the "main" Helper created at the `InstantSearch` - // level is aware of the client. - helper = algoliasearchHelper( - mainHelper.getClient(), - parameters.index, - parameters - ); - helper.recommendState = recommendParameters; - - // We forward the call to `search` to the "main" instance of the Helper - // which is responsible for managing the queries (it's the only one that is - // aware of the `searchClient`). - helper.search = () => { - if (isolated) { - instantSearchInstance.status = 'loading'; - this.render({ instantSearchInstance }); - return instantSearchInstance.compositionID - ? helper!.searchWithComposition() - : helper!.searchOnlyWithDerivedHelpers(); - } - - if (instantSearchInstance.onStateChange) { - instantSearchInstance.onStateChange({ - uiState: instantSearchInstance.mainIndex.getWidgetUiState({}), - setUiState: (nextState) => - instantSearchInstance.setUiState(nextState, false), - }); - - // We don't trigger a search when controlled because it becomes the - // responsibility of `setUiState`. - return mainHelper; - } - - return mainHelper.search(); - }; - - helper.searchWithoutTriggeringOnStateChange = () => { - return mainHelper.search(); - }; - - // We use the same pattern for the `searchForFacetValues`. - helper.searchForFacetValues = ( - facetName, - facetValue, - maxFacetHits, - userState - ) => { - const state = mergeSearchParameters( - mainHelper.state, - ...resolveSearchParameters(this) - ).setQueryParameters(userState!); - - return mainHelper.searchForFacetValues( - facetName, - facetValue, - maxFacetHits, - state - ); - }; - - const isolatedHelper = indexName - ? helper - : algoliasearchHelper({} as SearchClient, '__empty_index__', {}); - const derivingHelper = isolated - ? isolatedHelper - : nearestIsolatedHelper(parent, mainHelper); - - derivedHelper = derivingHelper.derive( - () => - mergeSearchParameters( - mainHelper.state, - ...resolveSearchParameters(this) - ), - () => this.getHelper()!.recommendState - ); - - const indexInitialResults = - instantSearchInstance._initialResults?.[this.getIndexId()]; - - if (indexInitialResults?.results) { - // We restore the shape of the results provided to the instance to respect - // the helper's structure. - const results = new algoliasearchHelper.SearchResults( - new algoliasearchHelper.SearchParameters(indexInitialResults.state), - indexInitialResults.results - ); - - derivedHelper.lastResults = results; - helper.lastResults = results; - } - - if (indexInitialResults?.recommendResults) { - const recommendResults = new algoliasearchHelper.RecommendResults( - new algoliasearchHelper.RecommendParameters({ - params: indexInitialResults.recommendResults.params, - }), - indexInitialResults.recommendResults.results - ); - derivedHelper.lastRecommendResults = recommendResults; - helper.lastRecommendResults = recommendResults; - } - - // Subscribe to the Helper state changes for the page before widgets - // are initialized. This behavior mimics the original one of the Helper. - // It makes sense to replicate it at the `init` step. We have another - // listener on `change` below, once `init` is done. - helper.on('change', ({ isPageReset }) => { - if (isPageReset) { - resetPageFromWidgets(localWidgets); - } - }); - - derivedHelper.on('search', () => { - // The index does not manage the "staleness" of the search. This is the - // responsibility of the main instance. It does not make sense to manage - // it at the index level because it's either: all of them or none of them - // that are stalled. The queries are performed into a single network request. - instantSearchInstance.scheduleStalledRender(); - - if (__DEV__) { - checkIndexUiState({ index: this, indexUiState: localUiState }); - } - }); - - derivedHelper.on('result', ({ results }) => { - // The index does not render the results it schedules a new render - // to let all the other indices emit their own results. It allows us to - // run the render process in one pass. - instantSearchInstance.scheduleRender(); - - // the derived helper is the one which actually searches, but the helper - // which is exposed e.g. via instance.helper, doesn't search, and thus - // does not have access to lastResults, which it used to in pre-federated - // search behavior. - helper!.lastResults = results; - lastValidSearchParameters = results?._state; - }); - - // eslint-disable-next-line no-warning-comments - // TODO: listen to "result" event when events for Recommend are implemented - derivedHelper.on('recommend:result', ({ recommend }) => { - // The index does not render the results it schedules a new render - // to let all the other indices emit their own results. It allows us to - // run the render process in one pass. - instantSearchInstance.scheduleRender(); - - // the derived helper is the one which actually searches, but the helper - // which is exposed e.g. via instance.helper, doesn't search, and thus - // does not have access to lastRecommendResults. - helper!.lastRecommendResults = recommend.results; - }); - - // 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`. - localWidgets.forEach((widget) => { - if (widget.getRenderState) { - const renderState = widget.getRenderState( - instantSearchInstance.renderState[this.getIndexId()] || {}, - createInitArgs(instantSearchInstance, this, uiState) - ); - - storeRenderState({ - renderState, - instantSearchInstance, - parent: this, - }); - } - }); - - localWidgets.forEach((widget) => { - warning( - // if it has NO getWidgetState or if it has getWidgetUiState, we don't warn - // aka we warn if there's _only_ getWidgetState - !widget.getWidgetState || Boolean(widget.getWidgetUiState), - 'The `getWidgetState` method is renamed `getWidgetUiState` and will no longer exist under that name in InstantSearch.js 5.x. Please use `getWidgetUiState` instead.' - ); - - if (widget.init) { - widget.init(createInitArgs(instantSearchInstance, this, uiState)); - } - }); - - // Subscribe to the Helper state changes for the `uiState` once widgets - // are initialized. Until the first render, state changes are part of the - // configuration step. This is mainly for backward compatibility with custom - // widgets. When the subscription happens before the `init` step, the (static) - // configuration of the widget is pushed in the URL. That's what we want to avoid. - // https://github.com/algolia/instantsearch/pull/994/commits/4a672ae3fd78809e213de0368549ef12e9dc9454 - helper.on('change', (event) => { - const { state } = event; - - const _uiState = (event as any)._uiState; - - localUiState = getLocalWidgetsUiState( - localWidgets, - { - searchParameters: state, - helper: helper!, - }, - _uiState || {} - ); - - // We don't trigger an internal change when controlled because it - // becomes the responsibility of `setUiState`. - if (!instantSearchInstance.onStateChange) { - instantSearchInstance.onInternalStateChange(); - } - }); - - if (indexInitialResults) { - // If there are initial results, we're not notified of the next results - // because we don't trigger an initial search. We therefore need to directly - // schedule a render that will render the results injected on the helper. - instantSearchInstance.scheduleRender(); - } - - if (hasRecommendWidget) { - instantSearchInstance._hasRecommendWidget = true; - } - if (hasSearchWidget) { - instantSearchInstance._hasSearchWidget = true; - } - }, - - render({ instantSearchInstance }: IndexRenderOptions) { - // we can't attach a listener to the error event of search, as the error - // then would no longer be thrown for global handlers. - if ( - instantSearchInstance.status === 'error' && - !instantSearchInstance.mainHelper!.hasPendingRequests() && - lastValidSearchParameters - ) { - helper!.setState(lastValidSearchParameters); - } - - // We only render index widgets if there are no results. - // This makes sure `render` is never called with `results` being `null`. - // If it's an isolated index without an index name, we render all widgets, - // as there are no results to display for the isolated index itself. - let widgetsToRender = - this.getResults() || - derivedHelper?.lastRecommendResults || - (isolated && !indexName) - ? localWidgets - : localWidgets.filter((widget) => widget.shouldRender); - - widgetsToRender = widgetsToRender.filter((widget) => { - if (!widget.shouldRender) { - return true; - } - - return widget.shouldRender({ instantSearchInstance }); - }); - - widgetsToRender.forEach((widget) => { - if (widget.getRenderState) { - const renderState = widget.getRenderState( - instantSearchInstance.renderState[this.getIndexId()] || {}, - createRenderArgs( - instantSearchInstance, - this, - widget - ) as RenderOptions - ); - - storeRenderState({ - renderState, - instantSearchInstance, - parent: this, - }); - } - }); - - widgetsToRender.forEach((widget) => { - // At this point, all the variables used below are set. Both `helper` - // and `derivedHelper` have been created at the `init` step. The attribute - // `lastResults` might be `null` though. It's possible that a stalled render - // happens before the result e.g with a dynamically added index the request might - // be delayed. The render is triggered for the complete tree but some parts do - // not have results yet. - - if (widget.render) { - widget.render( - createRenderArgs( - instantSearchInstance, - this, - widget - ) as RenderOptions - ); - } - }); - }, - - dispose() { - localWidgets.forEach((widget) => { - if (widget.dispose && helper) { - // The dispose function is always called once the instance is started - // (it's an effect of `removeWidgets`). The index is initialized and - // the Helper is available. We don't care about the return value of - // `dispose` because the index is removed. We can't call `removeWidgets` - // because we want to keep the widgets on the instance, to allow idempotent - // operations on `add` & `remove`. - widget.dispose({ - helper, - state: helper.state, - recommendState: helper.recommendState, - parent: this, - }); - } - }); - - localInstantSearchInstance = null; - localParent = null; - helper?.removeAllListeners(); - helper = null; - - derivedHelper?.detach(); - derivedHelper = null; - }, - - getWidgetUiState(uiState: TUiState) { - return localWidgets - .filter(isIndexWidget) - .filter((w) => !w._isolated) - .reduce( - (previousUiState, innerIndex) => - innerIndex.getWidgetUiState(previousUiState), - { - ...uiState, - [indexId]: { - ...uiState[indexId], - ...localUiState, - }, - } - ); - }, - - getWidgetState(uiState: UiState) { - warning( - false, - 'The `getWidgetState` method is renamed `getWidgetUiState` and will no longer exist under that name in InstantSearch.js 5.x. Please use `getWidgetUiState` instead.' - ); - - return this.getWidgetUiState(uiState); - }, - - getWidgetSearchParameters(searchParameters, { uiState }) { - return getLocalWidgetsSearchParameters(localWidgets, { - uiState, - initialSearchParameters: searchParameters, - }); - }, - - shouldRender() { - return true; - }, - - refreshUiState() { - localUiState = getLocalWidgetsUiState( - localWidgets, - { - searchParameters: this.getHelper()!.state, - helper: this.getHelper()!, - }, - localUiState - ); - }, - - setIndexUiState( - indexUiState: - | TIndexUiState - | ((previousIndexUiState: TIndexUiState) => TIndexUiState) - ) { - const nextIndexUiState = - typeof indexUiState === 'function' - ? indexUiState(localUiState as TIndexUiState) - : indexUiState; - - localInstantSearchInstance!.setUiState((state) => ({ - ...state, - [indexId]: nextIndexUiState, - })); - }, - }; -}; - -export default index; - -/** - * Walk up the parent chain to find the closest isolated index, or fall back to mainHelper - */ -function nearestIsolatedHelper( - current: IndexWidget | null, - mainHelper: Helper -): Helper { - while (current) { - if (current._isolated) { - return current.getHelper()!; - } - current = current.getParent(); - } - return mainHelper; -} +export { index as default } from 'instantsearch-core'; +export type * from 'instantsearch-core'; diff --git a/packages/react-instantsearch-core/package.json b/packages/react-instantsearch-core/package.json index 579a6248149..a881ea4df85 100644 --- a/packages/react-instantsearch-core/package.json +++ b/packages/react-instantsearch-core/package.json @@ -50,7 +50,8 @@ "instantsearch.js": "4.96.2", "use-sync-external-store": "^1.0.0", "zod": "^3.25.76 || ^4", - "zod-to-json-schema": "3.24.6" + "zod-to-json-schema": "3.24.6", + "instantsearch-core": "0.1.0" }, "devDependencies": { "@types/use-sync-external-store": "0.0.3", diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index f49a5a5c315..a9b5d80d7d5 100644 --- a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx +++ b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx @@ -4,7 +4,7 @@ import { useDynamicWidgets } from '../connectors/useDynamicWidgets'; import { invariant } from '../lib/invariant'; import { warn } from '../lib/warn'; -import type { DynamicWidgetsConnectorParams } from 'instantsearch.js/es/connectors/dynamic-widgets/connectDynamicWidgets'; +import type { DynamicWidgetsConnectorParams } from 'instantsearch-core'; import type { ReactElement, ComponentType, ReactNode } from 'react'; function DefaultFallbackComponent() { diff --git a/packages/react-instantsearch-core/src/components/Feeds.tsx b/packages/react-instantsearch-core/src/components/Feeds.tsx index de8e6dae548..44fa6f363bc 100644 --- a/packages/react-instantsearch-core/src/components/Feeds.tsx +++ b/packages/react-instantsearch-core/src/components/Feeds.tsx @@ -1,4 +1,4 @@ -import { createFeedContainer } from 'instantsearch.js/es/connectors/feeds/FeedContainer'; +import { createFeedContainer } from 'instantsearch-core'; import React, { useEffect, useRef } from 'react'; import { useFeeds } from '../connectors/useFeeds'; @@ -6,8 +6,8 @@ import { IndexContext } from '../lib/IndexContext'; import { useIndexContext } from '../lib/useIndexContext'; import { useInstantSearchContext } from '../lib/useInstantSearchContext'; -import type { FeedsConnectorParams } from 'instantsearch.js/es/connectors/feeds/connectFeeds'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { FeedsConnectorParams } from 'instantsearch-core'; +import type { IndexWidget } from 'instantsearch-core'; import type { ReactNode } from 'react'; export type FeedsProps = FeedsConnectorParams & { diff --git a/packages/react-instantsearch-core/src/components/InstantSearch.tsx b/packages/react-instantsearch-core/src/components/InstantSearch.tsx index 0cfcf190a42..e59c417cd2b 100644 --- a/packages/react-instantsearch-core/src/components/InstantSearch.tsx +++ b/packages/react-instantsearch-core/src/components/InstantSearch.tsx @@ -11,7 +11,7 @@ import type { import type { InstantSearch as InstantSearchType, UiState, -} from 'instantsearch.js'; +} from 'instantsearch-core'; export type InstantSearchProps< TUiState extends UiState = UiState, diff --git a/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx b/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx index a179ce9860e..13adddaad91 100644 --- a/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx +++ b/packages/react-instantsearch-core/src/components/InstantSearchSSRProvider.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { InstantSearchSSRContext } from '../lib/InstantSearchSSRContext'; import type { InternalInstantSearch } from '../lib/useInstantSearchApi'; -import type { InitialResults, UiState } from 'instantsearch.js'; +import type { InitialResults, UiState } from 'instantsearch-core'; import type { ReactNode } from 'react'; export type InstantSearchServerState = { diff --git a/packages/react-instantsearch-core/src/components/InstantSearchServerContext.ts b/packages/react-instantsearch-core/src/components/InstantSearchServerContext.ts index 3f7d46a03fb..5df00c85bc9 100644 --- a/packages/react-instantsearch-core/src/components/InstantSearchServerContext.ts +++ b/packages/react-instantsearch-core/src/components/InstantSearchServerContext.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; -import type { InstantSearch, UiState } from 'instantsearch.js'; +import type { InstantSearch, UiState } from 'instantsearch-core'; export type InstantSearchServerContextApi< TUiState extends UiState, diff --git a/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx index 91ab2d8aedd..d1ea5b524fd 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx @@ -21,7 +21,7 @@ import type { UseMenuProps } from '../../connectors/useMenu'; import type { UsePaginationProps } from '../../connectors/usePagination'; import type { UseRefinementListProps } from '../../connectors/useRefinementList'; import type { InstantSearchProps } from '../InstantSearch'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { IndexWidget } from 'instantsearch-core'; expect.addSnapshotSerializer(widgetSnapshotSerializer); diff --git a/packages/react-instantsearch-core/src/components/__tests__/Feeds.integration.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/Feeds.integration.test.tsx index 1b859bac998..726352c6b63 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/Feeds.integration.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/Feeds.integration.test.tsx @@ -13,7 +13,7 @@ import { InstantSearch } from '../InstantSearch'; import { InstantSearchSSRProvider } from '../InstantSearchSSRProvider'; import type { InstantSearchProps } from '../InstantSearch'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { IndexWidget } from 'instantsearch-core'; function createInstantSearchMock() { const indexContextRef = createRef(); diff --git a/packages/react-instantsearch-core/src/components/__tests__/Feeds.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/Feeds.test.tsx index c541c5996ab..c667411b9f1 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/Feeds.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/Feeds.test.tsx @@ -3,7 +3,7 @@ */ import { act, render, screen, waitFor } from '@testing-library/react'; -import { createFeedContainer } from 'instantsearch.js/es/connectors/feeds/FeedContainer'; +import { createFeedContainer } from 'instantsearch-core'; import React from 'react'; import { IndexContext } from '../../lib/IndexContext'; @@ -11,7 +11,7 @@ import { InstantSearchContext } from '../../lib/InstantSearchContext'; import { useIndexContext } from '../../lib/useIndexContext'; import { Feeds } from '../Feeds'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { IndexWidget } from 'instantsearch-core'; let mockFeedIDs: string[] = []; @@ -19,7 +19,7 @@ jest.mock('../../connectors/useFeeds', () => ({ useFeeds: jest.fn(() => ({ feedIDs: mockFeedIDs })), })); -jest.mock('instantsearch.js/es/connectors/feeds/FeedContainer', () => ({ +jest.mock('instantsearch-core', () => ({ createFeedContainer: jest.fn(), })); diff --git a/packages/react-instantsearch-core/src/components/__tests__/Index.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/Index.test.tsx index d6262785b00..8da30c3e98b 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/Index.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/Index.test.tsx @@ -17,7 +17,7 @@ import { Index } from '../Index'; import { InstantSearch } from '../InstantSearch'; import { InstantSearchSSRProvider } from '../InstantSearchSSRProvider'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { IndexWidget } from 'instantsearch-core'; describe('Index', () => { test('throws when used outside of ', () => { diff --git a/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx index f8abe3ac951..873f6623a2f 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/InstantSearch.test.tsx @@ -6,8 +6,8 @@ import { createAlgoliaSearchClient } from '@instantsearch/mocks'; import { createInstantSearchSpy, wait } from '@instantsearch/testutils'; import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { history } from 'instantsearch.js/es/lib/routers'; -import { simple } from 'instantsearch.js/es/lib/stateMappings'; +import { history } from 'instantsearch-core'; +import { simple } from 'instantsearch-core'; import React, { StrictMode, Suspense, version as ReactVersion } from 'react'; import { SearchBox } from 'react-instantsearch'; diff --git a/packages/react-instantsearch-core/src/components/__tests__/InstantSearchSSRProvider.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/InstantSearchSSRProvider.test.tsx index 8089591325f..7d403d8f36c 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/InstantSearchSSRProvider.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/InstantSearchSSRProvider.test.tsx @@ -11,15 +11,15 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import algoliasearchV4 from 'algoliasearch-v4'; import { algoliasearch as algoliasearchV5 } from 'algoliasearch-v5'; -import { history } from 'instantsearch.js/es/lib/routers'; -import { simple } from 'instantsearch.js/es/lib/stateMappings'; +import { history } from 'instantsearch-core'; +import { simple } from 'instantsearch-core'; import React, { StrictMode } from 'react'; import { Hits, RefinementList, SearchBox } from 'react-instantsearch'; import { InstantSearch } from '../InstantSearch'; import { InstantSearchSSRProvider } from '../InstantSearchSSRProvider'; -import type { Hit as AlgoliaHit, SearchClient } from 'instantsearch.js'; +import type { Hit as AlgoliaHit, SearchClient } from 'instantsearch-core'; function HitComponent({ hit }: { hit: AlgoliaHit }) { return <>{hit.objectID}; diff --git a/packages/react-instantsearch-core/src/components/__tests__/routing/dispose-start.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/routing/dispose-start.test.tsx index 806280a5872..29e44100281 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/routing/dispose-start.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/routing/dispose-start.test.tsx @@ -4,7 +4,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { render, waitFor } from '@testing-library/react'; -import historyRouter from 'instantsearch.js/es/lib/routers/history'; +import { history as historyRouter } from 'instantsearch-core'; import React, { useEffect } from 'react'; import { InstantSearch, SearchBox, useSearchBox } from 'react-instantsearch'; diff --git a/packages/react-instantsearch-core/src/components/__tests__/routing/external-influence.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/routing/external-influence.test.tsx index b26bc801871..b9d631db14e 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/routing/external-influence.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/routing/external-influence.test.tsx @@ -4,7 +4,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { render, waitFor } from '@testing-library/react'; -import historyRouter from 'instantsearch.js/es/lib/routers/history'; +import { history as historyRouter } from 'instantsearch-core'; import React, { useEffect } from 'react'; import { InstantSearch, SearchBox, useSearchBox } from 'react-instantsearch'; diff --git a/packages/react-instantsearch-core/src/components/__tests__/routing/modal.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/routing/modal.test.tsx index 0ba66edce8a..82b607200dd 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/routing/modal.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/routing/modal.test.tsx @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import historyRouter from 'instantsearch.js/es/lib/routers/history'; +import { history as historyRouter } from 'instantsearch-core'; import React from 'react'; import { InstantSearch, SearchBox } from 'react-instantsearch'; diff --git a/packages/react-instantsearch-core/src/components/__tests__/routing/spa-debounced.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/routing/spa-debounced.test.tsx index dd1676edea2..8aa938690ca 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/routing/spa-debounced.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/routing/spa-debounced.test.tsx @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import historyRouter from 'instantsearch.js/es/lib/routers/history'; +import { history as historyRouter } from 'instantsearch-core'; import React from 'react'; import { InstantSearch, SearchBox } from 'react-instantsearch'; diff --git a/packages/react-instantsearch-core/src/components/__tests__/routing/spa-replace-state.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/routing/spa-replace-state.test.tsx index a17599b7aba..8bc8ef68ec7 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/routing/spa-replace-state.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/routing/spa-replace-state.test.tsx @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import historyRouter from 'instantsearch.js/es/lib/routers/history'; +import { history as historyRouter } from 'instantsearch-core'; import React from 'react'; import { InstantSearch, SearchBox } from 'react-instantsearch'; diff --git a/packages/react-instantsearch-core/src/components/__tests__/routing/spa.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/routing/spa.test.tsx index 67a589db950..062afe52bee 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/routing/spa.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/routing/spa.test.tsx @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import historyRouter from 'instantsearch.js/es/lib/routers/history'; +import { history as historyRouter } from 'instantsearch-core'; import React from 'react'; import { InstantSearch, SearchBox } from 'react-instantsearch'; diff --git a/packages/react-instantsearch-core/src/connectors/useAutocomplete.ts b/packages/react-instantsearch-core/src/connectors/useAutocomplete.ts index 6b8d54b1774..596bb37c471 100644 --- a/packages/react-instantsearch-core/src/connectors/useAutocomplete.ts +++ b/packages/react-instantsearch-core/src/connectors/useAutocomplete.ts @@ -1,4 +1,4 @@ -import connectAutocomplete from 'instantsearch.js/es/connectors/autocomplete/connectAutocomplete'; +import { connectAutocomplete as connectAutocomplete } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { AutocompleteConnectorParams, AutocompleteWidgetDescription, -} from 'instantsearch.js/es/connectors/autocomplete/connectAutocomplete'; +} from 'instantsearch-core'; export type UseAutocompleteProps = AutocompleteConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useBreadcrumb.ts b/packages/react-instantsearch-core/src/connectors/useBreadcrumb.ts index 9e61d459a7f..1a0c7ad1f8c 100644 --- a/packages/react-instantsearch-core/src/connectors/useBreadcrumb.ts +++ b/packages/react-instantsearch-core/src/connectors/useBreadcrumb.ts @@ -1,4 +1,4 @@ -import connectBreadcrumb from 'instantsearch.js/es/connectors/breadcrumb/connectBreadcrumb'; +import { connectBreadcrumb as connectBreadcrumb } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { BreadcrumbConnectorParams, BreadcrumbWidgetDescription, -} from 'instantsearch.js/es/connectors/breadcrumb/connectBreadcrumb'; +} from 'instantsearch-core'; export type UseBreadcrumbProps = BreadcrumbConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useChat.ts b/packages/react-instantsearch-core/src/connectors/useChat.ts index 973161e0739..50d5ae4fcd3 100644 --- a/packages/react-instantsearch-core/src/connectors/useChat.ts +++ b/packages/react-instantsearch-core/src/connectors/useChat.ts @@ -1,4 +1,4 @@ -import connectChat from 'instantsearch.js/es/connectors/chat/connectChat'; +import { connectChat as connectChat } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -7,8 +7,8 @@ import type { ChatConnector, ChatConnectorParams, ChatWidgetDescription, -} from 'instantsearch.js/es/connectors/chat/connectChat'; -import type { UIMessage } from 'instantsearch.js/es/lib/chat'; +} from 'instantsearch-core'; +import type { UIMessage } from 'instantsearch-core'; export type UseChatProps = ChatConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useClearRefinements.ts b/packages/react-instantsearch-core/src/connectors/useClearRefinements.ts index 4c71e8ec4ce..030eeac7fd7 100644 --- a/packages/react-instantsearch-core/src/connectors/useClearRefinements.ts +++ b/packages/react-instantsearch-core/src/connectors/useClearRefinements.ts @@ -1,4 +1,4 @@ -import connectClearRefinements from 'instantsearch.js/es/connectors/clear-refinements/connectClearRefinements'; +import { connectClearRefinements as connectClearRefinements } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { ClearRefinementsConnectorParams, ClearRefinementsWidgetDescription, -} from 'instantsearch.js/es/connectors/clear-refinements/connectClearRefinements'; +} from 'instantsearch-core'; export type UseClearRefinementsProps = ClearRefinementsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useConfigure.ts b/packages/react-instantsearch-core/src/connectors/useConfigure.ts index 20306b05df8..b4e4bce40ab 100644 --- a/packages/react-instantsearch-core/src/connectors/useConfigure.ts +++ b/packages/react-instantsearch-core/src/connectors/useConfigure.ts @@ -1,4 +1,4 @@ -import connectConfigure from 'instantsearch.js/es/connectors/configure/connectConfigure'; +import { connectConfigure as connectConfigure } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { ConfigureConnectorParams, ConfigureWidgetDescription, -} from 'instantsearch.js/es/connectors/configure/connectConfigure'; +} from 'instantsearch-core'; export type UseConfigureProps = ConfigureConnectorParams['searchParameters']; diff --git a/packages/react-instantsearch-core/src/connectors/useCurrentRefinements.ts b/packages/react-instantsearch-core/src/connectors/useCurrentRefinements.ts index 02f3765d573..75d2825eabb 100644 --- a/packages/react-instantsearch-core/src/connectors/useCurrentRefinements.ts +++ b/packages/react-instantsearch-core/src/connectors/useCurrentRefinements.ts @@ -1,4 +1,4 @@ -import connectCurrentRefinements from 'instantsearch.js/es/connectors/current-refinements/connectCurrentRefinements'; +import { connectCurrentRefinements as connectCurrentRefinements } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { CurrentRefinementsConnectorParams, CurrentRefinementsWidgetDescription, -} from 'instantsearch.js/es/connectors/current-refinements/connectCurrentRefinements'; +} from 'instantsearch-core'; export type UseCurrentRefinementsProps = CurrentRefinementsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useDynamicWidgets.ts b/packages/react-instantsearch-core/src/connectors/useDynamicWidgets.ts index 6735c048b38..f034390a176 100644 --- a/packages/react-instantsearch-core/src/connectors/useDynamicWidgets.ts +++ b/packages/react-instantsearch-core/src/connectors/useDynamicWidgets.ts @@ -1,4 +1,4 @@ -import connectDynamicWidgets from 'instantsearch.js/es/connectors/dynamic-widgets/connectDynamicWidgets'; +import { connectDynamicWidgets as connectDynamicWidgets } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { DynamicWidgetsConnectorParams, DynamicWidgetsWidgetDescription, -} from 'instantsearch.js/es/connectors/dynamic-widgets/connectDynamicWidgets'; +} from 'instantsearch-core'; export type UseDynamicWidgetsProps = Omit< DynamicWidgetsConnectorParams, diff --git a/packages/react-instantsearch-core/src/connectors/useFeeds.ts b/packages/react-instantsearch-core/src/connectors/useFeeds.ts index 24db4dd9131..da43ac0eba3 100644 --- a/packages/react-instantsearch-core/src/connectors/useFeeds.ts +++ b/packages/react-instantsearch-core/src/connectors/useFeeds.ts @@ -1,4 +1,4 @@ -import connectFeeds from 'instantsearch.js/es/connectors/feeds/connectFeeds'; +import { connectFeeds as connectFeeds } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { FeedsConnectorParams, FeedsWidgetDescription, -} from 'instantsearch.js/es/connectors/feeds/connectFeeds'; +} from 'instantsearch-core'; export type UseFeedsProps = FeedsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useFilterSuggestions.ts b/packages/react-instantsearch-core/src/connectors/useFilterSuggestions.ts index d472dca5a93..40554eb25db 100644 --- a/packages/react-instantsearch-core/src/connectors/useFilterSuggestions.ts +++ b/packages/react-instantsearch-core/src/connectors/useFilterSuggestions.ts @@ -1,4 +1,4 @@ -import connectFilterSuggestions from 'instantsearch.js/es/connectors/filter-suggestions/connectFilterSuggestions'; +import { connectFilterSuggestions as connectFilterSuggestions } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { FilterSuggestionsConnectorParams, FilterSuggestionsWidgetDescription, -} from 'instantsearch.js/es/connectors/filter-suggestions/connectFilterSuggestions'; +} from 'instantsearch-core'; export type UseFilterSuggestionsProps = FilterSuggestionsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useFrequentlyBoughtTogether.ts b/packages/react-instantsearch-core/src/connectors/useFrequentlyBoughtTogether.ts index 34a95accc1d..1baa957d329 100644 --- a/packages/react-instantsearch-core/src/connectors/useFrequentlyBoughtTogether.ts +++ b/packages/react-instantsearch-core/src/connectors/useFrequentlyBoughtTogether.ts @@ -1,14 +1,14 @@ -import connectFrequentlyBoughtTogether from 'instantsearch.js/es/connectors/frequently-bought-together/connectFrequentlyBoughtTogether'; +import { connectFrequentlyBoughtTogether as connectFrequentlyBoughtTogether } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; import type { AdditionalWidgetProperties } from '../hooks/useConnector'; -import type { BaseHit } from 'instantsearch.js'; +import type { BaseHit } from 'instantsearch-core'; import type { FrequentlyBoughtTogetherConnector, FrequentlyBoughtTogetherConnectorParams, FrequentlyBoughtTogetherWidgetDescription, -} from 'instantsearch.js/es/connectors/frequently-bought-together/connectFrequentlyBoughtTogether'; +} from 'instantsearch-core'; export type UseFrequentlyBoughtTogetherProps = FrequentlyBoughtTogetherConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useGeoSearch.ts b/packages/react-instantsearch-core/src/connectors/useGeoSearch.ts index 57b69fb7dcd..1983c444da8 100644 --- a/packages/react-instantsearch-core/src/connectors/useGeoSearch.ts +++ b/packages/react-instantsearch-core/src/connectors/useGeoSearch.ts @@ -1,14 +1,14 @@ -import connectGeoSearch from 'instantsearch.js/es/connectors/geo-search/connectGeoSearch'; +import { connectGeoSearch as connectGeoSearch } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; import type { AdditionalWidgetProperties } from '../hooks/useConnector'; -import type { GeoHit } from 'instantsearch.js'; +import type { GeoHit } from 'instantsearch-core'; import type { GeoSearchConnector, GeoSearchConnectorParams, GeoSearchWidgetDescription, -} from 'instantsearch.js/es/connectors/geo-search/connectGeoSearch'; +} from 'instantsearch-core'; export type UseGeoSearchProps = GeoSearchConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useHierarchicalMenu.ts b/packages/react-instantsearch-core/src/connectors/useHierarchicalMenu.ts index b10cd55d483..ed38f5b474d 100644 --- a/packages/react-instantsearch-core/src/connectors/useHierarchicalMenu.ts +++ b/packages/react-instantsearch-core/src/connectors/useHierarchicalMenu.ts @@ -1,4 +1,4 @@ -import connectHierarchicalMenu from 'instantsearch.js/es/connectors/hierarchical-menu/connectHierarchicalMenu'; +import { connectHierarchicalMenu as connectHierarchicalMenu } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { HierarchicalMenuConnectorParams, HierarchicalMenuWidgetDescription, -} from 'instantsearch.js/es/connectors/hierarchical-menu/connectHierarchicalMenu'; +} from 'instantsearch-core'; export type UseHierarchicalMenuProps = HierarchicalMenuConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useHits.ts b/packages/react-instantsearch-core/src/connectors/useHits.ts index a24b439f2c6..8224f4d38d4 100644 --- a/packages/react-instantsearch-core/src/connectors/useHits.ts +++ b/packages/react-instantsearch-core/src/connectors/useHits.ts @@ -1,14 +1,14 @@ -import connectHits from 'instantsearch.js/es/connectors/hits/connectHits'; +import { connectHits as connectHits } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; import type { AdditionalWidgetProperties } from '../hooks/useConnector'; -import type { BaseHit } from 'instantsearch.js'; +import type { BaseHit } from 'instantsearch-core'; import type { HitsConnectorParams, HitsWidgetDescription, HitsConnector, -} from 'instantsearch.js/es/connectors/hits/connectHits'; +} from 'instantsearch-core'; export type UseHitsProps = HitsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useHitsPerPage.ts b/packages/react-instantsearch-core/src/connectors/useHitsPerPage.ts index dafa5260d2d..5860bfa7e62 100644 --- a/packages/react-instantsearch-core/src/connectors/useHitsPerPage.ts +++ b/packages/react-instantsearch-core/src/connectors/useHitsPerPage.ts @@ -1,4 +1,4 @@ -import connectHitsPerPage from 'instantsearch.js/es/connectors/hits-per-page/connectHitsPerPage'; +import { connectHitsPerPage as connectHitsPerPage } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { HitsPerPageConnectorParams, HitsPerPageWidgetDescription, -} from 'instantsearch.js/es/connectors/hits-per-page/connectHitsPerPage'; +} from 'instantsearch-core'; export type UseHitsPerPageProps = HitsPerPageConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useInfiniteHits.ts b/packages/react-instantsearch-core/src/connectors/useInfiniteHits.ts index f5bb0454abe..a2e7b811575 100644 --- a/packages/react-instantsearch-core/src/connectors/useInfiniteHits.ts +++ b/packages/react-instantsearch-core/src/connectors/useInfiniteHits.ts @@ -1,14 +1,14 @@ -import connectInfiniteHits from 'instantsearch.js/es/connectors/infinite-hits/connectInfiniteHits'; +import { connectInfiniteHits as connectInfiniteHits } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; import type { AdditionalWidgetProperties } from '../hooks/useConnector'; -import type { BaseHit } from 'instantsearch.js'; +import type { BaseHit } from 'instantsearch-core'; import type { InfiniteHitsConnectorParams, InfiniteHitsWidgetDescription, InfiniteHitsConnector, -} from 'instantsearch.js/es/connectors/infinite-hits/connectInfiniteHits'; +} from 'instantsearch-core'; export type UseInfiniteHitsProps = InfiniteHitsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useLookingSimilar.ts b/packages/react-instantsearch-core/src/connectors/useLookingSimilar.ts index ddd4efd3d3b..de7a6decfb3 100644 --- a/packages/react-instantsearch-core/src/connectors/useLookingSimilar.ts +++ b/packages/react-instantsearch-core/src/connectors/useLookingSimilar.ts @@ -1,14 +1,14 @@ -import connectLookingSimilar from 'instantsearch.js/es/connectors/looking-similar/connectLookingSimilar'; +import { connectLookingSimilar as connectLookingSimilar } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; import type { AdditionalWidgetProperties } from '../hooks/useConnector'; -import type { BaseHit } from 'instantsearch.js'; +import type { BaseHit } from 'instantsearch-core'; import type { LookingSimilarConnector, LookingSimilarConnectorParams, LookingSimilarWidgetDescription, -} from 'instantsearch.js/es/connectors/looking-similar/connectLookingSimilar'; +} from 'instantsearch-core'; export type UseLookingSimilarProps = LookingSimilarConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useMenu.ts b/packages/react-instantsearch-core/src/connectors/useMenu.ts index d8485060fba..7f1abadb4eb 100644 --- a/packages/react-instantsearch-core/src/connectors/useMenu.ts +++ b/packages/react-instantsearch-core/src/connectors/useMenu.ts @@ -1,4 +1,4 @@ -import connectMenu from 'instantsearch.js/es/connectors/menu/connectMenu'; +import { connectMenu as connectMenu } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { MenuConnectorParams, MenuWidgetDescription, -} from 'instantsearch.js/es/connectors/menu/connectMenu'; +} from 'instantsearch-core'; export type UseMenuProps = MenuConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useNumericMenu.ts b/packages/react-instantsearch-core/src/connectors/useNumericMenu.ts index b732020772f..343f00f1522 100644 --- a/packages/react-instantsearch-core/src/connectors/useNumericMenu.ts +++ b/packages/react-instantsearch-core/src/connectors/useNumericMenu.ts @@ -1,4 +1,4 @@ -import connectNumericMenu from 'instantsearch.js/es/connectors/numeric-menu/connectNumericMenu'; +import { connectNumericMenu as connectNumericMenu } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { NumericMenuConnectorParams, NumericMenuWidgetDescription, -} from 'instantsearch.js/es/connectors/numeric-menu/connectNumericMenu'; +} from 'instantsearch-core'; export type UseNumericMenuProps = NumericMenuConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/usePagination.ts b/packages/react-instantsearch-core/src/connectors/usePagination.ts index 3e9ca5b9953..4a4af1abcc3 100644 --- a/packages/react-instantsearch-core/src/connectors/usePagination.ts +++ b/packages/react-instantsearch-core/src/connectors/usePagination.ts @@ -1,4 +1,4 @@ -import connectPagination from 'instantsearch.js/es/connectors/pagination/connectPagination'; +import { connectPagination as connectPagination } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { PaginationConnectorParams, PaginationWidgetDescription, -} from 'instantsearch.js/es/connectors/pagination/connectPagination'; +} from 'instantsearch-core'; export type UsePaginationProps = PaginationConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/usePoweredBy.ts b/packages/react-instantsearch-core/src/connectors/usePoweredBy.ts index 1819eb3759e..5b11c76b771 100644 --- a/packages/react-instantsearch-core/src/connectors/usePoweredBy.ts +++ b/packages/react-instantsearch-core/src/connectors/usePoweredBy.ts @@ -1,6 +1,6 @@ -import { safelyRunOnBrowser } from 'instantsearch.js/es/lib/utils'; +import { safelyRunOnBrowser } from 'instantsearch-core'; -import type { PoweredByRenderState } from 'instantsearch.js/es/connectors/powered-by/connectPoweredBy'; +import type { PoweredByRenderState } from 'instantsearch-core'; export function usePoweredBy(): PoweredByRenderState { const hostname = safelyRunOnBrowser( diff --git a/packages/react-instantsearch-core/src/connectors/useQueryRules.ts b/packages/react-instantsearch-core/src/connectors/useQueryRules.ts index fe5195412d1..51e37f7ff46 100644 --- a/packages/react-instantsearch-core/src/connectors/useQueryRules.ts +++ b/packages/react-instantsearch-core/src/connectors/useQueryRules.ts @@ -1,4 +1,4 @@ -import connectQueryRules from 'instantsearch.js/es/connectors/query-rules/connectQueryRules'; +import { connectQueryRules as connectQueryRules } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { QueryRulesConnectorParams, QueryRulesWidgetDescription, -} from 'instantsearch.js/es/connectors/query-rules/connectQueryRules'; +} from 'instantsearch-core'; export type UseQueryRulesProps = QueryRulesConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useRange.ts b/packages/react-instantsearch-core/src/connectors/useRange.ts index a35501616a5..7ca697c9188 100644 --- a/packages/react-instantsearch-core/src/connectors/useRange.ts +++ b/packages/react-instantsearch-core/src/connectors/useRange.ts @@ -1,4 +1,4 @@ -import connectRange from 'instantsearch.js/es/connectors/range/connectRange'; +import { connectRange as connectRange } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { RangeConnectorParams, RangeWidgetDescription, -} from 'instantsearch.js/es/connectors/range/connectRange'; +} from 'instantsearch-core'; export type UseRangeProps = RangeConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useRefinementList.ts b/packages/react-instantsearch-core/src/connectors/useRefinementList.ts index 71e44054488..ab30c5a0583 100644 --- a/packages/react-instantsearch-core/src/connectors/useRefinementList.ts +++ b/packages/react-instantsearch-core/src/connectors/useRefinementList.ts @@ -1,4 +1,4 @@ -import connectRefinementList from 'instantsearch.js/es/connectors/refinement-list/connectRefinementList'; +import { connectRefinementList as connectRefinementList } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { RefinementListConnectorParams, RefinementListWidgetDescription, -} from 'instantsearch.js/es/connectors/refinement-list/connectRefinementList'; +} from 'instantsearch-core'; export type UseRefinementListProps = RefinementListConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useRelatedProducts.ts b/packages/react-instantsearch-core/src/connectors/useRelatedProducts.ts index 653a554e756..2304a65e0ba 100644 --- a/packages/react-instantsearch-core/src/connectors/useRelatedProducts.ts +++ b/packages/react-instantsearch-core/src/connectors/useRelatedProducts.ts @@ -1,14 +1,14 @@ -import connectRelatedProducts from 'instantsearch.js/es/connectors/related-products/connectRelatedProducts'; +import { connectRelatedProducts as connectRelatedProducts } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; import type { AdditionalWidgetProperties } from '../hooks/useConnector'; -import type { BaseHit } from 'instantsearch.js'; +import type { BaseHit } from 'instantsearch-core'; import type { RelatedProductsConnector, RelatedProductsConnectorParams, RelatedProductsWidgetDescription, -} from 'instantsearch.js/es/connectors/related-products/connectRelatedProducts'; +} from 'instantsearch-core'; export type UseRelatedProductsProps = RelatedProductsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useSearchBox.ts b/packages/react-instantsearch-core/src/connectors/useSearchBox.ts index a9b242c9ebb..cc11570223f 100644 --- a/packages/react-instantsearch-core/src/connectors/useSearchBox.ts +++ b/packages/react-instantsearch-core/src/connectors/useSearchBox.ts @@ -1,4 +1,4 @@ -import connectSearchBox from 'instantsearch.js/es/connectors/search-box/connectSearchBox'; +import { connectSearchBox as connectSearchBox } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { SearchBoxConnectorParams, SearchBoxWidgetDescription, -} from 'instantsearch.js/es/connectors/search-box/connectSearchBox'; +} from 'instantsearch-core'; export type UseSearchBoxProps = SearchBoxConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useSortBy.ts b/packages/react-instantsearch-core/src/connectors/useSortBy.ts index ee9c2a09703..8dfc9e18965 100644 --- a/packages/react-instantsearch-core/src/connectors/useSortBy.ts +++ b/packages/react-instantsearch-core/src/connectors/useSortBy.ts @@ -1,4 +1,4 @@ -import connectSortBy from 'instantsearch.js/es/connectors/sort-by/connectSortBy'; +import { connectSortBy as connectSortBy } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { SortByConnectorParams, SortByWidgetDescription, -} from 'instantsearch.js/es/connectors/sort-by/connectSortBy'; +} from 'instantsearch-core'; export type UseSortByProps = SortByConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useStats.ts b/packages/react-instantsearch-core/src/connectors/useStats.ts index ac5bd659c95..c76a0b4c0d9 100644 --- a/packages/react-instantsearch-core/src/connectors/useStats.ts +++ b/packages/react-instantsearch-core/src/connectors/useStats.ts @@ -1,4 +1,4 @@ -import connectStats from 'instantsearch.js/es/connectors/stats/connectStats'; +import { connectStats as connectStats } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { StatsConnectorParams, StatsWidgetDescription, -} from 'instantsearch.js/es/connectors/stats/connectStats'; +} from 'instantsearch-core'; export type UseStatsProps = StatsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useToggleRefinement.ts b/packages/react-instantsearch-core/src/connectors/useToggleRefinement.ts index 33830749ad6..c21a7d9034a 100644 --- a/packages/react-instantsearch-core/src/connectors/useToggleRefinement.ts +++ b/packages/react-instantsearch-core/src/connectors/useToggleRefinement.ts @@ -1,4 +1,4 @@ -import connectToggleRefinement from 'instantsearch.js/es/connectors/toggle-refinement/connectToggleRefinement'; +import { connectToggleRefinement as connectToggleRefinement } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -6,7 +6,7 @@ import type { AdditionalWidgetProperties } from '../hooks/useConnector'; import type { ToggleRefinementConnectorParams, ToggleRefinementWidgetDescription, -} from 'instantsearch.js/es/connectors/toggle-refinement/connectToggleRefinement'; +} from 'instantsearch-core'; export type UseToggleRefinementProps = ToggleRefinementConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useTrendingFacets.ts b/packages/react-instantsearch-core/src/connectors/useTrendingFacets.ts index 8c3d6d50da5..a87a24d7772 100644 --- a/packages/react-instantsearch-core/src/connectors/useTrendingFacets.ts +++ b/packages/react-instantsearch-core/src/connectors/useTrendingFacets.ts @@ -1,4 +1,4 @@ -import connectTrendingFacets from 'instantsearch.js/es/connectors/trending-facets/connectTrendingFacets'; +import { connectTrendingFacets as connectTrendingFacets } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; @@ -7,7 +7,7 @@ import type { TrendingFacetsConnector, TrendingFacetsConnectorParams, TrendingFacetsWidgetDescription, -} from 'instantsearch.js/es/connectors/trending-facets/connectTrendingFacets'; +} from 'instantsearch-core'; export type UseTrendingFacetsProps = TrendingFacetsConnectorParams; diff --git a/packages/react-instantsearch-core/src/connectors/useTrendingItems.ts b/packages/react-instantsearch-core/src/connectors/useTrendingItems.ts index 37543adf501..8528a665ad6 100644 --- a/packages/react-instantsearch-core/src/connectors/useTrendingItems.ts +++ b/packages/react-instantsearch-core/src/connectors/useTrendingItems.ts @@ -1,14 +1,14 @@ -import connectTrendingItems from 'instantsearch.js/es/connectors/trending-items/connectTrendingItems'; +import { connectTrendingItems as connectTrendingItems } from 'instantsearch-core'; import { useConnector } from '../hooks/useConnector'; import type { AdditionalWidgetProperties } from '../hooks/useConnector'; -import type { BaseHit } from 'instantsearch.js'; +import type { BaseHit } from 'instantsearch-core'; import type { TrendingItemsConnector, TrendingItemsConnectorParams, TrendingItemsWidgetDescription, -} from 'instantsearch.js/es/connectors/trending-items/connectTrendingItems'; +} from 'instantsearch-core'; export type UseTrendingItemsProps = TrendingItemsConnectorParams; diff --git a/packages/react-instantsearch-core/src/hooks/__tests__/useConnector.test.tsx b/packages/react-instantsearch-core/src/hooks/__tests__/useConnector.test.tsx index 377a022cc08..623edb5abe6 100644 --- a/packages/react-instantsearch-core/src/hooks/__tests__/useConnector.test.tsx +++ b/packages/react-instantsearch-core/src/hooks/__tests__/useConnector.test.tsx @@ -12,7 +12,7 @@ import { } from '@instantsearch/testutils'; import { render, waitFor, renderHook } from '@testing-library/react'; import { SearchParameters, SearchResults } from 'algoliasearch-helper'; -import connectHits from 'instantsearch.js/es/connectors/hits/connectHits'; +import { connectHits as connectHits } from 'instantsearch-core'; import React, { StrictMode, useState } from 'react'; import { Index } from '../../components/Index'; @@ -24,11 +24,11 @@ import { noop } from '../../lib/noop'; import { useConnector } from '../useConnector'; import type { UseHitsProps } from '../../connectors/useHits'; -import type { Connector } from 'instantsearch.js'; +import type { Connector } from 'instantsearch-core'; import type { HitsConnectorParams, HitsWidgetDescription, -} from 'instantsearch.js/es/connectors/hits/connectHits'; +} from 'instantsearch-core'; type CustomSearchBoxWidgetDescription = { $$type: 'test.searchBox'; diff --git a/packages/react-instantsearch-core/src/hooks/useConnector.ts b/packages/react-instantsearch-core/src/hooks/useConnector.ts index f6cc76b00a8..ab53bfc2ac7 100644 --- a/packages/react-instantsearch-core/src/hooks/useConnector.ts +++ b/packages/react-instantsearch-core/src/hooks/useConnector.ts @@ -15,7 +15,7 @@ import type { UiState, Widget, WidgetDescription, -} from 'instantsearch.js'; +} from 'instantsearch-core'; export type AdditionalWidgetProperties = Partial> & { skipSuspense?: boolean; diff --git a/packages/react-instantsearch-core/src/hooks/useInstantSearch.ts b/packages/react-instantsearch-core/src/hooks/useInstantSearch.ts index 55cb997af13..042886ce0ad 100644 --- a/packages/react-instantsearch-core/src/hooks/useInstantSearch.ts +++ b/packages/react-instantsearch-core/src/hooks/useInstantSearch.ts @@ -7,7 +7,7 @@ import { useSearchState } from '../lib/useSearchState'; import type { SearchResultsApi } from '../lib/useSearchResults'; import type { SearchStateApi } from '../lib/useSearchState'; -import type { InstantSearch, Middleware, UiState } from 'instantsearch.js'; +import type { InstantSearch, Middleware, UiState } from 'instantsearch-core'; export type InstantSearchApi = SearchStateApi & diff --git a/packages/react-instantsearch-core/src/lib/IndexContext.ts b/packages/react-instantsearch-core/src/lib/IndexContext.ts index 5e1b3ce7ca3..a0831c4fac7 100644 --- a/packages/react-instantsearch-core/src/lib/IndexContext.ts +++ b/packages/react-instantsearch-core/src/lib/IndexContext.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { IndexWidget } from 'instantsearch-core'; export const IndexContext = createContext(null); diff --git a/packages/react-instantsearch-core/src/lib/InstantSearchContext.ts b/packages/react-instantsearch-core/src/lib/InstantSearchContext.ts index b5342b5472f..c4222c03543 100644 --- a/packages/react-instantsearch-core/src/lib/InstantSearchContext.ts +++ b/packages/react-instantsearch-core/src/lib/InstantSearchContext.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; -import type { InstantSearch } from 'instantsearch.js'; +import type { InstantSearch } from 'instantsearch-core'; export const InstantSearchContext = createContext(null); diff --git a/packages/react-instantsearch-core/src/lib/InstantSearchSSRContext.ts b/packages/react-instantsearch-core/src/lib/InstantSearchSSRContext.ts index a76c8b87673..12914d76510 100644 --- a/packages/react-instantsearch-core/src/lib/InstantSearchSSRContext.ts +++ b/packages/react-instantsearch-core/src/lib/InstantSearchSSRContext.ts @@ -2,7 +2,7 @@ import { createContext } from 'react'; import type { InstantSearchServerState } from '../components/InstantSearchSSRProvider'; import type { InternalInstantSearch } from './useInstantSearchApi'; -import type { UiState } from 'instantsearch.js'; +import type { UiState } from 'instantsearch-core'; import type { MutableRefObject } from 'react'; export type InstantSearchSSRContextApi< diff --git a/packages/react-instantsearch-core/src/lib/getIndexSearchResults.ts b/packages/react-instantsearch-core/src/lib/getIndexSearchResults.ts index f8845751a58..fe7cf82cef4 100644 --- a/packages/react-instantsearch-core/src/lib/getIndexSearchResults.ts +++ b/packages/react-instantsearch-core/src/lib/getIndexSearchResults.ts @@ -1,6 +1,6 @@ import { createSearchResults } from './createSearchResults'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { IndexWidget } from 'instantsearch-core'; export function getIndexSearchResults(indexWidget: IndexWidget) { const helper = indexWidget.getHelper()!; diff --git a/packages/react-instantsearch-core/src/lib/useAppIdAndApiKey.ts b/packages/react-instantsearch-core/src/lib/useAppIdAndApiKey.ts index e658632f59e..e8f31c210cb 100644 --- a/packages/react-instantsearch-core/src/lib/useAppIdAndApiKey.ts +++ b/packages/react-instantsearch-core/src/lib/useAppIdAndApiKey.ts @@ -1,4 +1,4 @@ -import { getAppIdAndApiKey } from 'instantsearch.js/es/lib/utils'; +import { getAppIdAndApiKey } from 'instantsearch-core'; import { useInstantSearchContext } from './useInstantSearchContext'; diff --git a/packages/react-instantsearch-core/src/lib/useIndex.ts b/packages/react-instantsearch-core/src/lib/useIndex.ts index edeb54e4da0..eb1772c5fd9 100644 --- a/packages/react-instantsearch-core/src/lib/useIndex.ts +++ b/packages/react-instantsearch-core/src/lib/useIndex.ts @@ -1,4 +1,4 @@ -import index from 'instantsearch.js/es/widgets/index/index'; +import { index as index } from 'instantsearch-core'; import { useMemo } from 'react'; import { useForceUpdate } from './useForceUpdate'; @@ -9,7 +9,7 @@ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; import { useStableValue } from './useStableValue'; import { useWidget } from './useWidget'; -import type { IndexWidgetParams } from 'instantsearch.js/es/widgets/index/index'; +import type { IndexWidgetParams } from 'instantsearch-core'; export type UseIndexProps = IndexWidgetParams; diff --git a/packages/react-instantsearch-core/src/lib/useIndexContext.ts b/packages/react-instantsearch-core/src/lib/useIndexContext.ts index 2a8edce4ac2..869c26193a1 100644 --- a/packages/react-instantsearch-core/src/lib/useIndexContext.ts +++ b/packages/react-instantsearch-core/src/lib/useIndexContext.ts @@ -3,7 +3,7 @@ import { useContext } from 'react'; import { IndexContext } from './IndexContext'; import { invariant } from './invariant'; -import type { IndexWidget, UiState } from 'instantsearch.js'; +import type { IndexWidget, UiState } from 'instantsearch-core'; import type { Context } from 'react'; export function useIndexContext() { diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts index 8269c64c47e..36eb976278b 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts @@ -1,6 +1,5 @@ -import InstantSearch, { - INSTANTSEARCH_FUTURE_DEFAULTS, -} from 'instantsearch.js/es/lib/InstantSearch'; +import InstantSearch from 'instantsearch.js/es/lib/InstantSearch'; +import { INSTANTSEARCH_FUTURE_DEFAULTS } from 'instantsearch-core'; import { useCallback, useRef, version as ReactVersion } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; @@ -15,10 +14,11 @@ import { warn } from './warn'; import type { CompositionClient, + InstantSearch as InstantSearchCore, InstantSearchOptions, SearchClient, UiState, -} from 'instantsearch.js'; +} from 'instantsearch-core'; const defaultUserAgents = [ `react (${ReactVersion})`, @@ -37,7 +37,7 @@ export type UseInstantSearchApiProps< export type InternalInstantSearch< TUiState extends UiState, TRouteState = TUiState -> = InstantSearch & { +> = InstantSearchCore & { /** * Schedule a function to be called on the next timer tick * @private @@ -82,7 +82,7 @@ export function useInstantSearchApi( // We don't use the `instantsearch()` function because it comes with other // top-level APIs that we don't need. // See https://github.com/algolia/instantsearch/blob/5b529f43d8acc680f85837eaaa41f7fd03a3f833/src/index.es.ts#L63-L86 - const search = new InstantSearch(props) as InternalInstantSearch< + const search = new InstantSearch(props) as unknown as InternalInstantSearch< TUiState, TRouteState >; @@ -200,7 +200,7 @@ export function useInstantSearchApi( } const cleanupTimerRef = useRef | null>(null); - const store = useSyncExternalStore>( + const store = useSyncExternalStore>( useCallback(() => { const search = searchRef.current!; diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchContext.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchContext.ts index ef2a7126f1c..becfc9a5cd6 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchContext.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchContext.ts @@ -4,7 +4,7 @@ import { InstantSearchContext } from './InstantSearchContext'; import { invariant } from './invariant'; import type { InternalInstantSearch } from './useInstantSearchApi'; -import type { UiState } from 'instantsearch.js'; +import type { UiState } from 'instantsearch-core'; import type { Context } from 'react'; export function useInstantSearchContext< diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchSSRContext.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchSSRContext.ts index 70780d79e54..3cc773f8bb8 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchSSRContext.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchSSRContext.ts @@ -3,7 +3,7 @@ import { useContext } from 'react'; import { InstantSearchSSRContext } from './InstantSearchSSRContext'; import type { InstantSearchSSRContextApi } from './InstantSearchSSRContext'; -import type { UiState } from 'instantsearch.js'; +import type { UiState } from 'instantsearch-core'; import type { Context } from 'react'; export function useInstantSearchSSRContext< diff --git a/packages/react-instantsearch-core/src/lib/useInstantSearchServerContext.ts b/packages/react-instantsearch-core/src/lib/useInstantSearchServerContext.ts index 3665707e360..4811a7b330f 100644 --- a/packages/react-instantsearch-core/src/lib/useInstantSearchServerContext.ts +++ b/packages/react-instantsearch-core/src/lib/useInstantSearchServerContext.ts @@ -3,7 +3,7 @@ import { useContext } from 'react'; import { InstantSearchServerContext } from '../components/InstantSearchServerContext'; import type { InstantSearchServerContextApi } from '../components/InstantSearchServerContext'; -import type { UiState } from 'instantsearch.js'; +import type { UiState } from 'instantsearch-core'; import type { Context } from 'react'; export function useInstantSearchServerContext< diff --git a/packages/react-instantsearch-core/src/lib/useSearchResults.ts b/packages/react-instantsearch-core/src/lib/useSearchResults.ts index bb3d73a43da..34692512264 100644 --- a/packages/react-instantsearch-core/src/lib/useSearchResults.ts +++ b/packages/react-instantsearch-core/src/lib/useSearchResults.ts @@ -1,4 +1,4 @@ -import { isIndexWidget } from 'instantsearch.js/es/lib/utils'; +import { isIndexWidget } from 'instantsearch-core'; import { useEffect, useState } from 'react'; import { getIndexSearchResults } from './getIndexSearchResults'; @@ -6,7 +6,7 @@ import { useIndexContext } from './useIndexContext'; import { useInstantSearchContext } from './useInstantSearchContext'; import type { SearchResults } from 'algoliasearch-helper'; -import type { ScopedResult } from 'instantsearch.js'; +import type { ScopedResult } from 'instantsearch-core'; export type SearchResultsApi = { results: SearchResults; diff --git a/packages/react-instantsearch-core/src/lib/useSearchState.ts b/packages/react-instantsearch-core/src/lib/useSearchState.ts index 1bc3c61d1df..817c7530e82 100644 --- a/packages/react-instantsearch-core/src/lib/useSearchState.ts +++ b/packages/react-instantsearch-core/src/lib/useSearchState.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useIndexContext } from './useIndexContext'; import { useInstantSearchContext } from './useInstantSearchContext'; -import type { InstantSearch, UiState, IndexWidget } from 'instantsearch.js'; +import type { InstantSearch, UiState, IndexWidget } from 'instantsearch-core'; export type SearchStateApi = { uiState: TUiState; diff --git a/packages/react-instantsearch-core/src/lib/useWidget.ts b/packages/react-instantsearch-core/src/lib/useWidget.ts index b79e6325028..d95ea729bed 100644 --- a/packages/react-instantsearch-core/src/lib/useWidget.ts +++ b/packages/react-instantsearch-core/src/lib/useWidget.ts @@ -1,4 +1,4 @@ -import { isTwoPassWidget } from 'instantsearch.js/es/lib/utils'; +import { isTwoPassWidget } from 'instantsearch-core'; import { useEffect, useRef } from 'react'; import { dequal } from './dequal'; @@ -8,8 +8,8 @@ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; import { useRSCContext } from './useRSCContext'; import { warn } from './warn'; -import type { Widget } from 'instantsearch.js'; -import type { IndexWidget } from 'instantsearch.js/es/widgets/index/index'; +import type { Widget } from 'instantsearch-core'; +import type { IndexWidget } from 'instantsearch-core'; export function useWidget({ widget, diff --git a/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx b/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx index ac0e76d6d0e..b165d0d8ad1 100644 --- a/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx +++ b/packages/react-instantsearch-core/src/server/__tests__/getServerState.test.tsx @@ -22,7 +22,7 @@ import { import { getServerState } from '../getServerState'; import type { MockSearchClient } from '@instantsearch/mocks'; -import type { Hit as AlgoliaHit } from 'instantsearch.js'; +import type { Hit as AlgoliaHit } from 'instantsearch-core'; import type { InstantSearchServerState, InstantSearchProps, diff --git a/packages/react-instantsearch-core/src/server/getServerState.tsx b/packages/react-instantsearch-core/src/server/getServerState.tsx index 6d43cedac5e..850cfd96686 100644 --- a/packages/react-instantsearch-core/src/server/getServerState.tsx +++ b/packages/react-instantsearch-core/src/server/getServerState.tsx @@ -1,12 +1,12 @@ import { getInitialResults, waitForResults, -} from 'instantsearch.js/es/lib/server'; +} from 'instantsearch-core'; import { isTwoPassWidget, walkIndex, resetWidgetId, -} from 'instantsearch.js/es/lib/utils'; +} from 'instantsearch-core'; import React from 'react'; import { InstantSearchServerContext } from '../components/InstantSearchServerContext'; @@ -14,7 +14,7 @@ import { InstantSearchSSRProvider } from '../components/InstantSearchSSRProvider import type { InstantSearchServerContextApi } from '../components/InstantSearchServerContext'; import type { InstantSearchServerState } from '../components/InstantSearchSSRProvider'; -import type { InstantSearch, UiState } from 'instantsearch.js'; +import type { InstantSearch, UiState } from 'instantsearch-core'; import type { ReactNode } from 'react'; type SearchRef = { current: InstantSearch | undefined }; diff --git a/packages/react-instantsearch-nextjs/package.json b/packages/react-instantsearch-nextjs/package.json index e6fb65beb7f..cbc9cbd069c 100644 --- a/packages/react-instantsearch-nextjs/package.json +++ b/packages/react-instantsearch-nextjs/package.json @@ -48,7 +48,8 @@ "watch:es": "rollup -c rollup.config.mjs --watch" }, "dependencies": { - "@swc/helpers": "0.5.18" + "@swc/helpers": "0.5.18", + "instantsearch-core": "0.1.0" }, "devDependencies": { "@playwright/test": "1.49.1", diff --git a/packages/react-instantsearch-nextjs/src/InitializePromise.ts b/packages/react-instantsearch-nextjs/src/InitializePromise.ts index 0cc63a0dc3f..c1f24099d16 100644 --- a/packages/react-instantsearch-nextjs/src/InitializePromise.ts +++ b/packages/react-instantsearch-nextjs/src/InitializePromise.ts @@ -1,9 +1,9 @@ -import { getInitialResults } from 'instantsearch.js/es/lib/server'; +import { getInitialResults } from 'instantsearch-core'; import { isTwoPassWidget, walkIndex, resetWidgetId, -} from 'instantsearch.js/es/lib/utils'; +} from 'instantsearch-core'; import { ServerInsertedHTMLContext } from 'next/navigation'; import { useContext } from 'react'; import { @@ -18,7 +18,7 @@ import type { SearchOptions, CompositionClient, SearchClient, -} from 'instantsearch.js'; +} from 'instantsearch-core'; type InitializePromiseProps = { /** diff --git a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx index c32939ac5ec..19e282ca558 100644 --- a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx +++ b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx @@ -1,4 +1,4 @@ -import { safelyRunOnBrowser } from 'instantsearch.js/es/lib/utils'; +import { safelyRunOnBrowser } from 'instantsearch-core'; import React, { useEffect, useRef } from 'react'; import { InstantSearch, @@ -12,8 +12,8 @@ import { useDynamicRouteWarning } from './useDynamicRouteWarning'; import { useInstantSearchRouting } from './useInstantSearchRouting'; import { useNextHeaders } from './useNextHeaders'; -import type { InitialResults, StateMapping, UiState } from 'instantsearch.js'; -import type { BrowserHistoryArgs } from 'instantsearch.js/es/lib/routers/history'; +import type { InitialResults, StateMapping, UiState } from 'instantsearch-core'; +import type { BrowserHistoryArgs } from 'instantsearch-core'; import type { InstantSearchProps, InstantSearchSSRContextApi, diff --git a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx index b70ae0e3934..35c975da909 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx @@ -18,8 +18,8 @@ import { TriggerSearch } from '../TriggerSearch'; import type { PromiseWithState } from 'react-instantsearch-core'; -jest.mock('instantsearch.js/es/lib/utils', () => ({ - ...jest.requireActual('instantsearch.js/es/lib/utils'), +jest.mock('instantsearch-core', () => ({ + ...jest.requireActual('instantsearch-core'), resetWidgetId: jest.fn(), })); diff --git a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx index 79b6ae6f151..d011fae57b3 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx @@ -7,7 +7,7 @@ import { createSingleSearchResponse, } from '@instantsearch/mocks'; import { render, act } from '@testing-library/react'; -import * as utils from 'instantsearch.js/es/lib/utils'; +import * as utils from 'instantsearch-core'; import { ServerInsertedHTMLContext } from 'next/navigation'; import React from 'react'; import { SearchBox, TrendingItems } from 'react-instantsearch'; @@ -22,8 +22,8 @@ import { TriggerSearch } from '../TriggerSearch'; import type { PromiseWithState } from 'react-instantsearch-core'; -jest.mock('instantsearch.js/es/lib/utils', () => ({ - ...jest.requireActual('instantsearch.js/es/lib/utils'), +jest.mock('instantsearch-core', () => ({ + ...jest.requireActual('instantsearch-core'), resetWidgetId: jest.fn(), })); diff --git a/packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx index 2f2bd8dee14..17860540198 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx @@ -10,7 +10,7 @@ import { SearchBox } from 'react-instantsearch'; import { InstantSearchNext } from '../InstantSearchNext'; -import type { InitialResults } from 'instantsearch.js'; +import type { InitialResults } from 'instantsearch-core'; const InstantSearchInitialResults = Symbol.for('InstantSearchInitialResults'); declare global { diff --git a/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx b/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx index 4678eb1d67c..9b414cd0127 100644 --- a/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx +++ b/packages/react-instantsearch-nextjs/src/createInsertHTML.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { htmlEscapeJsonString } from './htmlEscape'; -import type { InitialResults } from 'instantsearch.js'; +import type { InitialResults } from 'instantsearch-core'; export const createInsertHTML = ({ diff --git a/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts b/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts index 9bde4fb2274..1a0e9b55d51 100644 --- a/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts +++ b/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts @@ -1,12 +1,12 @@ -import historyRouter from 'instantsearch.js/es/lib/routers/history'; +import { history as historyRouter } from 'instantsearch-core'; import { usePathname, useSearchParams } from 'next/navigation'; import { useRef, useEffect } from 'react'; import { useNextHeaders } from './useNextHeaders'; import type { InstantSearchNextProps } from './InstantSearchNext'; -import type { UiState } from 'instantsearch.js'; -import type { BrowserHistoryArgs } from 'instantsearch.js/es/lib/routers/history'; +import type { UiState } from 'instantsearch-core'; +import type { BrowserHistoryArgs } from 'instantsearch-core'; import type { InstantSearchProps } from 'react-instantsearch-core'; export function useInstantSearchRouting< diff --git a/packages/react-instantsearch-router-nextjs/package.json b/packages/react-instantsearch-router-nextjs/package.json index a1fc867e424..d3fd6cec528 100644 --- a/packages/react-instantsearch-router-nextjs/package.json +++ b/packages/react-instantsearch-router-nextjs/package.json @@ -48,7 +48,8 @@ "dependencies": { "@swc/helpers": "0.5.18", "instantsearch.js": "4.96.2", - "react-instantsearch-core": "7.32.2" + "react-instantsearch-core": "7.32.2", + "instantsearch-core": "0.1.0" }, "devDependencies": { "@playwright/test": "1.49.1" diff --git a/packages/react-instantsearch-router-nextjs/src/index.ts b/packages/react-instantsearch-router-nextjs/src/index.ts index 6583138c347..811889f4095 100644 --- a/packages/react-instantsearch-router-nextjs/src/index.ts +++ b/packages/react-instantsearch-router-nextjs/src/index.ts @@ -1,9 +1,9 @@ -import history from 'instantsearch.js/es/lib/routers/history'; +import { history as history } from 'instantsearch-core'; import { stripLocaleFromUrl } from './utils/stripLocaleFromUrl'; -import type { Router, UiState } from 'instantsearch.js'; -import type { BrowserHistoryArgs } from 'instantsearch.js/es/lib/routers/history'; +import type { Router, UiState } from 'instantsearch-core'; +import type { BrowserHistoryArgs } from 'instantsearch-core'; import type { Router as NextRouter, SingletonRouter } from 'next/router'; type BeforePopStateCallback = NonNullable; diff --git a/packages/react-instantsearch/package.json b/packages/react-instantsearch/package.json index d404531d01d..ee3ad9bafcd 100644 --- a/packages/react-instantsearch/package.json +++ b/packages/react-instantsearch/package.json @@ -47,7 +47,8 @@ "@swc/helpers": "0.5.18", "instantsearch-ui-components": "0.26.1", "instantsearch.js": "4.96.2", - "react-instantsearch-core": "7.32.2" + "react-instantsearch-core": "7.32.2", + "instantsearch-core": "0.1.0" }, "peerDependencies": { "algoliasearch": ">= 3.1 < 6", diff --git a/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx b/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx index 8765e9a7e3d..4831d889967 100644 --- a/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-connectors.test.tsx @@ -4,7 +4,7 @@ import { runTestSuites } from '@instantsearch/tests'; import * as suites from '@instantsearch/tests/connectors'; import { act, render } from '@testing-library/react'; -import { connectRatingMenu } from 'instantsearch.js/es/connectors'; +import { connectRatingMenu } from 'instantsearch-core'; import React, { useState } from 'react'; import { @@ -49,7 +49,7 @@ import type { TestOptionsMap, TestSetupsMap } from '@instantsearch/tests'; import type { RatingMenuConnectorParams, RatingMenuWidgetDescription, -} from 'instantsearch.js/es/connectors/rating-menu/connectRatingMenu'; +} from 'instantsearch-core'; type TestSuites = typeof suites; const testSuites: TestSuites = suites; diff --git a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx index 836217e428c..d1a07305027 100644 --- a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx @@ -38,8 +38,8 @@ import { } from '..'; import type { TestOptionsMap, TestSetupsMap } from '@instantsearch/tests'; -import type { Hit } from 'instantsearch.js'; -import type { SendEventForHits } from 'instantsearch.js/es/lib/utils'; +import type { Hit } from 'instantsearch-core'; +import type { SendEventForHits } from 'instantsearch-core'; type TestSuites = typeof suites; const testSuites: TestSuites = suites; diff --git a/packages/react-instantsearch/src/ui/CurrentRefinements.tsx b/packages/react-instantsearch/src/ui/CurrentRefinements.tsx index 1493d12b63d..8aa49e16139 100644 --- a/packages/react-instantsearch/src/ui/CurrentRefinements.tsx +++ b/packages/react-instantsearch/src/ui/CurrentRefinements.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { capitalize, isModifierClick } from './lib'; -import type { CurrentRefinementsConnectorParamsItem } from 'instantsearch.js/es/connectors/current-refinements/connectCurrentRefinements'; +import type { CurrentRefinementsConnectorParamsItem } from 'instantsearch-core'; export type CurrentRefinementsProps = React.ComponentProps<'div'> & { classNames?: Partial; diff --git a/packages/react-instantsearch/src/ui/HitsPerPage.tsx b/packages/react-instantsearch/src/ui/HitsPerPage.tsx index 2aa8750617c..27b67a05a8b 100644 --- a/packages/react-instantsearch/src/ui/HitsPerPage.tsx +++ b/packages/react-instantsearch/src/ui/HitsPerPage.tsx @@ -1,7 +1,7 @@ import { cx } from 'instantsearch-ui-components'; import React from 'react'; -import type { HitsPerPageConnectorParamsItem as HitsPerPageItem } from 'instantsearch.js/es/connectors/hits-per-page/connectHitsPerPage'; +import type { HitsPerPageConnectorParamsItem as HitsPerPageItem } from 'instantsearch-core'; export type HitsPerPageProps = Omit, 'onChange'> & { items: HitsPerPageItem[]; diff --git a/packages/react-instantsearch/src/ui/InfiniteHits.tsx b/packages/react-instantsearch/src/ui/InfiniteHits.tsx index b2610f8bf0f..a5240f5d112 100644 --- a/packages/react-instantsearch/src/ui/InfiniteHits.tsx +++ b/packages/react-instantsearch/src/ui/InfiniteHits.tsx @@ -2,8 +2,8 @@ import { cx } from 'instantsearch-ui-components'; import React from 'react'; import type { Banner } from 'algoliasearch-helper'; -import type { Hit } from 'instantsearch.js'; -import type { SendEventForHits } from 'instantsearch.js/es/lib/utils'; +import type { Hit } from 'instantsearch-core'; +import type { SendEventForHits } from 'instantsearch-core'; export type InfiniteHitsProps = React.ComponentProps<'div'> & { hits: THit[]; diff --git a/packages/react-instantsearch/src/ui/Menu.tsx b/packages/react-instantsearch/src/ui/Menu.tsx index 934d4f5276f..48e6910c2b7 100644 --- a/packages/react-instantsearch/src/ui/Menu.tsx +++ b/packages/react-instantsearch/src/ui/Menu.tsx @@ -5,8 +5,8 @@ import { isModifierClick } from './lib/isModifierClick'; import { ShowMoreButton } from './ShowMoreButton'; import type { ShowMoreButtonTranslations } from './ShowMoreButton'; -import type { CreateURL } from 'instantsearch.js'; -import type { MenuItem } from 'instantsearch.js/es/connectors/menu/connectMenu'; +import type { CreateURL } from 'instantsearch-core'; +import type { MenuItem } from 'instantsearch-core'; export type MenuProps = React.ComponentProps<'div'> & { items: MenuItem[]; diff --git a/packages/react-instantsearch/src/ui/Pagination.tsx b/packages/react-instantsearch/src/ui/Pagination.tsx index 17fd94395de..eac0280f13d 100644 --- a/packages/react-instantsearch/src/ui/Pagination.tsx +++ b/packages/react-instantsearch/src/ui/Pagination.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { isModifierClick } from './lib/isModifierClick'; -import type { CreateURL } from 'instantsearch.js'; +import type { CreateURL } from 'instantsearch-core'; export type PageItemTextOptions = { /** diff --git a/packages/react-instantsearch/src/ui/RefinementList.tsx b/packages/react-instantsearch/src/ui/RefinementList.tsx index 95e7242fce4..690b17fa1c2 100644 --- a/packages/react-instantsearch/src/ui/RefinementList.tsx +++ b/packages/react-instantsearch/src/ui/RefinementList.tsx @@ -1,12 +1,12 @@ import { cx } from 'instantsearch-ui-components'; -import { getHighlightedParts, unescape } from 'instantsearch.js/es/lib/utils'; +import { getHighlightedParts, unescape } from 'instantsearch-core'; import React from 'react'; import { Highlight } from './Highlight'; import { ShowMoreButton } from './ShowMoreButton'; import type { ShowMoreButtonTranslations } from './ShowMoreButton'; -import type { RefinementListItem } from 'instantsearch.js/es/connectors/refinement-list/connectRefinementList'; +import type { RefinementListItem } from 'instantsearch-core'; export type RefinementListProps = React.ComponentProps<'div'> & { canRefine: boolean; diff --git a/packages/react-instantsearch/src/ui/__tests__/InfiniteHits.test.tsx b/packages/react-instantsearch/src/ui/__tests__/InfiniteHits.test.tsx index e8b433abf40..d0908c3654e 100644 --- a/packages/react-instantsearch/src/ui/__tests__/InfiniteHits.test.tsx +++ b/packages/react-instantsearch/src/ui/__tests__/InfiniteHits.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { InfiniteHits } from '../InfiniteHits'; import type { InfiniteHitsProps } from '../InfiniteHits'; -import type { Hit } from 'instantsearch.js'; +import type { Hit } from 'instantsearch-core'; describe('InfiniteHits', () => { function createProps( diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index b6ffb4a5343..245ee2d6e8e 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -15,7 +15,7 @@ import { getPromptSuggestionHits, isPromptSuggestion, } from 'instantsearch-ui-components'; -import { warn } from 'instantsearch.js/es/lib/utils'; +import { warn } from 'instantsearch-core'; import React, { createElement, Fragment, @@ -45,9 +45,9 @@ import type { AutocompleteClassNames, AutocompleteIndexProps, } from 'instantsearch-ui-components'; -import type { BaseHit, Hit, IndexUiState } from 'instantsearch.js'; -import type { TransformItemsIndicesConfig } from 'instantsearch.js/es/connectors/autocomplete/connectAutocomplete'; -import type { ChatRenderState } from 'instantsearch.js/es/connectors/chat/connectChat'; +import type { BaseHit, Hit, IndexUiState } from 'instantsearch-core'; +import type { TransformItemsIndicesConfig } from 'instantsearch-core'; +import type { ChatRenderState } from 'instantsearch-core'; import type { ComponentProps } from 'react'; const Autocomplete = createAutocompleteComponent({ diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx index 70664089481..a7a8771139a 100644 --- a/packages/react-instantsearch/src/widgets/Chat.tsx +++ b/packages/react-instantsearch/src/widgets/Chat.tsx @@ -6,7 +6,7 @@ import { MemorySearchToolType, PonderToolType, DisplayResultsToolType, -} from 'instantsearch.js/es/lib/chat'; +} from 'instantsearch-core'; import React, { createElement, Fragment, @@ -42,8 +42,8 @@ import type { UserClientSideTools, ChatMessageProps, } from 'instantsearch-ui-components'; -import type { IndexUiState } from 'instantsearch.js'; -import type { UIMessage } from 'instantsearch.js/es/lib/chat'; +import type { IndexUiState } from 'instantsearch-core'; +import type { UIMessage } from 'instantsearch-core'; import type { UseChatProps } from 'react-instantsearch-core'; const ChatUiComponent = createChatComponent({ diff --git a/packages/react-instantsearch/src/widgets/FrequentlyBoughtTogether.tsx b/packages/react-instantsearch/src/widgets/FrequentlyBoughtTogether.tsx index b089f83efc7..bdb8aba3a48 100644 --- a/packages/react-instantsearch/src/widgets/FrequentlyBoughtTogether.tsx +++ b/packages/react-instantsearch/src/widgets/FrequentlyBoughtTogether.tsx @@ -9,7 +9,7 @@ import type { FrequentlyBoughtTogetherProps as FrequentlyBoughtTogetherPropsUiComponentProps, Pragma, } from 'instantsearch-ui-components'; -import type { Hit, BaseHit } from 'instantsearch.js'; +import type { Hit, BaseHit } from 'instantsearch-core'; import type { UseFrequentlyBoughtTogetherProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/Highlight.tsx b/packages/react-instantsearch/src/widgets/Highlight.tsx index 48cefa9214b..807ff8c1e2b 100644 --- a/packages/react-instantsearch/src/widgets/Highlight.tsx +++ b/packages/react-instantsearch/src/widgets/Highlight.tsx @@ -2,14 +2,14 @@ import { getHighlightedParts, getPropertyByPath, unescape, -} from 'instantsearch.js/es/lib/utils'; +} from 'instantsearch-core'; import React from 'react'; import { Highlight as HighlightUiComponent } from '../ui/Highlight'; import type { PartialKeys } from '../types'; import type { HighlightProps as HighlightUiComponentProps } from '../ui/Highlight'; -import type { BaseHit, Hit } from 'instantsearch.js'; +import type { BaseHit, Hit } from 'instantsearch-core'; export type HighlightProps> = { hit: THit; diff --git a/packages/react-instantsearch/src/widgets/Hits.tsx b/packages/react-instantsearch/src/widgets/Hits.tsx index a33b973b0a5..575e168b8b5 100644 --- a/packages/react-instantsearch/src/widgets/Hits.tsx +++ b/packages/react-instantsearch/src/widgets/Hits.tsx @@ -6,8 +6,8 @@ import type { HitsProps as HitsUiComponentProps, Pragma, } from 'instantsearch-ui-components'; -import type { Hit, BaseHit } from 'instantsearch.js'; -import type { SendEventForHits } from 'instantsearch.js/es/lib/utils'; +import type { Hit, BaseHit } from 'instantsearch-core'; +import type { SendEventForHits } from 'instantsearch-core'; import type { UseHitsProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/InfiniteHits.tsx b/packages/react-instantsearch/src/widgets/InfiniteHits.tsx index e03b3c06520..ecdd0a62388 100644 --- a/packages/react-instantsearch/src/widgets/InfiniteHits.tsx +++ b/packages/react-instantsearch/src/widgets/InfiniteHits.tsx @@ -4,7 +4,7 @@ import { useInfiniteHits } from 'react-instantsearch-core'; import { InfiniteHits as InfiniteHitsUiComponent } from '../ui/InfiniteHits'; import type { InfiniteHitsProps as InfiniteHitsUiComponentProps } from '../ui/InfiniteHits'; -import type { BaseHit, Hit } from 'instantsearch.js'; +import type { BaseHit, Hit } from 'instantsearch-core'; import type { UseInfiniteHitsProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/LookingSimilar.tsx b/packages/react-instantsearch/src/widgets/LookingSimilar.tsx index 16174432fcd..2112af492ed 100644 --- a/packages/react-instantsearch/src/widgets/LookingSimilar.tsx +++ b/packages/react-instantsearch/src/widgets/LookingSimilar.tsx @@ -6,7 +6,7 @@ import type { LookingSimilarProps as LookingSimilarPropsUiComponentProps, Pragma, } from 'instantsearch-ui-components'; -import type { Hit, BaseHit } from 'instantsearch.js'; +import type { Hit, BaseHit } from 'instantsearch-core'; import type { UseLookingSimilarProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/RefinementList.tsx b/packages/react-instantsearch/src/widgets/RefinementList.tsx index 718189d0b92..ce716e1d207 100644 --- a/packages/react-instantsearch/src/widgets/RefinementList.tsx +++ b/packages/react-instantsearch/src/widgets/RefinementList.tsx @@ -6,8 +6,8 @@ import { SearchBox as SearchBoxUiComponent } from '../ui/SearchBox'; import type { RefinementListProps as RefinementListUiComponentProps } from '../ui/RefinementList'; import type { SearchBoxProps } from '../ui/SearchBox'; -import type { RefinementListItem } from 'instantsearch.js/es/connectors/refinement-list/connectRefinementList'; -import type { RefinementListWidgetParams } from 'instantsearch.js/es/widgets/refinement-list/refinement-list'; +import type { RefinementListItem } from 'instantsearch-core'; +import type { RefinementListWidgetParams } from 'instantsearch.js/es/widgets'; import type { UseRefinementListProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/RelatedProducts.tsx b/packages/react-instantsearch/src/widgets/RelatedProducts.tsx index 009714ad128..6388eeb8781 100644 --- a/packages/react-instantsearch/src/widgets/RelatedProducts.tsx +++ b/packages/react-instantsearch/src/widgets/RelatedProducts.tsx @@ -6,7 +6,7 @@ import type { RelatedProductsProps as RelatedProductsUiComponentProps, Pragma, } from 'instantsearch-ui-components'; -import type { BaseHit, Hit } from 'instantsearch.js'; +import type { BaseHit, Hit } from 'instantsearch-core'; import type { UseRelatedProductsProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/ReverseHighlight.tsx b/packages/react-instantsearch/src/widgets/ReverseHighlight.tsx index fb88ceacf42..54e99380498 100644 --- a/packages/react-instantsearch/src/widgets/ReverseHighlight.tsx +++ b/packages/react-instantsearch/src/widgets/ReverseHighlight.tsx @@ -2,14 +2,14 @@ import { getHighlightedParts, getPropertyByPath, unescape, -} from 'instantsearch.js/es/lib/utils'; +} from 'instantsearch-core'; import React from 'react'; import { ReverseHighlight as ReverseHighlightUiComponent } from '../ui/ReverseHighlight'; import type { PartialKeys } from '../types'; import type { ReverseHighlightProps as ReverseHighlightUiComponentProps } from '../ui/ReverseHighlight'; -import type { BaseHit, Hit } from 'instantsearch.js'; +import type { BaseHit, Hit } from 'instantsearch-core'; export type ReverseHighlightProps> = { hit: THit; diff --git a/packages/react-instantsearch/src/widgets/SearchBox.tsx b/packages/react-instantsearch/src/widgets/SearchBox.tsx index cdeb9afc320..6720012d66e 100644 --- a/packages/react-instantsearch/src/widgets/SearchBox.tsx +++ b/packages/react-instantsearch/src/widgets/SearchBox.tsx @@ -4,7 +4,7 @@ import { useInstantSearch, useSearchBox } from 'react-instantsearch-core'; import { SearchBox as SearchBoxUiComponent } from '../ui/SearchBox'; import type { SearchBoxProps as SearchBoxUiComponentProps } from '../ui/SearchBox'; -import type { ChatRenderState } from 'instantsearch.js/es/connectors/chat/connectChat'; +import type { ChatRenderState } from 'instantsearch-core'; import type { UseSearchBoxProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/Snippet.tsx b/packages/react-instantsearch/src/widgets/Snippet.tsx index d583448e22a..f227801d88c 100644 --- a/packages/react-instantsearch/src/widgets/Snippet.tsx +++ b/packages/react-instantsearch/src/widgets/Snippet.tsx @@ -2,14 +2,14 @@ import { getHighlightedParts, getPropertyByPath, unescape, -} from 'instantsearch.js/es/lib/utils'; +} from 'instantsearch-core'; import React from 'react'; import { Snippet as SnippetUiComponent } from '../ui/Snippet'; import type { PartialKeys } from '../types'; import type { SnippetProps as SnippetUiComponentProps } from '../ui/Snippet'; -import type { BaseHit, Hit } from 'instantsearch.js'; +import type { BaseHit, Hit } from 'instantsearch-core'; export type SnippetProps> = { hit: THit; diff --git a/packages/react-instantsearch/src/widgets/TrendingItems.tsx b/packages/react-instantsearch/src/widgets/TrendingItems.tsx index 8fc23a4411e..16bef20c878 100644 --- a/packages/react-instantsearch/src/widgets/TrendingItems.tsx +++ b/packages/react-instantsearch/src/widgets/TrendingItems.tsx @@ -6,7 +6,7 @@ import type { TrendingItemsProps as TrendingItemsUiComponentProps, Pragma, } from 'instantsearch-ui-components'; -import type { BaseHit, Hit } from 'instantsearch.js'; +import type { BaseHit, Hit } from 'instantsearch-core'; import type { UseTrendingItemsProps } from 'react-instantsearch-core'; type UiProps = Pick< diff --git a/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx index 9376ea8928e..bbac90d3346 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { Hits } from '../Hits'; import type { MockSearchClient } from '@instantsearch/mocks'; -import type { AlgoliaHit } from 'instantsearch.js'; +import type { AlgoliaHit } from 'instantsearch-core'; type CustomRecord = { somethingSpecial: string; diff --git a/packages/react-instantsearch/src/widgets/__tests__/InfiniteHits.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/InfiniteHits.test.tsx index f0c9e88459d..4f0bde1188d 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/InfiniteHits.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/InfiniteHits.test.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { InfiniteHits } from '../InfiniteHits'; import type { MockSearchClient } from '@instantsearch/mocks'; -import type { AlgoliaHit } from 'instantsearch.js'; +import type { AlgoliaHit } from 'instantsearch-core'; type CustomHit = { somethingSpecial: string }; diff --git a/packages/react-instantsearch/src/widgets/__tests__/Pagination.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/Pagination.test.tsx index ee0d1471b4a..72375fb2d8c 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/Pagination.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/Pagination.test.tsx @@ -15,7 +15,7 @@ import React from 'react'; import { Pagination } from '../Pagination'; import type { MockSearchClient } from '@instantsearch/mocks'; -import type { SearchClient } from 'instantsearch.js'; +import type { SearchClient } from 'instantsearch-core'; function createMockedSearchClient({ nbPages }: { nbPages?: number } = {}) { return createAlgoliaSearchClient({ diff --git a/packages/react-instantsearch/src/widgets/__tests__/SearchBox.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/SearchBox.test.tsx index 2d5ba2e3201..64f7c03dc6c 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/SearchBox.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/SearchBox.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { SearchBox } from '../SearchBox'; -import type { UiState } from 'instantsearch.js'; +import type { UiState } from 'instantsearch-core'; describe('SearchBox', () => { test('forwards custom class names and `div` props to the root element', () => { diff --git a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx index bfca035da2c..2704540a85e 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx @@ -8,7 +8,7 @@ import { import * as widgets from '../..'; -import type { InstantSearch as InstantSearchClass } from 'instantsearch.js'; +import type { InstantSearch as InstantSearchClass } from 'instantsearch-core'; import type { ComponentProps } from 'react'; // We only track widgets that use connectors. diff --git a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx index 67adad96fb7..9b630b820b8 100644 --- a/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx +++ b/packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx @@ -7,7 +7,7 @@ import { import { addAbsolutePosition, addQueryID, -} from 'instantsearch.js/es/lib/utils'; +} from 'instantsearch-core'; import React, { createElement } from 'react'; import { Carousel } from '../../../components'; diff --git a/packages/vue-instantsearch/package.json b/packages/vue-instantsearch/package.json index 6ee8db28158..5d8156725c8 100644 --- a/packages/vue-instantsearch/package.json +++ b/packages/vue-instantsearch/package.json @@ -39,7 +39,8 @@ "@swc/helpers": "0.5.18", "instantsearch-ui-components": "0.26.1", "instantsearch.js": "4.96.2", - "mitt": "^2.1.0" + "mitt": "^2.1.0", + "instantsearch-core": "0.1.0" }, "peerDependencies": { "@vue/server-renderer": "^3.1.2", diff --git a/packages/vue-instantsearch/rollup.config.mjs b/packages/vue-instantsearch/rollup.config.mjs index 9117e4c8007..8deb282487f 100644 --- a/packages/vue-instantsearch/rollup.config.mjs +++ b/packages/vue-instantsearch/rollup.config.mjs @@ -139,6 +139,7 @@ const external = id => [ 'algoliasearch-helper', 'instantsearch.js', + 'instantsearch-core', 'instantsearch-ui-components', 'vue', 'mitt', diff --git a/packages/vue-instantsearch/src/__tests__/common-connectors.test.js b/packages/vue-instantsearch/src/__tests__/common-connectors.test.js index aa40143b4fd..3927a9adbd4 100644 --- a/packages/vue-instantsearch/src/__tests__/common-connectors.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-connectors.test.js @@ -14,7 +14,7 @@ import { connectRatingMenu, connectRefinementList, connectToggleRefinement, -} from 'instantsearch.js/es/connectors/index'; +} from 'instantsearch-core'; import { nextTick, mountApp } from '../../test/utils'; import { diff --git a/packages/vue-instantsearch/src/__tests__/common-shared.test.js b/packages/vue-instantsearch/src/__tests__/common-shared.test.js index b3f07a13ee4..eac49078eb3 100644 --- a/packages/vue-instantsearch/src/__tests__/common-shared.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-shared.test.js @@ -6,7 +6,7 @@ import * as testSuites from '@instantsearch/tests/shared'; import { connectMenu, connectPagination, -} from 'instantsearch.js/es/connectors/index'; +} from 'instantsearch-core'; import { nextTick, mountApp } from '../../test/utils'; import { diff --git a/packages/vue-instantsearch/src/components/Autocomplete.vue b/packages/vue-instantsearch/src/components/Autocomplete.vue index 11f025ccdd5..fcbcd52ff6f 100644 --- a/packages/vue-instantsearch/src/components/Autocomplete.vue +++ b/packages/vue-instantsearch/src/components/Autocomplete.vue @@ -20,7 +20,7 @@