* Implement UI for autocomplete feature
* Debounce autocompleteStart()
* Fix AMO_CDN env var in dev
This commit is contained in:
William Durand 2017-08-28 20:51:33 +02:00 коммит произвёл GitHub
Родитель 9eb45fcb3c
Коммит 9fb119bf60
16 изменённых файлов: 986 добавлений и 301 удалений

Просмотреть файл

@ -52,7 +52,7 @@
"amo:dev": {
"command": "better-npm-run start-dev-proxy",
"env": {
"AMO_CDN": "https://addons-amo-dev-cdn.allizom.org",
"AMO_CDN": "https://addons-dev-cdn.allizom.org",
"PROXY_API_HOST": "https://addons-dev.allizom.org",
"FXA_CONFIG": "local",
"CSP": false,
@ -181,6 +181,7 @@
"raven-js": "3.17.0",
"react": "15.6.1",
"react-addons-css-transition-group": "15.6.0",
"react-autosuggest": "9.3.2",
"react-cookie": "1.0.5",
"react-dom": "15.6.1",
"react-helmet": "5.1.3",

Просмотреть файл

@ -1,111 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose } from 'redux';
import {
ADDON_TYPE_EXTENSION,
ADDON_TYPE_THEME,
validAddonTypes,
} from 'core/constants';
import translate from 'core/i18n/translate';
import { convertFiltersToQueryParams } from 'core/searchUtils';
import SearchInput from 'ui/components/SearchInput';
import 'core/css/inc/lib.scss';
import './SearchForm.scss';
export class SearchFormBase extends React.Component {
static propTypes = {
addonType: PropTypes.string,
api: PropTypes.object.isRequired,
i18n: PropTypes.object.isRequired,
pathname: PropTypes.string.isRequired,
query: PropTypes.string.isRequired,
}
static contextTypes = {
router: PropTypes.object,
}
goToSearch(query) {
const { addonType, api, pathname } = this.props;
const filters = { query };
if (addonType) {
filters.addonType = addonType;
}
this.context.router.push({
pathname: `/${api.lang}/${api.clientApp}${pathname}`,
query: convertFiltersToQueryParams(filters),
});
}
handleSearch = (e) => {
e.preventDefault();
this.searchQuery.input.blur();
this.goToSearch(this.searchQuery.value);
}
render() {
const { addonType, api, i18n, pathname, query } = this.props;
let placeholderText;
if (addonType === ADDON_TYPE_EXTENSION) {
placeholderText = i18n.gettext('Search extensions');
} else if (addonType === ADDON_TYPE_THEME) {
placeholderText = i18n.gettext('Search themes');
} else {
placeholderText = i18n.gettext('Search extensions and themes');
}
return (
<form
method="GET"
action={`/${api.lang}/${api.clientApp}${pathname}`}
onSubmit={this.handleSearch}
className="SearchForm-form"
ref={(ref) => { this.form = ref; }}
>
<label
className="visually-hidden"
htmlFor="q"
>
{i18n.gettext('Search')}
</label>
<SearchInput
className="SearchForm-query"
defaultValue={query}
name="q"
placeholder={placeholderText}
ref={(ref) => { this.searchQuery = ref; }}
type="search"
/>
<button
className="visually-hidden"
onClick={this.handleSearch}
ref={(ref) => { this.submitButton = ref; }}
title="Enter"
type="submit"
>
{i18n.gettext('Search')}
</button>
</form>
);
}
}
export function mapStateToProps(state) {
return {
addonType: validAddonTypes.includes(state.viewContext.context) ?
state.viewContext.context : null,
api: state.api,
};
}
export default compose(
connect(mapStateToProps),
translate({ withRef: true }),
)(SearchFormBase);

Просмотреть файл

@ -1,15 +0,0 @@
@import "~core/css/inc/mixins";
@import "~amo/css/inc/vars";
@import "~ui/css/vars";
.SearchForm-form {
background: $header-search-color;
border: 0;
}
.SearchInput-input,
.SearchForm-form {
color: $base-color;
font-size: $font-size-m-smaller;
white-space: nowrap;
}

Просмотреть файл

@ -0,0 +1,27 @@
/* @flow */
import React from 'react';
import Icon from 'ui/components/Icon';
import LoadingText from 'ui/components/LoadingText';
type Props = {|
name: string,
iconUrl: string,
loading: boolean,
arrowAlt?: string,
|};
const Suggestion = ({ name, iconUrl, arrowAlt, loading }: Props) => {
return (
<p className="Suggestion">
<img alt={name} className="Suggestion-icon" src={iconUrl} />
<span className="Suggestion-name">
{loading ? <LoadingText minWidth={20} range={12} /> : name}
</span>
<Icon name="arrow-big-blue" alt={arrowAlt} />
</p>
);
};
export default Suggestion;

Просмотреть файл

@ -0,0 +1,241 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose } from 'redux';
import Autosuggest from 'react-autosuggest';
import { withRouter } from 'react-router';
import defaultDebounce from 'simple-debounce';
import {
ADDON_TYPE_EXTENSION,
ADDON_TYPE_THEME,
validAddonTypes,
} from 'core/constants';
import log from 'core/logger';
import translate from 'core/i18n/translate';
import { convertFiltersToQueryParams } from 'core/searchUtils';
import SearchInput from 'ui/components/SearchInput';
import { withErrorHandler } from 'core/errorHandler';
import { getAddonIconUrl } from 'core/imageUtils';
import {
autocompleteCancel,
autocompleteStart,
} from 'core/reducers/autocomplete';
import Suggestion from 'amo/components/SearchForm/Suggestion';
import 'core/css/inc/lib.scss';
import './styles.scss';
export class SearchFormBase extends React.Component {
static propTypes = {
addonType: PropTypes.string,
api: PropTypes.object.isRequired,
i18n: PropTypes.object.isRequired,
pathname: PropTypes.string.isRequired,
query: PropTypes.string.isRequired,
dispatch: PropTypes.func.isRequired,
errorHandler: PropTypes.object.isRequired,
suggestions: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
iconUrl: PropTypes.string.isRequired,
})).isRequired,
loadingSuggestions: PropTypes.bool.isRequired,
router: PropTypes.object.isRequired,
debounce: PropTypes.func.isRequired,
}
static defaultProps = {
debounce: defaultDebounce,
};
constructor(props: Object) {
super(props);
this.state = {
searchValue: props.query || '',
};
}
componentWillReceiveProps(nextProps) {
const { query } = nextProps;
if (this.props.query !== query) {
this.setState({ searchValue: query || '' });
}
}
getSuggestions() {
if (this.props.loadingSuggestions) {
// 10 is the maximum number of results returned by the API
return Array(10).fill({
name: this.props.i18n.gettext('Loading'),
iconUrl: getAddonIconUrl(),
loading: true,
});
}
return this.props.suggestions.map((suggestion) => ({
...suggestion,
loading: false,
}));
}
goToSearch(query) {
const { addonType, api, pathname, router } = this.props;
const filters = { query };
if (addonType) {
filters.addonType = addonType;
}
router.push({
pathname: `/${api.lang}/${api.clientApp}${pathname}`,
query: convertFiltersToQueryParams(filters),
});
}
handleSearch = (e) => {
e.preventDefault();
this.searchInput.blur();
this.goToSearch(this.searchInput.value);
}
handleSearchChange = (e) => {
this.setState({ searchValue: e.target.value });
}
handleSuggestionsFetchRequested = this.props.debounce(({ value }) => {
if (!value) {
log.debug(`Ignoring suggestions fetch requested because value is not supplied: ${value}`);
return;
}
const { addonType, dispatch, errorHandler } = this.props;
const filters = { query: value };
if (addonType) {
filters.addonType = addonType;
}
dispatch(autocompleteStart({
errorHandlerId: errorHandler.id,
filters,
}));
}, 200)
handleSuggestionsClearRequested = () => {
this.props.dispatch(autocompleteCancel());
}
handleSuggestionSelected = (e, { suggestion }) => {
e.preventDefault();
if (suggestion.loading) {
log.debug('Ignoring loading suggestion selected');
return;
}
this.setState({ searchValue: '' }, () => {
this.props.router.push(suggestion.url);
});
}
renderSuggestion = (suggestion) => {
const { name, iconUrl, loading } = suggestion;
return (
<Suggestion
name={name}
iconUrl={iconUrl}
loading={loading}
arrowAlt={this.props.i18n.gettext('Go to the add-on page')}
/>
);
}
render() {
const { addonType, api, i18n, pathname } = this.props;
let placeholderText;
if (addonType === ADDON_TYPE_EXTENSION) {
placeholderText = i18n.gettext('Search extensions');
} else if (addonType === ADDON_TYPE_THEME) {
placeholderText = i18n.gettext('Search themes');
} else {
placeholderText = i18n.gettext('Search extensions and themes');
}
const inputProps = {
value: this.state.searchValue,
onChange: this.handleSearchChange,
placeholder: placeholderText,
className: 'SearchForm-query',
name: 'q',
type: 'search',
inputRef: (ref) => { this.searchInput = ref; },
};
return (
<form
method="GET"
action={`/${api.lang}/${api.clientApp}${pathname}`}
onSubmit={this.handleSearch}
className="SearchForm-form"
ref={(ref) => { this.form = ref; }}
>
<label
className="visually-hidden"
htmlFor={inputProps.name}
>
{i18n.gettext('Search')}
</label>
<Autosuggest
className="SearchForm-suggestions"
focusInputOnSuggestionClick={false}
getSuggestionValue={(suggestion) => suggestion.name}
inputProps={inputProps}
onSuggestionsClearRequested={this.handleSuggestionsClearRequested}
onSuggestionsFetchRequested={this.handleSuggestionsFetchRequested}
onSuggestionSelected={this.handleSuggestionSelected}
renderSuggestion={this.renderSuggestion}
renderInputComponent={(props) => <SearchInput {...props} />}
suggestions={this.getSuggestions()}
theme={{
suggestionContainer: 'SearchForm-suggestions',
suggestionsList: 'SearchForm-suggestions-list',
suggestion: 'SearchForm-suggestions-item',
suggestionHighlighted: 'SearchForm-suggestions-item--highlighted',
}}
/>
<button
className="visually-hidden"
onClick={this.handleSearch}
ref={(ref) => { this.submitButton = ref; }}
title={i18n.gettext('Enter')}
type="submit"
>
{i18n.gettext('Search')}
</button>
</form>
);
}
}
export function mapStateToProps(state) {
return {
addonType: validAddonTypes.includes(state.viewContext.context) ?
state.viewContext.context : null,
api: state.api,
suggestions: state.autocomplete.suggestions,
loadingSuggestions: state.autocomplete.loading,
};
}
export default compose(
withRouter,
withErrorHandler({ name: 'SearchForm' }),
connect(mapStateToProps),
translate({ withRef: true }),
)(SearchFormBase);

