Search hints/autocomplete UI (#2971)
* Implement UI for autocomplete feature * Debounce autocompleteStart() * Fix AMO_CDN env var in dev
This commit is contained in:
Родитель
9eb45fcb3c
Коммит
9fb119bf60
|
@ -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');
|
||||
});
|
||||
});
|
36
yarn.lock
36
yarn.lock
|
@ -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"
|
||||
|
|
Загрузка…
Ссылка в новой задаче