diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index a45200f69..2d25b221c 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -10,7 +10,7 @@ "/examples/react-renderer", "/examples/starter-algolia", "/examples/starter", - "/examples/voice-search", + "/examples/reshape", "/examples/vue" ], "node": "14" diff --git a/examples/reshape/README.md b/examples/reshape/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/examples/reshape/app.tsx b/examples/reshape/app.tsx new file mode 100644 index 000000000..5edc4e5db --- /dev/null +++ b/examples/reshape/app.tsx @@ -0,0 +1,74 @@ +/** @jsx h */ +import { autocomplete } from '@algolia/autocomplete-js'; +import { createQuerySuggestionsPlugin } from '@algolia/autocomplete-plugin-query-suggestions'; +import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches'; +import { h, Fragment } from 'preact'; +import { pipe } from 'ramda'; + +import '@algolia/autocomplete-theme-classic'; + +import { groupBy, limit, uniqBy } from './functions'; +import { productsPlugin } from './productsPlugin'; +import { searchClient } from './searchClient'; + +const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({ + key: 'search', + limit: 10, +}); +const querySuggestionsPlugin = createQuerySuggestionsPlugin({ + searchClient, + indexName: 'instant_search_demo_query_suggestions', + getSearchParams() { + return { + hitsPerPage: 10, + }; + }, +}); + +const dedupeAndLimitSuggestions = pipe( + uniqBy(({ source, item }) => + source.sourceId === 'querySuggestionsPlugin' ? item.query : item.label + ), + limit(4) +); +const groupByCategory = groupBy((hit) => hit.categories[0], { + getSource({ name, items }) { + return { + getItems() { + return items.slice(0, 3); + }, + templates: { + header() { + return ( + + {name} +
+ + ); + }, + }, + }; + }, +}); + +autocomplete({ + container: '#autocomplete', + placeholder: 'Search', + debug: true, + openOnFocus: true, + plugins: [recentSearchesPlugin, querySuggestionsPlugin, productsPlugin], + reshape({ sourcesBySourceId }) { + const { + recentSearchesPlugin, + querySuggestionsPlugin, + products, + ...rest + } = sourcesBySourceId; + + return [ + dedupeAndLimitSuggestions(recentSearchesPlugin, querySuggestionsPlugin), + groupByCategory(products), + Object.values(rest), + ]; + }, +}); diff --git a/examples/reshape/env.ts b/examples/reshape/env.ts new file mode 100644 index 000000000..6eef24529 --- /dev/null +++ b/examples/reshape/env.ts @@ -0,0 +1,10 @@ +import * as preact from 'preact'; + +// Parcel picks the `source` field of the monorepo packages and thus doesn't +// apply the Babel config. We therefore need to manually override the constants +// in the app, as well as the React pragmas. +// See https://twitter.com/devongovett/status/1134231234605830144 +(global as any).__DEV__ = process.env.NODE_ENV !== 'production'; +(global as any).__TEST__ = false; +(global as any).h = preact.h; +(global as any).React = preact; diff --git a/examples/reshape/favicon.png b/examples/reshape/favicon.png new file mode 100644 index 000000000..084fdfdfc Binary files /dev/null and b/examples/reshape/favicon.png differ diff --git a/examples/reshape/functions/AutocompleteReshapeFunction.ts b/examples/reshape/functions/AutocompleteReshapeFunction.ts new file mode 100644 index 000000000..180794b11 --- /dev/null +++ b/examples/reshape/functions/AutocompleteReshapeFunction.ts @@ -0,0 +1,12 @@ +import { + AutocompleteReshapeSource, + BaseItem, +} from '@algolia/autocomplete-core'; + +export type AutocompleteReshapeFunction = < + TItem extends BaseItem +>( + ...params: TParams[] +) => ( + ...expressions: Array> +) => Array>; diff --git a/examples/reshape/functions/groupBy.ts b/examples/reshape/functions/groupBy.ts new file mode 100644 index 000000000..66df8754d --- /dev/null +++ b/examples/reshape/functions/groupBy.ts @@ -0,0 +1,65 @@ +import { BaseItem } from '@algolia/autocomplete-core'; +import { AutocompleteSource } from '@algolia/autocomplete-js'; +import { flatten } from '@algolia/autocomplete-shared'; + +import { AutocompleteReshapeFunction } from './AutocompleteReshapeFunction'; +import { normalizeReshapeSources } from './normalizeReshapeSources'; + +export type GroupByOptions< + TItem extends BaseItem, + TSource extends AutocompleteSource +> = { + getSource(params: { name: string; items: TItem[] }): Partial; +}; + +export const groupBy: AutocompleteReshapeFunction = < + TItem extends BaseItem, + TSource extends AutocompleteSource = AutocompleteSource +>( + predicate: (value: TItem) => string, + options: GroupByOptions +) => { + return function runGroupBy(...rawSources) { + const sources = normalizeReshapeSources(rawSources); + + if (sources.length === 0) { + return []; + } + + // Since we create multiple sources from a single one, we take the first one + // as reference to create the new sources from. + const referenceSource = sources[0]; + const items = flatten(sources.map((source) => source.getItems())); + const groupedItems = items.reduce>((acc, item) => { + const key = predicate(item as TItem); + + if (!acc.hasOwnProperty(key)) { + acc[key] = []; + } + + acc[key].push(item as TItem); + + return acc; + }, {}); + + return Object.entries(groupedItems).map(([groupName, groupItems]) => { + const userSource = options.getSource({ + name: groupName, + items: groupItems, + }); + + return { + ...referenceSource, + sourceId: groupName, + getItems() { + return groupItems; + }, + ...userSource, + templates: { + ...((referenceSource as any).templates as any), + ...(userSource as any).templates, + }, + }; + }); + }; +}; diff --git a/examples/reshape/functions/index.ts b/examples/reshape/functions/index.ts new file mode 100644 index 000000000..2af93dae4 --- /dev/null +++ b/examples/reshape/functions/index.ts @@ -0,0 +1,3 @@ +export * from './groupBy'; +export * from './limit'; +export * from './uniqBy'; diff --git a/examples/reshape/functions/limit.ts b/examples/reshape/functions/limit.ts new file mode 100644 index 000000000..58e0e1242 --- /dev/null +++ b/examples/reshape/functions/limit.ts @@ -0,0 +1,26 @@ +import { AutocompleteReshapeFunction } from './AutocompleteReshapeFunction'; +import { normalizeReshapeSources } from './normalizeReshapeSources'; + +export const limit: AutocompleteReshapeFunction = (value) => { + return function runLimit(...rawSources) { + const sources = normalizeReshapeSources(rawSources); + const limitPerSource = Math.ceil(value / sources.length); + let sharedLimitRemaining = value; + + return sources.map((source, index) => { + const isLastSource = index === sources.length - 1; + const sourceLimit = isLastSource + ? sharedLimitRemaining + : Math.min(limitPerSource, sharedLimitRemaining); + const items = source.getItems().slice(0, sourceLimit); + sharedLimitRemaining = Math.max(sharedLimitRemaining - items.length, 0); + + return { + ...source, + getItems() { + return items; + }, + }; + }); + }; +}; diff --git a/examples/reshape/functions/normalizeReshapeSources.ts b/examples/reshape/functions/normalizeReshapeSources.ts new file mode 100644 index 000000000..b9e179475 --- /dev/null +++ b/examples/reshape/functions/normalizeReshapeSources.ts @@ -0,0 +1,13 @@ +import { + AutocompleteReshapeSource, + BaseItem, +} from '@algolia/autocomplete-core'; +import { flatten } from '@algolia/autocomplete-shared'; + +// We filter out falsy values because dynamic sources may not exist at every render. +// We flatten to support pipe operators from functional libraries like Ramda. +export function normalizeReshapeSources( + sources: Array> +) { + return flatten(sources).filter(Boolean); +} diff --git a/examples/reshape/functions/uniqBy.ts b/examples/reshape/functions/uniqBy.ts new file mode 100644 index 000000000..331af3873 --- /dev/null +++ b/examples/reshape/functions/uniqBy.ts @@ -0,0 +1,41 @@ +import { + AutocompleteReshapeSource, + BaseItem, +} from '@algolia/autocomplete-core'; + +import { AutocompleteReshapeFunction } from './AutocompleteReshapeFunction'; +import { normalizeReshapeSources } from './normalizeReshapeSources'; + +type UniqByPredicate = (params: { + source: AutocompleteReshapeSource; + item: TItem; +}) => TItem; + +export const uniqBy: AutocompleteReshapeFunction> = < + TItem extends BaseItem +>( + predicate +) => { + return function runUniqBy(...rawSources) { + const sources = normalizeReshapeSources(rawSources); + const seen = new Set(); + + return sources.map((source) => { + const items = source.getItems().filter((item) => { + const appliedItem = predicate({ source, item }); + const hasSeen = seen.has(appliedItem); + + seen.add(appliedItem); + + return !hasSeen; + }); + + return { + ...source, + getItems() { + return items; + }, + }; + }); + }; +}; diff --git a/examples/reshape/index.html b/examples/reshape/index.html new file mode 100644 index 000000000..44c246a45 --- /dev/null +++ b/examples/reshape/index.html @@ -0,0 +1,20 @@ + + + + + + + + + Reshape API | Autocomplete + + + +
+
+
+ + + + + diff --git a/examples/reshape/package.json b/examples/reshape/package.json new file mode 100644 index 000000000..c8b9d4eaf --- /dev/null +++ b/examples/reshape/package.json @@ -0,0 +1,33 @@ +{ + "name": "@algolia/autocomplete-example-reshape", + "description": "Autocomplete example with the Reshape API", + "version": "1.2.2", + "private": true, + "license": "MIT", + "scripts": { + "build": "parcel build index.html", + "start": "parcel index.html" + }, + "dependencies": { + "@algolia/autocomplete-js": "1.2.2", + "@algolia/autocomplete-plugin-query-suggestions": "1.2.2", + "@algolia/autocomplete-plugin-recent-searches": "1.2.2", + "@algolia/autocomplete-preset-algolia": "1.2.2", + "@algolia/autocomplete-shared": "1.2.2", + "@algolia/autocomplete-theme-classic": "1.2.2", + "@algolia/client-search": "4.9.1", + "algoliasearch": "4.9.1", + "preact": "10.5.13", + "ramda": "0.27.1", + "search-insights": "1.7.1" + }, + "devDependencies": { + "@algolia/autocomplete-core": "1.2.2", + "parcel": "2.0.0-beta.2" + }, + "keywords": [ + "algolia", + "autocomplete", + "javascript" + ] +} diff --git a/examples/reshape/productsPlugin.tsx b/examples/reshape/productsPlugin.tsx new file mode 100644 index 000000000..20f616ede --- /dev/null +++ b/examples/reshape/productsPlugin.tsx @@ -0,0 +1,249 @@ +/** @jsx h */ +import { + AutocompleteComponents, + getAlgoliaResults, +} from '@algolia/autocomplete-js'; +import { h, Fragment } from 'preact'; + +import { searchClient } from './searchClient'; +import { ProductRecord } from './types'; + +export const productsPlugin = { + getSources({ query }) { + if (!query) { + return []; + } + + return [ + { + sourceId: 'products', + getItems() { + return getAlgoliaResults({ + searchClient, + queries: [ + { + indexName: 'instant_search', + query, + params: { + clickAnalytics: true, + attributesToSnippet: ['name:10', 'description:35'], + snippetEllipsisText: '…', + hitsPerPage: 15, + }, + }, + ], + transformResponse({ hits }) { + const [bestBuyHits] = hits; + + return bestBuyHits.map((hit) => ({ + ...hit, + comments: hit.popularity % 100, + sale: hit.free_shipping, + // eslint-disable-next-line @typescript-eslint/camelcase + sale_price: hit.free_shipping + ? (hit.price - hit.price / 10).toFixed(2) + : hit.price.toString(), + })); + }, + }); + }, + templates: { + header() { + return ( + + Products +
+ + ); + }, + item({ item, components }) { + return ; + }, + noResults() { + return 'No products for this query.'; + }, + }, + }, + ]; + }, +}; + +type ProductItemProps = { + hit: ProductHit; + components: AutocompleteComponents; +}; + +function ProductItem({ hit, components }: ProductItemProps) { + return ( + +
+
+ {hit.name} +
+ +
+
+ +
+
+ By {hit.brand} in{' '} + {hit.categories[0]} +
+ +
+ {hit.rating > 0 && ( +
+
+ {Array.from({ length: 5 }, (_value, index) => { + const isFilled = hit.rating >= index + 1; + + return ( + + + + ); + })} +
+
+ )} +
+ + + + {hit.comments.toLocaleString()} +
+
+ +
+
+
+ + ${hit.sale_price.toLocaleString()} + {' '} + {hit.sale && ( + + ${hit.price.toLocaleString()} + + )} +
+ {hit.sale && ( + + On sale + + )} +
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/examples/reshape/searchClient.ts b/examples/reshape/searchClient.ts new file mode 100644 index 000000000..8d0663310 --- /dev/null +++ b/examples/reshape/searchClient.ts @@ -0,0 +1,6 @@ +import algoliasearch from 'algoliasearch'; + +const appId = 'latency'; +const apiKey = '6be0576ff61c053d5f9a3225e2a90f76'; + +export const searchClient = algoliasearch(appId, apiKey); diff --git a/examples/reshape/style.css b/examples/reshape/style.css new file mode 100644 index 000000000..46a9ccf94 --- /dev/null +++ b/examples/reshape/style.css @@ -0,0 +1,25 @@ +* { + box-sizing: border-box; +} + +body { + background-color: rgb(244, 244, 249); + color: rgb(65, 65, 65); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + padding: 1rem; +} + +.container { + margin: 0 auto; + max-width: 640px; + width: 100%; +} + +body[data-theme='dark'] { + background-color: rgb(0, 0, 0); + color: rgb(183, 192, 199); +} diff --git a/examples/reshape/types/Highlighted.ts b/examples/reshape/types/Highlighted.ts new file mode 100644 index 000000000..a543e461e --- /dev/null +++ b/examples/reshape/types/Highlighted.ts @@ -0,0 +1,5 @@ +import { HighlightResult } from '@algolia/client-search'; + +export type Highlighted = TRecord & { + _highlightResult: HighlightResult; +}; diff --git a/examples/reshape/types/ProductHit.ts b/examples/reshape/types/ProductHit.ts new file mode 100644 index 000000000..74d7bd40f --- /dev/null +++ b/examples/reshape/types/ProductHit.ts @@ -0,0 +1,35 @@ +import { Hit } from '@algolia/client-search'; + +export type ProductRecord = { + brand: string; + categories: string[]; + comments: number; + description: string; + free_shipping: boolean; + hierarchicalCategories: { + lvl0: string; + lvl1?: string; + lvl2?: string; + lvl3?: string; + lvl4?: string; + lvl5?: string; + lvl6?: string; + }; + image: string; + name: string; + popularity: number; + price: number; + prince_range: string; + rating: number; + sale: boolean; + sale_price: string; + type: string; + url: string; +}; + +type WithAutocompleteAnalytics = THit & { + __autocomplete_indexName: string; + __autocomplete_queryID: string; +}; + +export type ProductHit = WithAutocompleteAnalytics>; diff --git a/examples/reshape/types/index.ts b/examples/reshape/types/index.ts new file mode 100644 index 000000000..5b77dc77b --- /dev/null +++ b/examples/reshape/types/index.ts @@ -0,0 +1,2 @@ +export * from './Highlighted'; +export * from './ProductHit'; diff --git a/packages/autocomplete-core/src/__tests__/reshape.test.ts b/packages/autocomplete-core/src/__tests__/reshape.test.ts new file mode 100644 index 000000000..4ead2c01d --- /dev/null +++ b/packages/autocomplete-core/src/__tests__/reshape.test.ts @@ -0,0 +1,213 @@ +import { createPlayground, runAllMicroTasks } from '../../../../test/utils'; +import { createAutocomplete } from '../createAutocomplete'; +import { AutocompleteReshapeSource } from '../types'; + +const recentSearchesPlugin = { + getSources() { + return [ + { + sourceId: 'recentSearchesPlugin', + getItems() { + return [ + { label: 'macbook' }, + { label: 'macbook pro' }, + { label: 'macbook air' }, + ]; + }, + }, + ]; + }, +}; + +const querySuggestionsPlugin = { + getSources() { + return [ + { + sourceId: 'querySuggestionsPlugin', + getItems() { + return [ + { query: 'iphone' }, + { query: 'iphone pro' }, + { query: 'iphone pro max' }, + ]; + }, + }, + ]; + }, +}; + +const customLimit = (value: number) => { + return function runCustomLimit( + ...sources: Array> + ) { + return sources.map((source) => { + const items = source.getItems(); + + return { + ...source, + getItems() { + return items.slice(0, value); + }, + }; + }); + }; +}; + +const limitToOnePerSource = customLimit(1); + +describe('reshape', () => { + test('gets called with sourcesBySourceId, sources and state', async () => { + const reshape = jest.fn(({ sources }) => sources); + const { inputElement } = createPlayground(createAutocomplete, { + openOnFocus: true, + plugins: [recentSearchesPlugin, querySuggestionsPlugin], + reshape, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + expect(reshape).toHaveBeenCalledTimes(1); + expect(reshape).toHaveBeenCalledWith({ + sourcesBySourceId: { + recentSearchesPlugin: expect.objectContaining({ + sourceId: 'recentSearchesPlugin', + }), + querySuggestionsPlugin: expect.objectContaining({ + sourceId: 'querySuggestionsPlugin', + }), + }, + sources: expect.arrayContaining([ + expect.objectContaining({ sourceId: 'recentSearchesPlugin' }), + expect.objectContaining({ sourceId: 'querySuggestionsPlugin' }), + ]), + state: expect.any(Object), + }); + }); + + test('provides resolved items in sources', async () => { + const reshape = jest.fn(({ sources }) => sources); + const asyncPlugin = { + getSources() { + return [ + { + sourceId: 'asyncPlugin', + getItems() { + return Promise.resolve([{ label: 'macbook' }]); + }, + }, + ]; + }, + }; + const { inputElement } = createPlayground(createAutocomplete, { + openOnFocus: true, + plugins: [asyncPlugin], + reshape, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + expect(reshape).toHaveBeenCalledTimes(1); + + const call = reshape.mock.calls[0][0]; + const source: AutocompleteReshapeSource = call.sources[0]; + const sourcesBySourceId = call.sourcesBySourceId; + + expect(source.getItems()).toEqual([{ label: 'macbook' }]); + expect(sourcesBySourceId.asyncPlugin.getItems()).toEqual([ + { label: 'macbook' }, + ]); + }); + + test('supports a reshaped source in return', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + openOnFocus: true, + onStateChange, + plugins: [recentSearchesPlugin, querySuggestionsPlugin], + reshape({ sources }) { + return limitToOnePerSource(...sources); + }, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + collections: [ + expect.objectContaining({ + items: [{ __autocomplete_id: 0, label: 'macbook' }], + }), + expect.objectContaining({ + items: [{ __autocomplete_id: 1, query: 'iphone' }], + }), + ], + }), + }) + ); + }); + + test('supports an array of reshaped sources in return', async () => { + const onStateChange = jest.fn(); + + const { inputElement } = createPlayground(createAutocomplete, { + openOnFocus: true, + onStateChange, + plugins: [recentSearchesPlugin, querySuggestionsPlugin], + reshape({ sources }) { + return [limitToOnePerSource(...sources)]; + }, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + collections: [ + expect.objectContaining({ + items: [{ __autocomplete_id: 0, label: 'macbook' }], + }), + expect.objectContaining({ + items: [{ __autocomplete_id: 1, query: 'iphone' }], + }), + ], + }), + }) + ); + }); + + test('ignores undefined sources', async () => { + const onStateChange = jest.fn(); + const { inputElement } = createPlayground(createAutocomplete, { + openOnFocus: true, + onStateChange, + plugins: [recentSearchesPlugin, querySuggestionsPlugin], + reshape({ sources }) { + return [limitToOnePerSource(...sources), undefined]; + }, + }); + + inputElement.focus(); + await runAllMicroTasks(); + + expect(onStateChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + collections: [ + expect.objectContaining({ + items: [{ __autocomplete_id: 0, label: 'macbook' }], + }), + expect.objectContaining({ + items: [{ __autocomplete_id: 1, query: 'iphone' }], + }), + ], + }), + }) + ); + }); +}); diff --git a/packages/autocomplete-core/src/getAutocompleteSetters.ts b/packages/autocomplete-core/src/getAutocompleteSetters.ts index 4a698129a..2ffd7473e 100644 --- a/packages/autocomplete-core/src/getAutocompleteSetters.ts +++ b/packages/autocomplete-core/src/getAutocompleteSetters.ts @@ -1,10 +1,11 @@ +import { flatten } from '@algolia/autocomplete-shared'; + import { AutocompleteApi, AutocompleteCollection, AutocompleteStore, BaseItem, } from './types'; -import { flatten } from './utils'; interface GetAutocompleteSettersOptions { store: AutocompleteStore; diff --git a/packages/autocomplete-core/src/getDefaultProps.ts b/packages/autocomplete-core/src/getDefaultProps.ts index bc0bc7a1f..dc8a46ef2 100644 --- a/packages/autocomplete-core/src/getDefaultProps.ts +++ b/packages/autocomplete-core/src/getDefaultProps.ts @@ -1,6 +1,7 @@ import { getItemsCount, generateAutocompleteId, + flatten, } from '@algolia/autocomplete-shared'; import { @@ -10,7 +11,7 @@ import { BaseItem, InternalAutocompleteOptions, } from './types'; -import { getNormalizedSources, flatten } from './utils'; +import { getNormalizedSources } from './utils'; export function getDefaultProps( props: AutocompleteOptions, @@ -32,6 +33,7 @@ export function getDefaultProps( stallThreshold: 300, environment, shouldPanelOpen: ({ state }) => getItemsCount(state) > 0, + reshape: ({ sources }) => sources, ...props, // Since `generateAutocompleteId` triggers a side effect (it increments // an internal counter), we don't want to execute it if unnecessary. diff --git a/packages/autocomplete-core/src/onInput.ts b/packages/autocomplete-core/src/onInput.ts index 7cdce3e70..451160329 100644 --- a/packages/autocomplete-core/src/onInput.ts +++ b/packages/autocomplete-core/src/onInput.ts @@ -1,3 +1,4 @@ +import { reshape } from './reshape'; import { preResolve, resolve, postResolve } from './resolve'; import { AutocompleteScopeApi, @@ -97,6 +98,9 @@ export function onInput({ ) .then(resolve) .then((responses) => postResolve(responses, sources)) + .then((collections) => + reshape({ collections, props, state: store.getState() }) + ) .then((collections) => { setStatus('idle'); setCollections(collections as any); diff --git a/packages/autocomplete-core/src/reshape.ts b/packages/autocomplete-core/src/reshape.ts new file mode 100644 index 000000000..d6e7f4fba --- /dev/null +++ b/packages/autocomplete-core/src/reshape.ts @@ -0,0 +1,55 @@ +import { flatten } from '@algolia/autocomplete-shared'; + +import { + AutocompleteCollection, + AutocompleteReshapeSourcesBySourceId, + AutocompleteState, + BaseItem, + InternalAutocompleteOptions, +} from './types'; + +type ReshapeParams = { + collections: Array>; + props: InternalAutocompleteOptions; + state: AutocompleteState; +}; + +export function reshape({ + collections, + props, + state, +}: ReshapeParams) { + // Sources are grouped by `sourceId` to conveniently pick them via destructuring. + // Example: `const { recentSearchesPlugin } = sourcesBySourceId` + const sourcesBySourceId = collections.reduce< + AutocompleteReshapeSourcesBySourceId + >( + (acc, collection) => ({ + ...acc, + [collection.source.sourceId]: { + ...collection.source, + getItems() { + // We provide the resolved items from the collection to the `reshape` prop. + return flatten(collection.items); + }, + }, + }), + {} + ); + + const reshapeSources = props.reshape({ + sources: Object.values(sourcesBySourceId), + sourcesBySourceId, + state, + }); + + // We reconstruct the collections with the items modified by the `reshape` prop. + return flatten(reshapeSources) + .filter(Boolean) + .map((source) => { + return { + source, + items: source.getItems(), + }; + }); +} diff --git a/packages/autocomplete-core/src/resolve.ts b/packages/autocomplete-core/src/resolve.ts index 68bb97a5b..0b732b7ef 100644 --- a/packages/autocomplete-core/src/resolve.ts +++ b/packages/autocomplete-core/src/resolve.ts @@ -4,7 +4,7 @@ import type { RequesterDescription, TransformResponse, } from '@algolia/autocomplete-preset-algolia'; -import { decycle, invariant } from '@algolia/autocomplete-shared'; +import { decycle, flatten, invariant } from '@algolia/autocomplete-shared'; import { MultipleQueriesQuery, SearchForFacetValuesResponse, @@ -13,7 +13,7 @@ import { import type { SearchClient } from 'algoliasearch/lite'; import { BaseItem, InternalAutocompleteSource } from './types'; -import { flatten, mapToAlgoliaResponse } from './utils'; +import { mapToAlgoliaResponse } from './utils'; function isDescription( item: diff --git a/packages/autocomplete-core/src/types/AutocompleteOptions.ts b/packages/autocomplete-core/src/types/AutocompleteOptions.ts index d5ceec0be..98f795f79 100644 --- a/packages/autocomplete-core/src/types/AutocompleteOptions.ts +++ b/packages/autocomplete-core/src/types/AutocompleteOptions.ts @@ -4,6 +4,7 @@ import { AutocompleteScopeApi, BaseItem } from './AutocompleteApi'; import { AutocompleteEnvironment } from './AutocompleteEnvironment'; import { AutocompleteNavigator } from './AutocompleteNavigator'; import { AutocompletePlugin } from './AutocompletePlugin'; +import { Reshape } from './AutocompleteReshape'; import { AutocompleteSource, InternalAutocompleteSource, @@ -165,6 +166,16 @@ export interface AutocompleteOptions { * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-plugins */ plugins?: Array>; + /** + * The function called to reshape the sources after they're resolved. + * + * This is useful to transform sources before rendering them. You can group sources by attribute, remove duplicates, create shared limits between sources, etc. + * + * See [**Reshaping sources**](https://www.algolia.com/doc/ui-libraries/autocomplete/guides/reshaping-sources/) for more information. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-reshape + */ + reshape?: Reshape; } // Props manipulated internally with default values. @@ -186,4 +197,5 @@ export interface InternalAutocompleteOptions shouldPanelOpen(params: { state: AutocompleteState }): boolean; onSubmit(params: OnSubmitParams): void; onReset(params: OnResetParams): void; + reshape: Reshape; } diff --git a/packages/autocomplete-core/src/types/AutocompleteReshape.ts b/packages/autocomplete-core/src/types/AutocompleteReshape.ts new file mode 100644 index 000000000..ceb7e7d0c --- /dev/null +++ b/packages/autocomplete-core/src/types/AutocompleteReshape.ts @@ -0,0 +1,33 @@ +import { BaseItem } from './AutocompleteApi'; +import { AutocompleteSource } from './AutocompleteSource'; +import { AutocompleteState } from './AutocompleteState'; + +export type AutocompleteReshapeSource< + TItem extends BaseItem +> = AutocompleteSource & { + getItems(): TItem[]; +}; + +export type AutocompleteReshapeSourcesBySourceId< + TItem extends BaseItem +> = Record>; + +export type Reshape< + TItem extends BaseItem, + TState extends AutocompleteState = AutocompleteState +> = (params: { + /** + * The resolved sources provided by [`getSources`](https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/sources/#param-getsources) + */ + sources: Array>; + /** + * The resolved sources grouped by [`sourceId`](https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/sources/#param-sourceid)s + */ + sourcesBySourceId: AutocompleteReshapeSourcesBySourceId; + /** + * The current Autocomplete state. + * + * @link https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/state + */ + state: TState; +}) => Array>; diff --git a/packages/autocomplete-core/src/types/index.ts b/packages/autocomplete-core/src/types/index.ts index 2b2fb4f7c..02ce13328 100644 --- a/packages/autocomplete-core/src/types/index.ts +++ b/packages/autocomplete-core/src/types/index.ts @@ -6,6 +6,7 @@ export * from './AutocompleteOptions'; export * from './AutocompleteSource'; export * from './AutocompletePropGetters'; export * from './AutocompletePlugin'; +export * from './AutocompleteReshape'; export * from './AutocompleteSetters'; export * from './AutocompleteState'; export * from './AutocompleteStore'; diff --git a/packages/autocomplete-core/src/utils/index.ts b/packages/autocomplete-core/src/utils/index.ts index d0998991d..78bfba092 100644 --- a/packages/autocomplete-core/src/utils/index.ts +++ b/packages/autocomplete-core/src/utils/index.ts @@ -1,5 +1,4 @@ export * from './createConcurrentSafePromise'; -export * from './flatten'; export * from './getNextActiveItemId'; export * from './getNormalizedSources'; export * from './getActiveItem'; diff --git a/packages/autocomplete-core/src/utils/__tests__/flatten.test.ts b/packages/autocomplete-shared/src/__tests__/flatten.test.ts similarity index 100% rename from packages/autocomplete-core/src/utils/__tests__/flatten.test.ts rename to packages/autocomplete-shared/src/__tests__/flatten.test.ts diff --git a/packages/autocomplete-core/src/utils/flatten.ts b/packages/autocomplete-shared/src/flatten.ts similarity index 100% rename from packages/autocomplete-core/src/utils/flatten.ts rename to packages/autocomplete-shared/src/flatten.ts diff --git a/packages/autocomplete-shared/src/index.ts b/packages/autocomplete-shared/src/index.ts index 6a4a7de39..cdcd3b7ff 100644 --- a/packages/autocomplete-shared/src/index.ts +++ b/packages/autocomplete-shared/src/index.ts @@ -1,6 +1,7 @@ export * from './createRef'; export * from './debounce'; export * from './decycle'; +export * from './flatten'; export * from './generateAutocompleteId'; export * from './getAttributeValueByPath'; export * from './getItemsCount'; diff --git a/yarn.lock b/yarn.lock index 3f9b76276..83ae3f662 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15482,6 +15482,11 @@ raf@^3.4.1: dependencies: performance-now "^2.1.0" +ramda@0.27.1: + version "0.27.1" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9" + integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw== + ramda@~0.26.1: version "0.26.1" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06"