Skip to content
This repository has been archived by the owner on Dec 30, 2022. It is now read-only.

Commit

Permalink
feat(voiceSearch): add voice search widget (#2316)
Browse files Browse the repository at this point in the history
* feat(voiceSearch): WIP add VoiceSearch component

* feat(voiceSearch): WIP add voice search helper

* feat(voiceSearch): WIP adding connector

* Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* Apply suggestions from code review

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* feat(voiceSearch): rename variables

* feat(voiceSearch): rename helper to voiceSearch

* type(voiceSearch): fix lint error

* test(voiceSearch): add tests for component

* chore(voiceSearch): fix lint error

* chore(voiceSearch): disable tslint for unused parameter

* test(voiceSearch): add stories

* fix(voiceSearch): fix wrong props

* chore(voiceSearch): fix lint error

* test(voiceSearch): add story for custom button

* chore(voiceSearch): change to curly brace

* Apply suggestions from code review

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* chore(voiceSearch): remove comments since the config is now global

* chore(voiceSearch): add default value to searchAsYouSpeak and put typing for setSearchAsYouSpeak

* chore(voiceSearch): clean up svg

* fix(voiceSearch): fix class name for status

* chore(voiceSearch): remove unnecessary comment

* test(voiceSearch): remove unused test case

* test(voiceSearch): change way to match snapshot

* chore(voiceSearch): remove 'else'

* chore(voiceSearch): remove unnecessary $

* test(voiceSearch): add stories

* fix(voiceSearch): provide one voiceSearchHelper instancer per component

* refactor(voiceSearch): move voiceSearchHelper-related logic from the connector to the component

* test(voiceSearch): fix tests for the component

* test(voiceSearch): add tests for VoiceSearchHelper (copied from IS.js)

* chore(voiceSearch): increase limit of bundlesize

* test(voiceSearch): improve tests

* chore(voiceSearch): increase bundlesize limit

* chore(voiceSearch): increase limit of bundlesize

* Update packages/react-instantsearch-dom/src/components/VoiceSearch.tsx

Co-Authored-By: eunjae-lee <karis612@gmail.com>

* chore(voiceSearch): update voiceSearchHelper from IS.js

* chore(voiceSearch): change code using Fragment

* type(voiceSearch): Update packages/react-instantsearch-core/src/types/translatable.ts

Co-Authored-By: Samuel Vaillant <samuel.vllnt@gmail.com>

* chore(voiceSearch): use defaultProps

* fix(types): improve types for voiceSearch

* test(voiceSearch): improve test

* fix(voiceSearch): rename buttonComponent to buttonTextComponent

* chore(voiceSearch): change the path to import directly from source

* type(voiceSearch): add accessibility modifier to defaultProps

* test(voiceSearch): extract styles from stories to util.css

* test(voiceSearch): clean up mocks after each test

* feat(voiceSearch): add dispose to voiceSearchHelper

* chore(voiceSearch): rename voiceSearchHelper to createVoiceSearchHelper

* feat(voiceSearch): dispose voiceSearchHelper on unmount

* chore(voiceSearch): extract svg element to a component to remove duplicates

* type(voiceSearch): add accessibility modifier

* chore: update bundlesize

* chore: adjust bundlesize

* test(voiceSearch): fix wrong cleanup

* fix(voiceSearch): use defaultProps for searchAsYouSpeak

* fix(types): introduce Status, ErrorCode types

* fix(voiceSearch): initialize voiceSearchHelper at componentDidMount

* fix(voiceSearch): stopping voice recognition also removes event listeners

* chore(voiceSearch): rename index.js to index.ts and modify rollup config

* chore(voiceSearch): fix wrong prop name on story

* feat(voiceSearch): export createVoiceSearchHelper for users to create custom component
  • Loading branch information
Eunjae Lee authored and samouss committed Jun 4, 2019
1 parent 7f5b57f commit 0e3b124
Show file tree
Hide file tree
Showing 14 changed files with 925 additions and 3 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,15 @@
},
{
"path": "packages/react-instantsearch/dist/umd/Dom.min.js",
"maxSize": "62.00 kB"
"maxSize": "64.20 kB"
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
"maxSize": "41.50 kB"
},
{
"path": "packages/react-instantsearch-dom/dist/umd/ReactInstantSearchDOM.min.js",
"maxSize": "63.00 kB"
"maxSize": "64.00 kB"
},
{
"path": "packages/react-instantsearch-dom-maps/dist/umd/ReactInstantSearchDOMMaps.min.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/react-instantsearch-core/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const plugins = [
];

