packages/web/src/components/search/DataSearch.js
import React, { Component } from 'react';
import Downshift from 'downshift';
import { withTheme } from 'emotion-theming';
import {
addComponent,
removeComponent,
watchComponent,
updateQuery,
setQueryOptions,
setQueryListener,
} from '@appbaseio/reactivecore/lib/actions';
import {
debounce,
pushToAndClause,
checkValueChange,
checkPropChange,
checkSomePropChange,
getClassName,
} from '@appbaseio/reactivecore/lib/utils/helper';
import types from '@appbaseio/reactivecore/lib/utils/types';
import getSuggestions from '@appbaseio/reactivecore/lib/utils/suggestions';
import causes from '@appbaseio/reactivecore/lib/utils/causes';
import Title from '../../styles/Title';
import Input, { suggestionsContainer, suggestions, noSuggestions } from '../../styles/Input';
import SearchSvg from '../shared/SearchSvg';
import CancelSvg from '../shared/CancelSvg';
import InputIcon from '../../styles/InputIcon';
import Container from '../../styles/Container';
import { connect } from '../../utils';
class DataSearch extends Component {
constructor(props) {
super(props);
this.state = {
currentValue: '',
suggestions: [],
isOpen: false,
};
this.internalComponent = `${props.componentId}__internal`;
this.locked = false;
props.setQueryListener(props.componentId, props.onQueryChange, null);
}
componentWillMount() {
this.props.addComponent(this.props.componentId, 'DATASEARCH');
this.props.addComponent(this.internalComponent);
if (this.props.highlight) {
const queryOptions = DataSearch.highlightQuery(this.props) || {};
queryOptions.size = 20;
this.props.setQueryOptions(this.props.componentId, queryOptions);
} else {
this.props.setQueryOptions(this.props.componentId, {
size: 20,
});
}
this.setReact(this.props);
if (this.props.selectedValue) {
this.setValue(this.props.selectedValue, true);
} else if (this.props.defaultSelected) {
this.setValue(this.props.defaultSelected, true);
}
}
componentWillReceiveProps(nextProps) {
checkSomePropChange(
this.props,
nextProps,
['highlight', 'dataField', 'highlightField'],
() => {
const queryOptions = DataSearch.highlightQuery(nextProps) || {};
queryOptions.size = 20;
this.props.setQueryOptions(nextProps.componentId, queryOptions);
},
);
checkPropChange(this.props.react, nextProps.react, () => this.setReact(nextProps));
if (Array.isArray(nextProps.suggestions) && this.state.currentValue.trim().length) {
// shallow check allows us to set suggestions even if the next set
// of suggestions are same as the current one
if (this.props.suggestions !== nextProps.suggestions) {
this.setState({
suggestions: this.onSuggestions(nextProps.suggestions),
});
}
}
checkSomePropChange(
this.props,
nextProps,
['fieldWeights', 'fuzziness', 'queryFormat', 'dataField', 'nestedField'],
() => {
this.updateQuery(nextProps.componentId, this.state.currentValue, nextProps);
},
);
if (this.props.defaultSelected !== nextProps.defaultSelected) {
this.setValue(nextProps.defaultSelected, true, nextProps);
} else if (
// since, selectedValue will be updated when currentValue changes,
// we must only check for the changes introduced by
// clear action from SelectedFilters component in which case,
// the currentValue will never match the updated selectedValue
this.props.selectedValue !== nextProps.selectedValue
&& this.state.currentValue !== nextProps.selectedValue
) {
this.setValue(nextProps.selectedValue || '', true, nextProps);
}
}
componentWillUnmount() {
this.props.removeComponent(this.props.componentId);
this.props.removeComponent(this.internalComponent);
}
setReact = (props) => {
const { react } = props;
if (react) {
const newReact = pushToAndClause(react, this.internalComponent);
props.watchComponent(props.componentId, newReact);
} else {
props.watchComponent(props.componentId, {
and: this.internalComponent,
});
}
};
static highlightQuery = (props) => {
if (props.customHighlight) {
return props.customHighlight(props);
}
if (!props.highlight) {
return null;
}
const fields = {};
const highlightField = props.highlightField ? props.highlightField : props.dataField;
if (typeof highlightField === 'string') {
fields[highlightField] = {};
} else if (Array.isArray(highlightField)) {
highlightField.forEach((item) => {
fields[item] = {};
});
}
return {
highlight: {
pre_tags: ['<mark>'],
post_tags: ['</mark>'],
fields,
},
};
};
static defaultQuery = (value, props) => {
let finalQuery = null;
let fields;
if (value) {
if (Array.isArray(props.dataField)) {
fields = props.dataField;
} else {
fields = [props.dataField];
}
finalQuery = {
bool: {
should: DataSearch.shouldQuery(value, fields, props),
minimum_should_match: '1',
},
};
}
if (value === '') {
finalQuery = {
match_all: {},
};
}
if (finalQuery && props.nestedField) {
finalQuery = {
nested: {
path: props.nestedField,
query: finalQuery,
},
};
}
return finalQuery;
};
static shouldQuery = (value, dataFields, props) => {
const fields = dataFields.map((field, index) =>
`${field}${
Array.isArray(props.fieldWeights) && props.fieldWeights[index]
? `^${props.fieldWeights[index]}`
: ''
}`);
if (props.queryFormat === 'and') {
return [
{
multi_match: {
query: value,
fields,
type: 'cross_fields',
operator: 'and',
},
},
{
multi_match: {
query: value,
fields,
type: 'phrase_prefix',
operator: 'and',
},
},
];
}
return [
{
multi_match: {
query: value,
fields,
type: 'best_fields',
operator: 'or',
fuzziness: props.fuzziness ? props.fuzziness : 0,
},
},
{
multi_match: {
query: value,
fields,
type: 'phrase_prefix',
operator: 'or',
},
},
];
};
onSuggestions = (results) => {
if (this.props.onSuggestion) {
return results.map(suggestion => this.props.onSuggestion(suggestion));
}
const fields = Array.isArray(this.props.dataField)
? this.props.dataField
: [this.props.dataField];
return getSuggestions(fields, results, this.state.currentValue.toLowerCase());
};
setValue = (value, isDefaultValue = false, props = this.props, cause) => {
// ignore state updates when component is locked
if (props.beforeValueChange && this.locked) {
return;
}
this.locked = true;
const performUpdate = () => {
this.setState(
{
currentValue: value,
suggestions: [],
},
() => {
if (isDefaultValue) {
if (this.props.autosuggest) {
this.setState({
isOpen: false,
});
this.updateQuery(this.internalComponent, value, props);
}
// in case of strict selection only SUGGESTION_SELECT should be able
// to set the query otherwise the value should reset
if (props.strictSelection) {
if (cause === causes.SUGGESTION_SELECT || value === '') {
this.updateQuery(props.componentId, value, props);
} else {
this.setValue('', true);
}
} else {
this.updateQuery(props.componentId, value, props);
}
} else {
// debounce for handling text while typing
this.handleTextChange(value);
}
this.locked = false;
if (props.onValueChange) props.onValueChange(value);
},
);
};
checkValueChange(props.componentId, value, props.beforeValueChange, performUpdate);
};
handleTextChange = debounce((value) => {
if (this.props.autosuggest) {
this.updateQuery(this.internalComponent, value, this.props);
} else {
this.updateQuery(this.props.componentId, value, this.props);
}
}, this.props.debounce);
updateQuery = (componentId, value, props) => {
const {
customQuery, defaultQuery, filterLabel, showFilter, URLParams,
} = props;
// defaultQuery from props is always appended
// regardless of a customQuery
const query = customQuery || DataSearch.defaultQuery;
const queryObject = defaultQuery
? {
bool: {
must: [
...query(value, props),
...defaultQuery(value, props),
],
},
} // prettier-ignore
: query(value, props);
props.updateQuery({
componentId,
query: queryObject,
value,
label: filterLabel,
showFilter,
URLParams,
componentType: 'DATASEARCH',
});
};
handleFocus = (event) => {
this.setState({
isOpen: true,
});
if (this.props.onFocus) {
this.props.onFocus(event);
}
};
clearValue = () => {
this.setValue('', true);
this.onValueSelected(null, causes.CLEAR_VALUE);
};
handleKeyDown = (event, highlightedIndex) => {
// if a suggestion was selected, delegate the handling
// to suggestion handler
if (event.key === 'Enter' && highlightedIndex === null) {
this.setValue(event.target.value, true);
this.onValueSelected(event.target.value, causes.ENTER_PRESS);
}
if (this.props.onKeyDown) {
this.props.onKeyDown(event);
}
};
onInputChange = (e) => {
const { value } = e.target;
if (!this.state.isOpen) {
this.setState({
isOpen: true,
});
}
this.setValue(value);
};
onSuggestionSelected = (suggestion) => {
this.setValue(suggestion.value, true, this.props, causes.SUGGESTION_SELECT);
this.onValueSelected(suggestion.value, causes.SUGGESTION_SELECT, suggestion.source);
};
onValueSelected = (currentValue = this.state.currentValue, ...cause) => {
const { onValueSelected } = this.props;
if (onValueSelected) {
onValueSelected(currentValue, ...cause);
}
};
handleStateChange = (changes) => {
const { isOpen, type } = changes;
if (type === Downshift.stateChangeTypes.mouseUp) {
this.setState({
isOpen,
});
}
};
getBackgroundColor = (highlightedIndex, index) => {
const isDark = this.props.themePreset === 'dark';
if (isDark) {
return highlightedIndex === index ? '#555' : '#424242';
}
return highlightedIndex === index ? '#eee' : '#fff';
};
handleSearchIconClick = () => {
const { currentValue } = this.state;
if (currentValue.trim()) {
this.setValue(currentValue, true);
}
};
renderIcon = () => {
if (this.props.showIcon) {
return this.props.icon || <SearchSvg />;
}
return null;
};
renderCancelIcon = () => {
if (this.props.showClear) {
return this.props.clearIcon || <CancelSvg />;
}
return null;
};
renderIcons = () => (
<div>
{this.state.currentValue
&& this.props.showClear && (
<InputIcon
onClick={this.clearValue}
iconPosition="right"
clearIcon={this.props.iconPosition === 'right'}
>
{this.renderCancelIcon()}
</InputIcon>
)}
<InputIcon
onClick={this.handleSearchIconClick}
iconPosition={this.props.iconPosition}
>
{this.renderIcon()}
</InputIcon>
</div>
);
renderLoader = () => {
const {
loader, isLoading, themePreset, theme,
} = this.props;
const { currentValue } = this.state;
if (isLoading && loader && currentValue) {
return (
<div className={`${noSuggestions(
themePreset,
theme,
)} ${getClassName(this.props.innerClass, 'no-suggestion')}`}
>
<li>{loader}</li>
</div>
);
}
return null;
}
render() {
let suggestionsList = [];
if (
!this.state.currentValue
&& this.props.defaultSuggestions
&& this.props.defaultSuggestions.length
) {
suggestionsList = this.props.defaultSuggestions;
} else if (this.state.currentValue) {
suggestionsList = this.state.suggestions;
}
const { theme, themePreset, renderSuggestions } = this.props;
return (
<Container style={this.props.style} className={this.props.className}>
{this.props.title && (
<Title className={getClassName(this.props.innerClass, 'title') || null}>
{this.props.title}
</Title>
)}
{this.props.defaultSuggestions || this.props.autosuggest ? (
<Downshift
id={`${this.props.componentId}-downshift`}
onChange={this.onSuggestionSelected}
onStateChange={this.handleStateChange}
isOpen={this.state.isOpen}
itemToString={i => i}
render={({
getInputProps, getItemProps, isOpen, highlightedIndex,
}) => (
<div className={suggestionsContainer}>
<Input
id={`${this.props.componentId}-input`}
showIcon={this.props.showIcon}
showClear={this.props.showClear}
iconPosition={this.props.iconPosition}
innerRef={this.props.innerRef}
{...getInputProps({
className: getClassName(this.props.innerClass, 'input'),
placeholder: this.props.placeholder,
value:
this.state.currentValue === null
? ''
: this.state.currentValue,
onChange: this.onInputChange,
onBlur: this.props.onBlur,
onFocus: this.handleFocus,
onKeyPress: this.props.onKeyPress,
onKeyDown: e => this.handleKeyDown(e, highlightedIndex),
onKeyUp: this.props.onKeyUp,
})}
themePreset={themePreset}
/>
{this.renderIcons()}
{renderSuggestions
&& renderSuggestions({
currentValue: this.state.currentValue,
isOpen,
getItemProps,
highlightedIndex,
suggestions: this.props.suggestions,
parsedSuggestions: suggestionsList,
})}
{this.renderLoader()}
{!renderSuggestions && isOpen && suggestionsList.length ? (
<ul
className={`${suggestions(
themePreset,
theme,
)} ${getClassName(this.props.innerClass, 'list')}`}
>
{suggestionsList.slice(0, 10).map((item, index) => (
<li
{...getItemProps({ item })}
key={`${index + 1}-${item.value}`}
style={{
backgroundColor: this.getBackgroundColor(
highlightedIndex,
index,
),
}}
>
{typeof item.label === 'string' ? (
<div
className="trim"
dangerouslySetInnerHTML={{
__html: item.label,
}}
/>
) : (
item.label
)}
</li>
))}
</ul>
) : null}
</div>
)}
{...this.props.downShiftProps}
/>
) : (
<div className={suggestionsContainer}>
<Input
className={getClassName(this.props.innerClass, 'input') || null}
placeholder={this.props.placeholder}
value={this.state.currentValue ? this.state.currentValue : ''}
onChange={this.onInputChange}
onBlur={this.props.onBlur}
onFocus={this.props.onFocus}
onKeyPress={this.props.onKeyPress}
onKeyDown={this.props.onKeyDown}
onKeyUp={this.props.onKeyUp}
autoFocus={this.props.autoFocus}
iconPosition={this.props.iconPosition}
showIcon={this.props.showIcon}
showClear={this.props.showClear}
innerRef={this.props.innerRef}
themePreset={themePreset}
/>
{this.renderIcons()}
</div>
)}
</Container>
);
}
}
DataSearch.propTypes = {
addComponent: types.funcRequired,
removeComponent: types.funcRequired,
setQueryListener: types.funcRequired,
setQueryOptions: types.funcRequired,
updateQuery: types.funcRequired,
watchComponent: types.funcRequired,
options: types.options,
selectedValue: types.selectedValue,
suggestions: types.suggestions,
// component props
autoFocus: types.bool,
autosuggest: types.bool,
beforeValueChange: types.func,
className: types.string,
clearIcon: types.children,
componentId: types.stringRequired,
customHighlight: types.func,
customQuery: types.func,
dataField: types.dataFieldArray,
debounce: types.number,
defaultSelected: types.string,
defaultSuggestions: types.suggestions,
downShiftProps: types.props,
fieldWeights: types.fieldWeights,
filterLabel: types.string,
fuzziness: types.fuzziness,
highlight: types.bool,
highlightField: types.stringOrArray,
icon: types.children,
iconPosition: types.iconPosition,
innerClass: types.style,
innerRef: types.func,
isLoading: types.bool,
loader: types.title,
onBlur: types.func,
onFocus: types.func,
onKeyDown: types.func,
onKeyPress: types.func,
onKeyUp: types.func,
onQueryChange: types.func,
onSuggestion: types.func,
onValueChange: types.func,
onValueSelected: types.func,
placeholder: types.string,
queryFormat: types.queryFormatSearch,
react: types.react,
renderSuggestions: types.func,
showClear: types.bool,
showFilter: types.bool,
showIcon: types.bool,
style: types.style,
title: types.title,
theme: types.style,
themePreset: types.themePreset,
URLParams: types.bool,
strictSelection: types.bool,
};
DataSearch.defaultProps = {
autosuggest: true,
className: null,
debounce: 0,
downShiftProps: {},
iconPosition: 'left',
placeholder: 'Search',
queryFormat: 'or',
showFilter: true,
showIcon: true,
style: {},
URLParams: false,
showClear: false,
strictSelection: false,
};
const mapStateToProps = (state, props) => ({
selectedValue:
(state.selectedValues[props.componentId]
&& state.selectedValues[props.componentId].value)
|| null,
suggestions: state.hits[props.componentId] && state.hits[props.componentId].hits,
themePreset: state.config.themePreset,
isLoading: state.isLoading[props.componentId],
});
const mapDispatchtoProps = dispatch => ({
addComponent: (component, name) => dispatch(addComponent(component, name)),
removeComponent: component => dispatch(removeComponent(component)),
setQueryOptions: (component, props) => dispatch(setQueryOptions(component, props)),
updateQuery: updateQueryObject => dispatch(updateQuery(updateQueryObject)),
watchComponent: (component, react) => dispatch(watchComponent(component, react)),
setQueryListener: (component, onQueryChange, beforeQueryChange) =>
dispatch(setQueryListener(component, onQueryChange, beforeQueryChange)),
});
export default connect(
mapStateToProps,
mapDispatchtoProps,
)(withTheme(DataSearch));