Просмотреть файл

@ -0,0 +1,76 @@
@import "~core/css/inc/mixins";
@import "~amo/css/inc/vars";
@import "~ui/css/vars";
$item-padding-horizontal: 23px;
$item-padding: 5px $item-padding-horizontal;
$arrow-width: 18px;
$addon-icon-margin-end: 12px;
.SearchForm-form {
background: $header-search-color;
border: 0;
}
.SearchInput-input,
.SearchForm-form {
color: $base-color;
font-size: $font-size-m-smaller;
white-space: nowrap;
}
.SearchForm-suggestions-list {
background-color: $white;
color: $black;
margin: 0;
padding: 0;
}
.SearchForm-suggestions-item {
cursor: pointer;
display: block;
outline: none;
padding: $item-padding;
width: 100%;
.Icon-arrow-big-blue {
display: none;
height: 16px;
margin-top: 1px;
width: $arrow-width;
}
}
.SearchForm-suggestions-item:hover,
.SearchForm-suggestions-item:active,
.SearchForm-suggestions-item:focus,
.SearchForm-suggestions-item--highlighted {
color: $link-color;
white-space: normal;
.Icon-arrow-big-blue {
display: block;
}
}
.Suggestion {
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 0;
padding: 0;
}
.Suggestion-name {
flex: 1;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Suggestion-icon {
@include margin-end($addon-icon-margin-end);
height: 16px;
width: 16px;
}

Просмотреть файл

@ -1,3 +1,6 @@
import { getAddonIconUrl } from 'core/imageUtils';
export const AUTOCOMPLETE_LOADED = 'AUTOCOMPLETE_LOADED';
export const AUTOCOMPLETE_STARTED = 'AUTOCOMPLETE_STARTED';
export const AUTOCOMPLETE_CANCELLED = 'AUTOCOMPLETE_CANCELLED';
@ -53,10 +56,14 @@ export default function reducer(state = initialState, action = {}) {
...state,
loading: false,
suggestions: payload.results
// TODO: Remove this when `null` names are not returned. See:
// https://github.com/mozilla/addons-server/issues/6189
// TODO: Remove this when `null` names are not returned. See:
// https://github.com/mozilla/addons-server/issues/6189
.filter((result) => result.name !== null)
.map((result) => result.name),
.map((result) => ({
name: result.name,
url: result.url,
iconUrl: getAddonIconUrl(result),
})),
};
default:
return state;

Просмотреть файл

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="19px" height="18px" viewBox="0 0 19 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
<title>Icon/Arrow/Big/Blue</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Specs" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="Header-Copy" transform="translate(-1454.000000, -1387.000000)" stroke="#0A84FF" stroke-width="2">
<g id="Group-3" transform="translate(1100.000000, 1265.000000)">
<g id="Icon/Arrow/Big/Blue" transform="translate(355.000000, 123.000000)">
<path d="M0,8 L15.6923077,8" id="Shape"></path>
<polyline id="Shape" points="8.71794872 0 16.5641026 8 8.71794872 16"></polyline>
</g>
</g>
</g>
</g>
</svg>

После

Ширина:  |  Высота:  |  Размер: 1008 B

Просмотреть файл

@ -119,3 +119,14 @@
.Icon-restart {
@include icon($name: "restart");
}
.Icon-arrow-big-blue {
@include icon($name: "arrow-big-blue");
width: 18px;
[dir=rtl] & {
right: auto;
transform: rotate(180deg);
}
}

Просмотреть файл

@ -9,15 +9,26 @@ import './style.scss';
export default class SearchInput extends React.Component {
static propTypes = {
className: PropTypes.string,
defaultValue: PropTypes.string,
name: PropTypes.string.isRequired,
className: PropTypes.string,
value: PropTypes.string,
onBlur: PropTypes.func,
onChange: PropTypes.func,
onFocus: PropTypes.func,
onKeyDown: PropTypes.func,
placeholder: PropTypes.string,
type: PropTypes.string,
inputRef: PropTypes.func,
}
static defaultProps = {
type: 'search',
value: '',
};
constructor(props) {
super(props);
this.state = { animating: false, focus: false, value: props.defaultValue };
this.state = { animating: false, focus: false, value: props.value };
}
componentDidMount() {
@ -25,20 +36,38 @@ export default class SearchInput extends React.Component {
window.addEventListener('resize', this.setIconPosition);
}
componentWillReceiveProps(nextProps) {
const { value } = this.props;
if (nextProps.value !== value) {
this.setState({ value: nextProps.value });
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.setIconPosition);
}
onBlur = () => {
this.setState({ focus: false });
onBlur = (e) => {
this.setState({ focus: false }, () => {
if (this.props.onBlur) {
this.props.onBlur(e);
}
});
if (!this.value) {
// There is no transitionstart event, but animation will start if there is no value.
this.setState({ animating: true });
}
}
onFocus = () => {
this.setState({ focus: true });
onFocus = (e) => {
this.setState({ focus: true }, () => {
if (this.props.onFocus) {
this.props.onFocus(e);
}
});
if (!this.value) {
// There is no transitionstart event, but animation will start if there is no value.
this.setState({ animating: true });
@ -49,16 +78,21 @@ export default class SearchInput extends React.Component {
this.onTransitionEndTimeout = window.setTimeout(this.onTransitionEnd, 275);
}
onInput = (e) => {
this.setState({ value: e.target.value });
}
onTransitionEnd = () => {
if (this.onTransitionEndTimeout) {
window.clearTimeout(this.onTransitionEndTimeout);
}
this.setState({ animating: false });
};
}
onChange = (e) => {
e.persist();
this.setState({ value: e.target.value }, () => {
if (this.props.onChange) {
this.props.onChange(e);
}
});
}
setIconPosition = () => {
if (!this.animateLeft) {
@ -68,14 +102,24 @@ export default class SearchInput extends React.Component {
this.animateIcon.style.transform = `translateX(${labelLeft - this.animateLeft}px)`;
}
setInputRef = (el) => {
this.input = el;
if (this.props.inputRef) {
this.props.inputRef(el);
}
}
get value() {
return this.input.value;
return this.state.value;
}
render() {
const { className, name, placeholder, ...props } = this.props;
const { className, name, placeholder, type } = this.props;
const { onKeyDown } = this.props;
const { animating, focus, value } = this.state;
const id = `SearchInput-input-${name}`;
return (
<div
className={classNames(className, 'SearchInput', {
@ -99,16 +143,18 @@ export default class SearchInput extends React.Component {
{placeholder}
</label>
<input
{...props}
autoComplete="off"
className="SearchInput-input"
id={id}
name={name}
value={value}
type={type}
onBlur={this.onBlur}
onFocus={this.onFocus}
onInput={this.onInput}
onChange={this.onChange}
onKeyDown={onKeyDown}
placeholder={placeholder}
ref={(el) => { this.input = el; }}
ref={this.setInputRef}
/>
</div>
);

Просмотреть файл

@ -1,179 +1,410 @@
import React from 'react';
import { Simulate, renderIntoDocument } from 'react-addons-test-utils';
import { mount } from 'enzyme';
import { setViewContext } from 'amo/actions/viewContext';
import {
SearchFormBase,
mapStateToProps,
} from 'amo/components/SearchForm';
import Suggestion from 'amo/components/SearchForm/Suggestion';
import LoadingText from 'ui/components/LoadingText';
import {
ADDON_TYPE_EXTENSION,
ADDON_TYPE_THEME,
VIEW_CONTEXT_HOME,
} from 'core/constants';
import { dispatchSignInActions } from 'tests/unit/amo/helpers';
import { getFakeI18nInst } from 'tests/unit/helpers';
import {
createFakeAutocompleteResult,
dispatchAutocompleteResults,
dispatchSignInActions,
} from 'tests/unit/amo/helpers';
import {
createFakeEvent,
createStubErrorHandler,
getFakeI18nInst,
} from 'tests/unit/helpers';
import {
autocompleteCancel,
autocompleteStart,
} from 'core/reducers/autocomplete';
describe('<SearchForm />', () => {
describe(__filename, () => {
const pathname = '/search/';
const api = { clientApp: 'firefox', lang: 'de' };
let errorHandler;
let router;
let root;
let form;
let input;
class SearchFormWrapper extends React.Component {
static childContextTypes = {
router: React.PropTypes.object,
}
const mountComponent = (props = {}) => {
return mount(
<SearchFormBase
pathname={pathname}
api={api}
query="foo"
i18n={getFakeI18nInst()}
loadingSuggestions={false}
suggestions={[]}
errorHandler={errorHandler}
dispatch={() => {}}
router={router}
debounce={(callback) => (...args) => callback(...args)}
{...props}
/>
);
};
getChildContext() {
return { router };
}
const createFakeChangeEvent = (value = '') => {
return createFakeEvent({
target: { value },
});
};
render() {
return (
<SearchFormBase
pathname={pathname}
api={api}
query="foo"
ref={(ref) => { this.root = ref; }}
i18n={getFakeI18nInst()}
{...this.props}
/>
);
}
}
describe('render/UI', () => {
beforeEach(() => {
router = { push: sinon.spy() };
errorHandler = createStubErrorHandler();
});
beforeEach(() => {
router = { push: sinon.spy() };
root = renderIntoDocument(<SearchFormWrapper />).root;
form = root.form;
input = root.searchQuery.input;
});
it('renders a form', () => {
const wrapper = mountComponent();
it('renders a form', () => {
expect(form.classList.contains('SearchForm-form')).toBeTruthy();
});
expect(wrapper.find('.SearchForm-form')).toHaveLength(1);
});
it('renders a search input with Explore placeholder', () => {
expect(input.placeholder).toEqual('Search extensions and themes');
expect(input.type).toEqual('search');
});
it('renders a search input with Explore placeholder', () => {
const wrapper = mountComponent();
const input = wrapper.find('input');
it('renders Extensions placeholder', () => {
root = renderIntoDocument(
<SearchFormWrapper addonType={ADDON_TYPE_EXTENSION} />).root;
input = root.searchQuery.input;
expect(input).toHaveProp('placeholder', 'Search extensions and themes');
expect(input).toHaveProp('type', 'search');
});
expect(input.placeholder).toEqual('Search extensions');
expect(input.type).toEqual('search');
});
it('renders Extensions placeholder', () => {
const wrapper = mountComponent({
addonType: ADDON_TYPE_EXTENSION,
});
const input = wrapper.find('input');
it('renders Themes placeholder', () => {
root = renderIntoDocument(
<SearchFormWrapper addonType={ADDON_TYPE_THEME} />).root;
input = root.searchQuery.input;
expect(input).toHaveProp('placeholder', 'Search extensions');
expect(input).toHaveProp('type', 'search');
});
expect(input.placeholder).toEqual('Search themes');
expect(input.type).toEqual('search');
});
it('renders Themes placeholder', () => {
const wrapper = mountComponent({
addonType: ADDON_TYPE_THEME,
});
const input = wrapper.find('input');
it('renders the query', () => {
expect(input.value).toEqual('foo');
});
expect(input).toHaveProp('placeholder', 'Search themes');
expect(input).toHaveProp('type', 'search');
});
it('changes the URL on submit', () => {
sinon.assert.notCalled(router.push);
input.value = 'adblock';
Simulate.submit(form);
sinon.assert.called(router.push);
});
it('renders the query', () => {
const wrapper = mountComponent();
it('blurs the form on submit', () => {
const blurSpy = sinon.stub(input, 'blur');
expect(!blurSpy.called).toBeTruthy();
input.value = 'something';
Simulate.submit(form);
sinon.assert.called(blurSpy);
});
expect(wrapper.find('input').prop('value')).toEqual('foo');
});
it('does nothing on non-Enter keydowns', () => {
sinon.assert.notCalled(router.push);
input.value = 'adblock';
Simulate.keyDown(input, { key: 'A', shiftKey: true });
sinon.assert.notCalled(router.push);
});
it('changes the URL on submit', () => {
const wrapper = mountComponent();
it('updates the location on form submit', () => {
sinon.assert.notCalled(router.push);
input.value = 'adblock';
Simulate.click(root.submitButton);
sinon.assert.called(router.push);
});
sinon.assert.notCalled(router.push);
wrapper.find('input').simulate('change', createFakeChangeEvent('adblock'));
wrapper.find('form').simulate('submit');
sinon.assert.called(router.push);
});
it('passes addonType when set', () => {
root = renderIntoDocument(
<SearchFormWrapper addonType={ADDON_TYPE_EXTENSION} />
).root;
form = root.form;
input = root.searchQuery.input;
it('blurs the form on submit', () => {
const wrapper = mountComponent();
const blurSpy = sinon.stub(wrapper.instance().searchInput, 'blur');
sinon.assert.notCalled(router.push);
input.value = '& 26 %';
Simulate.click(root.submitButton);
sinon.assert.calledWith(router.push, {
pathname: '/de/firefox/search/',
query: { q: '& 26 %', type: ADDON_TYPE_EXTENSION },
sinon.assert.notCalled(blurSpy);
wrapper.find('input').simulate('change', createFakeChangeEvent('something'));
wrapper.find('form').simulate('submit');
sinon.assert.called(blurSpy);
});
it('does nothing on non-Enter keydowns', () => {
const wrapper = mountComponent();
sinon.assert.notCalled(router.push);
wrapper.find('input').simulate('change', createFakeChangeEvent('adblock'));
wrapper.find('input').simulate('keydown', { key: 'A', shiftKey: true });
sinon.assert.notCalled(router.push);
});
it('updates the location on form submit', () => {
const wrapper = mountComponent();
sinon.assert.notCalled(router.push);
wrapper.find('input').simulate('change', createFakeChangeEvent('adblock'));
wrapper.find('button').simulate('click');
sinon.assert.called(router.push);
});
it('passes addonType when set', () => {
const wrapper = mountComponent({
addonType: ADDON_TYPE_EXTENSION,
});
sinon.assert.notCalled(router.push);
wrapper.find('input').simulate('change', createFakeChangeEvent('& 26 %'));
wrapper.find('button').simulate('click');
sinon.assert.calledWith(router.push, {
pathname: '/de/firefox/search/',
query: { q: '& 26 %', type: ADDON_TYPE_EXTENSION },
});
});
it('does not set type when it is not defined', () => {
const wrapper = mountComponent();
sinon.assert.notCalled(router.push);
wrapper.find('input').simulate('change', createFakeChangeEvent('searching'));
wrapper.find('button').simulate('click');
sinon.assert.calledWith(router.push, {
pathname: '/de/firefox/search/',
query: { q: 'searching' },
});
});
it('encodes the value of the search text', () => {
const wrapper = mountComponent();
sinon.assert.notCalled(router.push);
wrapper.find('input').simulate('change', createFakeChangeEvent('& 26 %'));
wrapper.find('button').simulate('click');
sinon.assert.calledWith(router.push, {
pathname: '/de/firefox/search/',
query: { q: '& 26 %' },
});
});
it('updates the state when props update', () => {
const wrapper = mountComponent({ query: '' });
expect(wrapper.state('searchValue')).toEqual('');
wrapper.setProps({ query: 'foo' });
expect(wrapper.state('searchValue')).toEqual('foo');
});
it('updates the state when user is typing', () => {
const wrapper = mountComponent({ query: '' });
expect(wrapper.state('searchValue')).toEqual('');
wrapper.find('input').simulate('change', createFakeChangeEvent('foo'));
expect(wrapper.state('searchValue')).toEqual('foo');
wrapper.find('input').simulate('change', createFakeChangeEvent(undefined));
expect(wrapper.state('searchValue')).toEqual('');
});
it('fetches suggestions on focus', () => {
const dispatch = sinon.spy();
const wrapper = mountComponent({
query: 'foo',
dispatch,
});
// Expect no call to to handleSuggestionsFetchRequested() until the input
// has focus, even if there is already a `searchValue`
sinon.assert.notCalled(dispatch);
// This is needed to trigger handleSuggestionsFetchRequested()
wrapper.find('input').simulate('focus');
sinon.assert.callCount(dispatch, 1);
sinon.assert.calledWith(dispatch, autocompleteStart({
errorHandlerId: errorHandler.id,
filters: {
query: 'foo',
},
}));
});
it('clears suggestions when input is cleared', () => {
const dispatch = sinon.spy();
const wrapper = mountComponent({
query: 'foo',
dispatch,
});
// clearing the input calls handleSuggestionsClearRequested()
wrapper.find('input').simulate('change', createFakeChangeEvent());
sinon.assert.callCount(dispatch, 1);
sinon.assert.calledWith(dispatch, autocompleteCancel());
});
it('displays suggestions when user is typing', () => {
const { store } = dispatchAutocompleteResults({ results: [
createFakeAutocompleteResult(),
createFakeAutocompleteResult(),
] });
const { autocomplete: autocompleteState } = store.getState();
const wrapper = mountComponent({
query: 'foo',
suggestions: autocompleteState.suggestions,
});
expect(wrapper.find(Suggestion)).toHaveLength(0);
// this triggers Autosuggest
wrapper.find('input').simulate('focus');
expect(wrapper.find(Suggestion)).toHaveLength(2);
expect(wrapper.find(LoadingText)).toHaveLength(0);
});
it('does not display suggestions when search is empty', () => {
const { store } = dispatchAutocompleteResults({ results: [
createFakeAutocompleteResult(),
createFakeAutocompleteResult(),
] });
const { autocomplete: autocompleteState } = store.getState();
// setting the `query` prop to empty also sets the input state to empty.
const wrapper = mountComponent({
query: '',
suggestions: autocompleteState.suggestions,
});
wrapper.find('input').simulate('focus');
expect(wrapper.find(Suggestion)).toHaveLength(0);
});
it('does not display suggestions when there is no suggestion', () => {
const wrapper = mountComponent({ suggestions: [] });
wrapper.find('input').simulate('focus');
expect(wrapper.find(Suggestion)).toHaveLength(0);
expect(wrapper.find(LoadingText)).toHaveLength(0);
});
it('displays 10 loading bars when suggestions are loading', () => {
const { store } = dispatchSignInActions();
store.dispatch(autocompleteStart({
errorHandlerId: errorHandler.id,
filters: { query: 'test' },
}));
const wrapper = mountComponent(mapStateToProps(store.getState()));
wrapper.find('input').simulate('focus');
expect(wrapper.find(LoadingText)).toHaveLength(10);
});
it('updates the state and push a new route when a suggestion is selected', () => {
const result = createFakeAutocompleteResult();
const { store } = dispatchAutocompleteResults({ results: [result] });
const { autocomplete: autocompleteState } = store.getState();
const wrapper = mountComponent({
query: 'foo',
suggestions: autocompleteState.suggestions,
});
expect(wrapper.state('searchValue')).toEqual('foo');
wrapper.find('input').simulate('focus');
wrapper.find(Suggestion).simulate('click');
expect(wrapper.state('searchValue')).toEqual('');
sinon.assert.callCount(router.push, 1);
sinon.assert.calledWith(router.push, result.url);
});
it('ignores loading suggestions that are selected', () => {
const result = createFakeAutocompleteResult();
const { store } = dispatchAutocompleteResults({ results: [result] });
const { autocomplete: autocompleteState } = store.getState();
const wrapper = mountComponent({ query: 'baz' });
expect(wrapper.state('searchValue')).toEqual('baz');
wrapper.instance().handleSuggestionSelected(createFakeEvent(), {
suggestion: {
...autocompleteState.suggestions[0],
loading: true,
},
});
expect(wrapper.state('searchValue')).toEqual('baz');
sinon.assert.notCalled(router.push);
});
it('does not fetch suggestions when there is not value', () => {
const dispatch = sinon.spy();
const wrapper = mountComponent({ dispatch });
wrapper.instance().handleSuggestionsFetchRequested({});
sinon.assert.notCalled(dispatch);
});
it('adds addonType to the filters used to fetch suggestions', () => {
const dispatch = sinon.spy();
const wrapper = mountComponent({
query: 'ad',
addonType: ADDON_TYPE_EXTENSION,
dispatch,
});
wrapper.find('input').simulate('focus');
sinon.assert.callCount(dispatch, 1);
sinon.assert.calledWith(dispatch, autocompleteStart({
errorHandlerId: errorHandler.id,
filters: {
query: 'ad',
addonType: ADDON_TYPE_EXTENSION,
},
}));
});
});
it('does not set type when it is not defined', () => {
sinon.assert.notCalled(router.push);
input.value = 'searching';
Simulate.click(root.submitButton);
sinon.assert.calledWith(router.push, {
pathname: '/de/firefox/search/',
query: { q: 'searching' },
});
});
describe('mapStateToProps', () => {
it('passes the api through', () => {
const { store } = dispatchSignInActions();
it('encodes the value of the search text', () => {
sinon.assert.notCalled(router.push);
input.value = '& 26 %';
Simulate.click(root.submitButton);
sinon.assert.calledWith(router.push, {
pathname: '/de/firefox/search/',
query: { q: '& 26 %' },
const state = store.getState();
expect(mapStateToProps(state).api).toEqual(state.api);
});
it('passes the context through', () => {
const { store } = dispatchSignInActions();
store.dispatch(setViewContext(ADDON_TYPE_EXTENSION));
const state = store.getState();
expect(mapStateToProps(state).addonType).toEqual(ADDON_TYPE_EXTENSION);
});
it('does not set addonType if context is not a validAddonType', () => {
const { store } = dispatchSignInActions();
store.dispatch(setViewContext(VIEW_CONTEXT_HOME));
const state = store.getState();
expect(mapStateToProps(state).addonType).toEqual(null);
});
it('passes the suggestions through', () => {
const result = createFakeAutocompleteResult();
const { store } = dispatchAutocompleteResults({ results: [result] });
const state = store.getState();
expect(mapStateToProps(state).suggestions).toEqual([
{
iconUrl: result.icon_url,
name: result.name,
url: result.url,
},
]);
expect(mapStateToProps(state).loadingSuggestions).toEqual(false);
});
it('passes the loading suggestions boolean through', () => {
const { store } = dispatchSignInActions();
store.dispatch(autocompleteStart({
errorHandlerId: errorHandler.id,
filters: { query: 'test' },
}));
const state = store.getState();
expect(mapStateToProps(state).loadingSuggestions).toEqual(true);
});
});
});
describe('SearchForm mapStateToProps', () => {
it('passes the api through', () => {
const { store } = dispatchSignInActions();
const state = store.getState();
expect(mapStateToProps(state).api).toEqual(state.api);
});
it('passes the context through', () => {
const { store } = dispatchSignInActions();
store.dispatch(setViewContext(ADDON_TYPE_EXTENSION));
const state = store.getState();
expect(mapStateToProps(state).addonType).toEqual(ADDON_TYPE_EXTENSION);
});
it('does not set addonType if context is not a validAddonType', () => {
const { store } = dispatchSignInActions();
store.dispatch(setViewContext(VIEW_CONTEXT_HOME));
const state = store.getState();
expect(mapStateToProps(state).addonType).toEqual(null);
});
});

Просмотреть файл

@ -0,0 +1,45 @@
import React from 'react';
import { shallow } from 'enzyme';
import Suggestion from 'amo/components/SearchForm/Suggestion';
import Icon from 'ui/components/Icon';
import LoadingText from 'ui/components/LoadingText';
import { fakeAddon } from 'tests/unit/amo/helpers';
describe(__filename, () => {
const shallowComponent = (props = {}) => {
const allProps = {
name: fakeAddon.name,
iconUrl: fakeAddon.icon_url,
loading: false,
...props,
};
return shallow(<Suggestion {...allProps} />);
};
it('renders itself', () => {
const root = shallowComponent();
expect(root.find('.Suggestion')).toHaveLength(1);
expect(root.find(Icon)).toHaveLength(1);
expect(root.find(LoadingText)).toHaveLength(0);
});
it('can pass a alt text to the arrow icon', () => {
const props = { arrowAlt: 'go to add-on' };
const root = shallowComponent(props);
expect(root.find(Icon)).toHaveLength(1);
expect(root.find(Icon)).toHaveProp('alt', props.arrowAlt);
});
it('displays a loading indicator when loading prop is true', () => {
const props = { loading: true };
const root = shallowComponent(props);
expect(root.find(Icon)).toHaveLength(1);
expect(root.find(LoadingText)).toHaveLength(1);
});
});

Просмотреть файл

@ -1,4 +1,5 @@
import { normalize } from 'normalizr';
import config from 'config';
import createStore from 'amo/store';
import {
@ -7,9 +8,13 @@ import {
import { addon as addonSchema } from 'core/api';
import { ADDON_TYPE_THEME, CLIENT_APP_FIREFOX } from 'core/constants';
import { searchLoad, searchStart } from 'core/actions/search';
import { autocompleteLoad, autocompleteStart } from 'core/reducers/autocomplete';
import {
userAuthToken, sampleUserAgent, signedInApiState as coreSignedInApiState,
createStubErrorHandler,
userAuthToken,
sampleUserAgent,
signedInApiState as coreSignedInApiState,
} from '../helpers';
export const fakeAddon = Object.freeze({
@ -123,7 +128,10 @@ export function dispatchSearchResults({
filters = { query: 'test' },
store = dispatchClientMetadata().store,
} = {}) {
store.dispatch(searchStart({ errorHandlerId: 'some-error', filters }));
store.dispatch(searchStart({
errorHandlerId: createStubErrorHandler().id,
filters,
}));
store.dispatch(searchLoad({
entities: { addons },
result: {
@ -144,7 +152,7 @@ export function createAddonsApiResult(results) {
export function createFakeAutocompleteResult({ name = 'suggestion-result' } = {}) {
return {
id: Date.now(),
icon_url: `https://example.org/${name}.png`,
icon_url: `${config.get('amoCDN')}/${name}.png`,
name,
url: `https://example.org/en-US/firefox/addons/${name}/`,
};
@ -159,3 +167,17 @@ export function createFakeAddon({ files = {} } = {}) {
},
};
}
export function dispatchAutocompleteResults({
filters = { query: 'test' },
store = dispatchClientMetadata().store,
results = [],
} = {}) {
store.dispatch(autocompleteStart({
errorHandlerId: createStubErrorHandler().id,
filters,
}));
store.dispatch(autocompleteLoad({ results }));
return { store };
}

Просмотреть файл

@ -43,7 +43,25 @@ describe(__filename, () => {
const { loading, suggestions } = reducer(undefined, autocompleteLoad({ results }));
expect(loading).toBe(false);
expect(suggestions).toEqual(['foo', 'bar', 'baz']);
expect(suggestions).toHaveLength(3);
expect(suggestions[0]).toHaveProperty('name', 'foo');
expect(suggestions[1]).toHaveProperty('name', 'bar');
expect(suggestions[2]).toHaveProperty('name', 'baz');
});
it('sets the icon_url as iconUrl', () => {
const result = createFakeAutocompleteResult({ name: 'baz' });
const results = [result];
const { loading, suggestions } = reducer(undefined, autocompleteLoad({ results }));
expect(loading).toBe(false);
expect(suggestions).toEqual([
{
name: result.name,
url: result.url,
iconUrl: result.icon_url,
},
]);
});
it('excludes AUTOCOMPLETE_LOADED results with null names', () => {
@ -55,7 +73,7 @@ describe(__filename, () => {
const { loading, suggestions } = reducer(undefined, autocompleteLoad({ results }));
expect(loading).toBe(false);
expect(suggestions).toEqual(['foo', 'baz']);
expect(suggestions).toHaveLength(2);
});
});

Просмотреть файл

@ -1,6 +1,7 @@
/* global window */
import React from 'react';
import { renderIntoDocument, Simulate } from 'react-addons-test-utils';
import { shallow } from 'enzyme';
import { assertHasClass, assertNotHasClass } from 'tests/unit/helpers';
import SearchInput from 'ui/components/SearchInput';
@ -29,20 +30,20 @@ describe('<SearchInput />', () => {
const removeEventListener = sinon.stub(window, 'removeEventListener');
const root = renderIntoDocument(<SearchInput name="foo" />);
expect(addEventListener.calledWith('resize', root.setIconPosition)).toBeTruthy();
expect(removeEventListener.called).toBeFalsy();
sinon.assert.notCalled(removeEventListener);
root.componentWillUnmount();
expect(removeEventListener.calledWith('resize', root.setIconPosition)).toBeTruthy();
sinon.assert.calledWith(removeEventListener, 'resize', root.setIconPosition);
});
it('starts with the --text class with a defaultValue', () => {
const root = renderIntoDocument(<SearchInput name="foo" defaultValue="wat" />);
it('starts with the --text class with a value', () => {
const root = renderIntoDocument(<SearchInput name="foo" value="wat" />);
assertHasClass(root.root, 'SearchInput--text');
});
it('sets the value in state on input', () => {
it('sets the value in state on change', () => {
const root = renderIntoDocument(<SearchInput name="foo" />);
expect(root.state.value).toEqual(undefined);
Simulate.input(root.input, { target: { value: 'test' } });
expect(root.state.value).toEqual('');
Simulate.change(root.input, { target: { value: 'test' } });
expect(root.state.value).toEqual('test');
});
@ -56,7 +57,7 @@ describe('<SearchInput />', () => {
});
it('keeps the --text class on blur when it has text', () => {
const root = renderIntoDocument(<SearchInput name="foo" defaultValue="Hello" />);
const root = renderIntoDocument(<SearchInput name="foo" value="Hello" />);
assertHasClass(root.root, 'SearchInput--text');
Simulate.blur(root.input);
assertHasClass(root.root, 'SearchInput--text');
@ -87,7 +88,6 @@ describe('<SearchInput />', () => {
it('only adds the --text class on focus with a value', () => {
const root = renderIntoDocument(<SearchInput name="foo" value="hey" />);
assertNotHasClass(root.root, 'SearchInput--text');
assertNotHasClass(root.root, 'SearchInput--animating');
Simulate.focus(root.input);
assertHasClass(root.root, 'SearchInput--text');
@ -112,7 +112,42 @@ describe('<SearchInput />', () => {
});
it('exposes the value of the input', () => {
const root = renderIntoDocument(<SearchInput name="foo" defaultValue="yo" />);
const root = renderIntoDocument(<SearchInput name="foo" value="yo" />);
expect(root.value).toEqual('yo');
});
it('forwards the onFocus event', () => {
const spy = sinon.spy();
const root = renderIntoDocument(<SearchInput name="foo" onFocus={spy} />);
Simulate.focus(root.input);
sinon.assert.callCount(spy, 1);
});
it('forwards the onBlur event', () => {
const spy = sinon.spy();
const root = renderIntoDocument(<SearchInput name="foo" onBlur={spy} />);
Simulate.blur(root.input);
sinon.assert.callCount(spy, 1);
});
it('gives the input element to the inputRef prop', () => {
const spy = sinon.spy();
const root = renderIntoDocument(<SearchInput name="foo" inputRef={spy} />);
sinon.assert.callCount(spy, 1);
sinon.assert.calledWith(spy, root.input);
});
it('forwards the onChange event', () => {
const spy = sinon.spy();
const root = renderIntoDocument(<SearchInput name="foo" onChange={spy} />);
Simulate.change(root.input);
sinon.assert.callCount(spy, 1);
});
it('updates state when it receives new props', () => {
const root = shallow(<SearchInput name="foo" />);
expect(root.state().value).toBe('');
root.setProps({ value: 'new-value' });
expect(root.state().value).toBe('new-value');
});
});

Просмотреть файл

@ -5500,6 +5500,10 @@ oauth-sign@~0.8.1:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
object-assign@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2"
object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@ -6294,7 +6298,7 @@ promisify-node@^0.4.0:
nodegit-promise "~4.0.0"
object-assign "^4.0.1"
prop-types@15.5.10, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7:
prop-types@15.5.10, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.5.8:
version "15.5.10"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
dependencies:
@ -6438,6 +6442,22 @@ react-addons-test-utils@^15.5.1:
version "15.6.0"
resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.6.0.tgz#062d36117fe8d18f3ba5e06eb33383b0b85ea5b9"
react-autosuggest@9.3.2:
version "9.3.2"
resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.3.2.tgz#dd8c0fbe9c25aa94afe296180353647f6ecc10a7"
dependencies:
prop-types "^15.5.10"
react-autowhatever "^10.1.0"
shallow-equal "^1.0.0"
react-autowhatever@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.1.0.tgz#41f6d69382437d3447a0a3c8913bb8ca2feaabc1"
dependencies:
prop-types "^15.5.8"
react-themeable "^1.1.0"
section-iterator "^2.0.0"
react-cookie@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/react-cookie/-/react-cookie-1.0.5.tgz#234190bd55ddfea361444a89c873077ab6abf651"
@ -6560,6 +6580,12 @@ react-test-renderer@^15.5.4:
fbjs "^0.8.9"
object-assign "^4.1.0"
react-themeable@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e"
dependencies:
object-assign "^3.0.0"
react-transform-hmr@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/react-transform-hmr/-/react-transform-hmr-1.0.4.tgz#e1a40bd0aaefc72e8dfd7a7cda09af85066397bb"
@ -7062,6 +7088,10 @@ scss-tokenizer@^0.2.3:
js-base64 "^2.1.8"
source-map "^0.4.2"
section-iterator@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a"
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
@ -7200,6 +7230,10 @@ shallow-clone@^0.1.2:
lazy-cache "^0.2.3"
mixin-object "^2.0.1"
shallow-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7"
shallowequal@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.0.1.tgz#4349160418200bad3b82d723ded65f2354db2a23"