const createConfiguration = ({ name, minify = false } = {}) => ({
input: 'src/index.js',
input: 'src/index.ts',
external: ['react'],
output: {
file: `dist/umd/ReactInstantSearch${name}${minify ? '.min' : ''}.js`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ export {
default as connectToggleRefinement,
} from './connectors/connectToggleRefinement';
export { default as connectHitInsights } from './connectors/connectHitInsights';

// Types
export * from './types';
1 change: 1 addition & 0 deletions packages/react-instantsearch-core/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './translatable';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Translate = (key: string, ...params: any[]) => string;
157 changes: 157 additions & 0 deletions packages/react-instantsearch-dom/src/components/VoiceSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { Component } from 'react';
import { translatable, Translate } from 'react-instantsearch-core';
import { createClassNames } from '../core/utils';
import createVoiceSearchHelper, {
VoiceSearchHelper,
VoiceListeningState,
Status,
ErrorCode,
} from '../lib/voiceSearchHelper';
const cx = createClassNames('VoiceSearch');

type InnerComponentProps = {
status: Status;
errorCode?: ErrorCode;
isListening: boolean;
transcript: string;
isSpeechFinal: boolean;
isBrowserSupported: boolean;
};

type VoiceSearchProps = {
searchAsYouSpeak: boolean;
refine: (query: string) => void;
translate: Translate;
buttonTextComponent: React.FC<InnerComponentProps>;
statusComponent: React.FC<InnerComponentProps>;
};

const ButtonSvg = ({ children }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);

const DefaultButtonText: React.FC<InnerComponentProps> = ({
status,
errorCode,
isListening,
}) => {
return status === 'error' && errorCode === 'not-allowed' ? (
<ButtonSvg>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</ButtonSvg>
) : (
<ButtonSvg>
<path
d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"
fill={isListening ? 'currentColor' : ''}
/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</ButtonSvg>
);
};

const DefaultStatus: React.FC<InnerComponentProps> = ({ transcript }) => (
<p>{transcript}</p>
);

class VoiceSearch extends Component<VoiceSearchProps, VoiceListeningState> {
protected static defaultProps = {
searchAsYouSpeak: false,
buttonTextComponent: DefaultButtonText,
statusComponent: DefaultStatus,
};
private voiceSearchHelper?: VoiceSearchHelper;

public componentDidMount() {
const { searchAsYouSpeak, refine } = this.props;
this.voiceSearchHelper = createVoiceSearchHelper({
searchAsYouSpeak,
onQueryChange: query => refine(query),
onStateChange: () => {
this.setState(this.voiceSearchHelper!.getState());
},
});
this.setState(this.voiceSearchHelper.getState());
}

public render() {
if (!this.voiceSearchHelper) {
return null;
}

const { status, transcript, isSpeechFinal, errorCode } = this.state;
const { isListening, isBrowserSupported } = this.voiceSearchHelper;
const {
translate,
buttonTextComponent: ButtonTextComponent,
statusComponent: StatusComponent,
} = this.props;
const innerProps: InnerComponentProps = {
status,
errorCode,
isListening: isListening(),
transcript,
isSpeechFinal,
isBrowserSupported: isBrowserSupported(),
};

return (
<div className={cx('')}>
<button
className={cx('button')}
type="button"
title={
isBrowserSupported()
? translate('buttonTitle')
: translate('disabledButtonTitle')
}
onClick={this.onClick}
disabled={!isBrowserSupported()}
>
<ButtonTextComponent {...innerProps} />
</button>
<div className={cx('status')}>
<StatusComponent {...innerProps} />
</div>
</div>
);
}

public componentWillUnmount() {
if (this.voiceSearchHelper) {
this.voiceSearchHelper.dispose();
}
}

private onClick = (event: React.MouseEvent<HTMLElement>) => {
if (!this.voiceSearchHelper) {
return;
}
event.currentTarget.blur();
const { toggleListening } = this.voiceSearchHelper;
toggleListening();
};
}

export default translatable({
buttonTitle: 'Search by voice',
disabledButtonTitle: 'Search by voice (not supported on this browser)',
})(VoiceSearch);
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import VoiceSearch from '../VoiceSearch';

const mockGetState = jest.fn().mockImplementation(() => ({}));
const mockIsBrowserSupported = jest.fn().mockImplementation(() => true);
const mockIsListening = jest.fn();
const mockToggleListening = jest.fn();
const mockDispose = jest.fn();

jest.mock('../../lib/voiceSearchHelper', () => {
return () => {
return {
getState: mockGetState,
isBrowserSupported: mockIsBrowserSupported,
isListening: mockIsListening,
toggleListening: mockToggleListening,
dispose: mockDispose,
};
};
});

Enzyme.configure({ adapter: new Adapter() });

describe('VoiceSearch', () => {
afterEach(() => {
mockGetState.mockImplementation(() => ({}));
mockIsBrowserSupported.mockImplementation(() => true);
mockIsListening.mockClear();
mockToggleListening.mockClear();
});

describe('button', () => {
it('calls toggleListening when button is clicked', () => {
const wrapper = mount(<VoiceSearch />);
wrapper.find('button').simulate('click');
expect(mockToggleListening).toHaveBeenCalledTimes(1);
});
});

describe('Rendering', () => {
it('with default props', () => {
const wrapper = mount(<VoiceSearch />);
expect(wrapper).toMatchSnapshot();
});

it('with custom component for button with isListening: false', () => {
const customButtonText = ({ isListening }) =>
isListening ? 'Stop' : 'Start';

const wrapper = mount(
<VoiceSearch buttonTextComponent={customButtonText} />
);
expect(wrapper.find('button').text()).toBe('Start');
});

it('with custom component for button with isListening: true', () => {
const customButtonText = ({ isListening }) =>
isListening ? 'Stop' : 'Start';
mockIsListening.mockImplementation(() => true);

const wrapper = mount(
<VoiceSearch buttonTextComponent={customButtonText} />
);
expect(wrapper.find('button').text()).toBe('Stop');
});

it('renders a disabled button when the browser is not supported', () => {
mockIsBrowserSupported.mockImplementation(() => false);
const wrapper = mount(<VoiceSearch />);
expect(wrapper.find('button').prop('title')).toBe(
'Search by voice (not supported on this browser)'
);
expect(wrapper.find('button').prop('disabled')).toBe(true);
});

it('with custom template for status', () => {
const customStatus = ({
status,
errorCode,
isListening,
transcript,
isSpeechFinal,
isBrowserSupported,
}) => (
<div>
<p>status: {status}</p>
<p>errorCode: {errorCode}</p>
<p>isListening: {isListening ? 'true' : 'false'}</p>
<p>transcript: {transcript}</p>
<p>isSpeechFinal: {isSpeechFinal ? 'true' : 'false'}</p>
<p>isBrowserSupported: {isBrowserSupported ? 'true' : 'false'}</p>
</div>
);

mockIsListening.mockImplementation(() => true);
mockGetState.mockImplementation(() => ({
status: 'recognizing',
transcript: 'Hello',
isSpeechFinal: false,
errorCode: undefined,
}));

const wrapper = mount(<VoiceSearch statusComponent={customStatus} />);
expect(wrapper.find('.ais-VoiceSearch-status')).toMatchSnapshot();
});

it('calls voiceSearchHelper.dispose() on unmount', () => {
const wrapper = mount(<VoiceSearch />);
wrapper.find('button').simulate('click');
wrapper.unmount();
expect(mockDispose).toHaveBeenCalledTimes(1);
});
});
});
Loading

0 comments on commit 0e3b124

Please sign in to comment.