diff --git a/package.json b/package.json index a6fd334fbb..c562f78b03 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ }, { "path": "packages/react-instantsearch-dom/dist/umd/ReactInstantSearchDOM.min.js", - "maxSize": "35.50 kB" + "maxSize": "35.75 kB" }, { "path": "packages/react-instantsearch-dom-maps/dist/umd/ReactInstantSearchDOMMaps.min.js", diff --git a/packages/react-instantsearch-core/src/connectors/__tests__/connectVoiceSearch.js b/packages/react-instantsearch-core/src/connectors/__tests__/connectVoiceSearch.js new file mode 100644 index 0000000000..2fb8201dc2 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/__tests__/connectVoiceSearch.js @@ -0,0 +1,349 @@ +import connect from '../connectVoiceSearch'; +import { SearchParameters } from 'algoliasearch-helper'; + +jest.mock('../../core/createConnector', () => x => x); + +describe('connectVoiceSearch', () => { + describe('single index', () => { + const contextValue = { mainTargetedIndex: 'index' }; + + describe('getProvidedProps', () => { + it('provides the correct props to the component', () => { + expect(connect.getProvidedProps({ contextValue }, {}, {})).toEqual({ + currentRefinement: '', + }); + + expect( + connect.getProvidedProps({ contextValue }, { query: 'abc' }, {}) + ).toEqual({ + currentRefinement: 'abc', + }); + }); + }); + + describe('refine', () => { + it('calls refine and updates the state correctly', () => { + expect(connect.refine({ contextValue }, {}, 'abc')).toEqual({ + page: 1, + query: 'abc', + additionalVoiceParameters: {}, + }); + }); + + it('refines and get additionalVoiceParameters', () => { + const { additionalVoiceParameters } = connect.refine( + { contextValue }, + {}, + 'abc' + ); + expect(additionalVoiceParameters).toEqual(expect.objectContaining({})); + }); + + it('refines with additionalQueryParameters', () => { + const props = { + contextValue, + additionalQueryParameters: () => ({ additional: 'param' }), + }; + expect(connect.refine(props, {}, 'abc')).toEqual({ + page: 1, + query: 'abc', + additionalVoiceParameters: { + additional: 'param', + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: undefined, + removeStopWords: true, + }, + }); + }); + + it('refines with language', () => { + const props = { contextValue, language: 'en-US' }; + expect(connect.refine(props, {}, 'abc')).toEqual({ + page: 1, + query: 'abc', + additionalVoiceParameters: { + queryLanguages: ['en'], + }, + }); + }); + + it('refines with language (2)', () => { + const props = { + contextValue, + language: 'en-US', + additionalQueryParameters: () => ({}), + }; + expect(connect.refine(props, {}, 'abc')).toEqual({ + page: 1, + query: 'abc', + additionalVoiceParameters: { + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + }, + }); + }); + + it('overrides with additionalQueryParameters', () => { + const props = { + contextValue, + additionalQueryParameters: () => ({ + ignorePlurals: false, + optionalWords: 'something else', + removeStopWords: false, + }), + }; + expect(connect.refine(props, {}, 'abc')).toEqual({ + page: 1, + query: 'abc', + additionalVoiceParameters: { + ignorePlurals: false, + optionalWords: 'something else', + removeStopWords: false, + }, + }); + }); + }); + + describe('cleanUp', () => { + it('should return the right searchState when clean up', () => { + expect( + connect.cleanUp( + { + contextValue, + }, + { + query: 'abc', + additionalVoiceParameters: { + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + }, + } + ) + ).toEqual({}); + }); + }); + + describe('getSearchParameters', () => { + it('returns searchParameters with query', () => { + expect( + connect.getSearchParameters( + new SearchParameters(), + { + contextValue, + }, + { query: 'foo' } + ) + ).toEqual(expect.objectContaining({ query: 'foo' })); + }); + + it('returns searchParameters with additional params', () => { + expect( + connect.getSearchParameters( + new SearchParameters(), + { + contextValue, + }, + { + query: 'abc', + additionalVoiceParameters: { + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + }, + } + ) + ).toEqual( + expect.objectContaining({ + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + query: 'abc', + }) + ); + }); + }); + + describe('getMetadata', () => { + it('returns correct metadata', () => { + expect( + connect.getMetadata( + { + contextValue, + }, + { + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + query: 'abc', + } + ) + ).toEqual({ + id: 'query', + index: 'index', + items: [ + { + label: 'query: abc', + value: expect.any(Function), + currentRefinement: 'abc', + }, + ], + }); + }); + }); + }); + + describe('multi index', () => { + const contextValue = { mainTargetedIndex: 'first' }; + const indexContextValue = { targetedIndex: 'second' }; + + describe('getProvidedProps', () => { + it('provides the correct props to the component', () => { + expect( + connect.getProvidedProps({ contextValue, indexContextValue }, {}, {}) + ).toEqual({ + currentRefinement: '', + }); + + expect( + connect.getProvidedProps( + { contextValue, indexContextValue }, + { indices: { second: { query: 'yep' } } }, + {} + ) + ).toEqual({ + currentRefinement: 'yep', + }); + }); + }); + + describe('refine', () => { + it('refines with additionalQueryParameters', () => { + const props = { + contextValue, + indexContextValue, + additionalQueryParameters: () => ({ additional: 'param' }), + }; + expect(connect.refine(props, {}, 'abc')).toEqual({ + indices: { + second: { + page: 1, + query: 'abc', + additionalVoiceParameters: { + additional: 'param', + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: undefined, + removeStopWords: true, + }, + }, + }, + }); + }); + + it('refines with language', () => { + const props = { contextValue, indexContextValue, language: 'en-US' }; + expect(connect.refine(props, {}, 'abc')).toEqual({ + indices: { + second: { + page: 1, + query: 'abc', + additionalVoiceParameters: { + queryLanguages: ['en'], + }, + }, + }, + }); + }); + }); + + describe('getSearchParameters', () => { + it('returns searchParameters with query', () => { + expect( + connect.getSearchParameters( + new SearchParameters(), + { + contextValue, + indexContextValue, + }, + { indices: { second: { query: 'foo' } } } + ) + ).toEqual(expect.objectContaining({ query: 'foo' })); + }); + + it('returns searchParameters with additional params', () => { + expect( + connect.getSearchParameters( + new SearchParameters(), + { + contextValue, + indexContextValue, + }, + { + indices: { + second: { + query: 'abc', + additionalVoiceParameters: { + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + }, + }, + }, + } + ) + ).toEqual( + expect.objectContaining({ + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + query: 'abc', + }) + ); + }); + }); + + describe('getMetadata', () => { + it('returns correct metadata', () => { + expect( + connect.getMetadata( + { + contextValue, + indexContextValue, + }, + { + indices: { + second: { + ignorePlurals: true, + optionalWords: 'abc', + queryLanguages: ['en'], + removeStopWords: true, + query: 'abc', + }, + }, + } + ) + ).toEqual({ + id: 'query', + index: 'second', + items: [ + { + label: 'query: abc', + value: expect.any(Function), + currentRefinement: 'abc', + }, + ], + }); + }); + }); + }); +}); diff --git a/packages/react-instantsearch-core/src/connectors/connectVoiceSearch.js b/packages/react-instantsearch-core/src/connectors/connectVoiceSearch.js new file mode 100644 index 0000000000..0e2a201e84 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/connectVoiceSearch.js @@ -0,0 +1,159 @@ +import PropTypes from 'prop-types'; +import createConnector from '../core/createConnector'; +import { + cleanUpValue, + refineValue, + getCurrentRefinementValue, + getIndexId, +} from '../core/indexUtils'; + +function getId() { + return 'query'; +} + +function getAdditionalId() { + return 'additionalVoiceParameters'; +} + +function getCurrentRefinementQuery(props, searchState, context) { + const id = getId(); + const currentRefinement = getCurrentRefinementValue( + props, + searchState, + context, + id, + '' + ); + + if (currentRefinement) { + return currentRefinement; + } + return ''; +} + +function getCurrentRefinementAdditional(props, searchState, context) { + const id = getAdditionalId(); + const currentRefinement = getCurrentRefinementValue( + props, + searchState, + context, + id, + '' + ); + + if (currentRefinement) { + return currentRefinement; + } + return {}; +} + +function refine(props, searchState, nextRefinement, context) { + const id = getId(); + const voiceParams = getAdditionalId(); + const queryLanguages = props.language + ? { queryLanguages: [props.language.split('-')[0]] } + : {}; + const additionalQueryParameters = + typeof props.additionalQueryParameters === 'function' + ? { + ignorePlurals: true, + removeStopWords: true, + optionalWords: nextRefinement, + ...props.additionalQueryParameters({ query: nextRefinement }), + } + : {}; + const nextValue = { + [id]: nextRefinement, + [voiceParams]: { + ...queryLanguages, + ...additionalQueryParameters, + }, + }; + const resetPage = true; + return refineValue(searchState, nextValue, context, resetPage); +} + +function cleanUp(props, searchState, context) { + const interimState = cleanUpValue(searchState, context, getId()); + return cleanUpValue(interimState, context, getAdditionalId()); +} + +export default createConnector({ + displayName: 'AlgoliaVoiceSearch', + + propTypes: { + defaultRefinement: PropTypes.string, + }, + + getProvidedProps(props, searchState, searchResults) { + return { + currentRefinement: getCurrentRefinementQuery(props, searchState, { + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + }), + isSearchStalled: searchResults.isSearchStalled, + }; + }, + + refine(props, searchState, nextRefinement) { + return refine(props, searchState, nextRefinement, { + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + }); + }, + + cleanUp(props, searchState) { + return cleanUp(props, searchState, { + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + }); + }, + + getSearchParameters(searchParameters, props, searchState) { + const query = getCurrentRefinementQuery(props, searchState, { + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + }); + const additionalParams = getCurrentRefinementAdditional( + props, + searchState, + { + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + } + ); + + return searchParameters + .setQuery(query) + .setQueryParameters(additionalParams); + }, + + getMetadata(props, searchState) { + const id = getId(); + const currentRefinement = getCurrentRefinementQuery(props, searchState, { + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + }); + return { + id, + index: getIndexId({ + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + }), + items: + currentRefinement === null + ? [] + : [ + { + label: `${id}: ${currentRefinement}`, + value: nextState => + refine(props, nextState, '', { + ais: props.contextValue, + multiIndexContext: props.indexContextValue, + }), + currentRefinement, + }, + ], + }; + }, +}); diff --git a/packages/react-instantsearch-core/src/index.ts b/packages/react-instantsearch-core/src/index.ts index 4555cd34a6..49ceb27ddf 100644 --- a/packages/react-instantsearch-core/src/index.ts +++ b/packages/react-instantsearch-core/src/index.ts @@ -51,6 +51,7 @@ export { default as connectToggleRefinement, } from './connectors/connectToggleRefinement'; export { default as connectHitInsights } from './connectors/connectHitInsights'; +export { default as connectVoiceSearch } from './connectors/connectVoiceSearch'; // Types export * from './types'; diff --git a/packages/react-instantsearch-dom/src/components/VoiceSearch.tsx b/packages/react-instantsearch-dom/src/components/VoiceSearch.tsx index 79459dc461..16a97fd482 100644 --- a/packages/react-instantsearch-dom/src/components/VoiceSearch.tsx +++ b/packages/react-instantsearch-dom/src/components/VoiceSearch.tsx @@ -23,6 +23,9 @@ export type InnerComponentProps = { type VoiceSearchProps = { searchAsYouSpeak: boolean; + language?: string; + additionalQueryParameters?: (params: { query: string }) => {} | void; + refine: (query: string) => void; translate: Translate; buttonTextComponent: React.FC; @@ -84,9 +87,10 @@ class VoiceSearch extends Component { private voiceSearchHelper?: VoiceSearchHelper; public componentDidMount() { - const { searchAsYouSpeak, refine } = this.props; + const { searchAsYouSpeak = false, language, refine } = this.props; this.voiceSearchHelper = createVoiceSearchHelper({ searchAsYouSpeak, + language, onQueryChange: query => refine(query), onStateChange: () => { this.setState(this.voiceSearchHelper!.getState()); diff --git a/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts b/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts index b3d483c513..0938f11d34 100644 --- a/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts +++ b/packages/react-instantsearch-dom/src/lib/voiceSearchHelper/index.ts @@ -2,6 +2,7 @@ export type VoiceSearchHelperParams = { searchAsYouSpeak: boolean; + language?: string; onQueryChange: (query: string) => void; onStateChange: () => void; }; @@ -33,6 +34,7 @@ export type ToggleListening = () => void; export default function createVoiceSearchHelper({ searchAsYouSpeak, + language, onQueryChange, onStateChange, }: VoiceSearchHelperParams): VoiceSearchHelper { @@ -107,6 +109,9 @@ export default function createVoiceSearchHelper({ } resetState('askingPermission'); recognition.interimResults = true; + if (language) { + recognition.lang = language; + } recognition.addEventListener('start', onStart); recognition.addEventListener('error', onError); recognition.addEventListener('result', onResult); diff --git a/packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts b/packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts index d671030e87..66db565290 100644 --- a/packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts +++ b/packages/react-instantsearch-dom/src/widgets/VoiceSearch.ts @@ -1,4 +1,4 @@ -import { connectSearchBox } from 'react-instantsearch-core'; +import { connectVoiceSearch } from 'react-instantsearch-core'; import VoiceSearch from '../components/VoiceSearch'; -export default connectSearchBox(VoiceSearch); +export default connectVoiceSearch(VoiceSearch); diff --git a/stories/VoiceSearch.stories.tsx b/stories/VoiceSearch.stories.tsx index ed8d2a134f..bff5256155 100644 --- a/stories/VoiceSearch.stories.tsx +++ b/stories/VoiceSearch.stories.tsx @@ -134,4 +134,35 @@ stories ); - }); + }) + .add('with additional paramaters', () => ( + + {}} /> + + + )) + .add('with additional paramaters & language', () => ( + + {}} /> + + )) + .add('with additional paramaters & user set & language', () => ( + + ({ analyticsTags: ['voice'] })} + /> + + ));