appbaseio/reactivesearch

View on GitHub
packages/vue/src/components/search/DataSearch.jsx

Summary

Maintainability
F
4 days
Test Coverage
import {
    Actions,
    helper,
    suggestions as getSuggestions,
    causes
} from '@appbaseio/reactivecore';
import VueTypes from 'vue-types';
import { connect } from '../../utils/index';
import Title from '../../styles/Title';
import Input, { suggestionsContainer, suggestions } from '../../styles/Input';
import InputIcon from '../../styles/InputIcon';
import Downshift from '../basic/DownShift.jsx';
import Container from '../../styles/Container';
import types from '../../utils/vueTypes';
import SearchSvg from '../shared/SearchSvg';
import CancelSvg from '../shared/CancelSvg';

const {
    addComponent,
    removeComponent,
    watchComponent,
    updateQuery,
    setQueryOptions,
    setQueryListener
} = Actions;
const { debounce, pushToAndClause, checkValueChange, getClassName } = helper;

const DataSearch = {
    name: 'DataSearch',
    data() {
        const props = this.$props;
        this.__state = {
            currentValue: '',
            isOpen: false,
            normalizedSuggestions: []
        };
        this.internalComponent = `${props.componentId}__internal`;
        this.locked = false;
        return this.__state;
    },
    created() {
        this.handleTextChange = debounce(value => {
            if (this.$props.autosuggest) {
                this.updateQueryHandler(this.internalComponent, value, this.$props);
            } else {
                this.updateQueryHandler(this.$props.componentId, value, this.$props);
            }
        }, this.$props.debounce);
        const onQueryChange = (...args) => {
            this.$emit('queryChange', ...args);
        };
        this.setQueryListener(this.$props.componentId, onQueryChange, null);
    },
    computed: {
        suggestionsList() {
            let suggestionsList = [];
            if (
                !this.$data.currentValue
                && this.$props.defaultSuggestions
                && this.$props.defaultSuggestions.length
            ) {
                suggestionsList = this.$props.defaultSuggestions;
            } else if (this.$data.currentValue) {
                suggestionsList = this.normalizedSuggestions;
            }
            return suggestionsList;
        }
    },
    props: {
        options: types.options,
        autoFocus: types.bool,
        autosuggest: VueTypes.bool.def(true),
        beforeValueChange: types.func,
        className: VueTypes.string.def(''),
        clearIcon: types.children,
        componentId: types.stringRequired,
        customHighlight: types.func,
        customQuery: types.func,
        dataField: types.dataFieldArray,
        debounce: VueTypes.number.def(0),
        defaultSelected: types.string,
        defaultSuggestions: types.suggestions,
        fieldWeights: types.fieldWeights,
        filterLabel: types.string,
        fuzziness: types.fuzziness,
        highlight: types.bool,
        highlightField: types.stringOrArray,
        icon: types.children,
        iconPosition: VueTypes.oneOf(['left', 'right']).def('left'),
        innerClass: types.style,
        innerRef: types.func,
        onSuggestion: types.func,
        placeholder: VueTypes.string.def('Search'),
        queryFormat: VueTypes.oneOf(['and', 'or']).def('or'),
        react: types.react,
        showClear: VueTypes.bool.def(true),
        showFilter: VueTypes.bool.def(true),
        showIcon: VueTypes.bool.def(true),
        title: types.title,
        theme: types.style,
        URLParams: VueTypes.bool.def(false),
        strictSelection: VueTypes.bool.def(false)
    },
    beforeMount() {
        this.addComponent(this.$props.componentId, 'DATASEARCH');
        this.addComponent(this.internalComponent);

        if (this.$props.highlight) {
            const queryOptions = DataSearch.highlightQuery(this.$props) || {};
            queryOptions.size = 20;
            this.setQueryOptions(this.$props.componentId, queryOptions);
        } else {
            this.setQueryOptions(this.$props.componentId, {
                size: 20
            });
        }

        this.setReact(this.$props);

        if (this.selectedValue) {
            this.setValue(this.selectedValue, true);
        } else if (this.$props.defaultSelected) {
            this.setValue(this.$props.defaultSelected, true);
        }
    },
    beforeDestroy() {
        this.removeComponent(this.$props.componentId);
        this.removeComponent(this.internalComponent);
    },
    watch: {
        highlight() {
            this.updateQueryOptions();
        },
        dataField() {
            this.updateQueryOptions();
            this.updateQueryHandler(
                this.$props.componentId,
                this.$data.currentValue,
                this.$props
            );
        },
        highlightField() {
            this.updateQueryOptions();
        },
        react() {
            this.setReact(this.$props);
        },
        fieldWeights() {
            this.updateQueryHandler(
                this.$props.componentId,
                this.$data.currentValue,
                this.$props
            );
        },
        fuzziness() {
            this.updateQueryHandler(
                this.$props.componentId,
                this.$data.currentValue,
                this.$props
            );
        },
        queryFormat() {
            this.updateQueryHandler(
                this.$props.componentId,
                this.$data.currentValue,
                this.$props
            );
        },
        defaultSelected(newVal) {
            this.setValue(newVal, true, this.$props);
        },
        suggestions(newVal) {
            if (Array.isArray(newVal) && this.$data.currentValue.trim().length) {
                // shallow check allows us to set suggestions even if the next set
                // of suggestions are same as the current one
                this.normalizedSuggestions = this.onSuggestions(newVal);
            }
        },
        selectedValue(newVal) {
            if (this.selectedValue !== newVal && this.$data.currentValue !== newVal) {
                this.setValue(newVal || '', true, this.$props);
            }
        }
    },
    methods: {
        updateQueryOptions() {
            const queryOptions = DataSearch.highlightQuery(this.$props) || {};
            queryOptions.size = 20;
            this.setQueryOptions(this.$props.componentId, queryOptions);
        },
        setReact(props) {
            const { react } = this.$props;

            if (react) {
                const newReact = pushToAndClause(react, this.internalComponent);
                this.watchComponent(props.componentId, newReact);
            } else {
                this.watchComponent(props.componentId, {
                    and: this.internalComponent
                });
            }
        },
        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.$data.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.currentValue = value;
                this.normalizedSuggestions = [];
                if (isDefaultValue) {
                    if (this.$props.autosuggest) {
                        this.isOpen = false;
                        this.updateQueryHandler(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.updateQueryHandler(props.componentId, value, props);
                        } else {
                            this.setValue('', true);
                        }
                    } else {
                        this.updateQueryHandler(props.componentId, value, props);
                    }
                } else {
                    // debounce for handling text while typing
                    this.handleTextChange(value);
                }

                this.locked = false;
                this.$emit('valueChange', value);
            };

            checkValueChange(
                props.componentId,
                value,
                props.beforeValueChange,
                performUpdate
            );
        },

        updateQueryHandler(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)]
                    }
                  }
                : query(value, props);
            this.updateQuery({
                componentId,
                query: queryObject,
                value,
                label: filterLabel,
                showFilter,
                URLParams,
                componentType: 'DATASEARCH'
            });
        },
        // need to review
        handleFocus(event) {
            this.isOpen = true;

            if (this.$props.onFocus) {
                this.$emit('focus', event);
            }
        },

        clearValue() {
            this.setValue('', true);
            this.onValueSelectedHandler(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.onValueSelectedHandler(event.target.value, causes.ENTER_PRESS);
            }
            // Need to review
            if (this.$props.onKeyDown) {
                this.$emit('keyDown', event);
            }
        },

        onInputChange(e) {
            const { value } = e.target;

            if (!this.$data.isOpen) {
                this.isOpen = true;
            }

            this.setValue(value);
        },

        onSuggestionSelected(suggestion) {
            this.setValue(
                suggestion.value,
                true,
                this.$props,
                causes.SUGGESTION_SELECT
            );
            this.onValueSelectedHandler(
                suggestion.value,
                causes.SUGGESTION_SELECT,
                suggestion.source
            );
        },

        onValueSelectedHandler(currentValue = this.state.currentValue, ...cause) {
            this.$emit('valueSelected', currentValue, ...cause);
        },

        handleStateChange(changes) {
            const { isOpen } = changes;
            this.isOpen = isOpen;
        },

        getBackgroundColor(highlightedIndex, index) {
            const isDark = this.themePreset === 'dark';

            if (isDark) {
                return highlightedIndex === index ? '#555' : '#424242';
            }

            return highlightedIndex === index ? '#eee' : '#fff';
        },

        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() {
            return (
                <div>
                    {this.$data.currentValue
                        && this.$props.showClear && (
                        <InputIcon
                            onClick={this.clearValue}
                            iconPosition="right"
                            clearIcon={this.$props.iconPosition === 'right'}
                        >
                            {this.renderCancelIcon()}
                        </InputIcon>
                    )}
                    <InputIcon iconPosition={this.$props.iconPosition}>
                        {this.renderIcon()}
                    </InputIcon>
                </div>
            );
        }
    },
    render() {
        const { theme } = this.$props;
        const renderSuggestions = this.$scopedSlots.suggestions;
        return (
            <Container class={this.$props.className}>
                {this.$props.title && (
                    <Title class={getClassName(this.$props.innerClass, 'title') || ''}>
                        {this.$props.title}
                    </Title>
                )}
                {this.$props.defaultSuggestions || this.$props.autosuggest ? (
                    <Downshift
                        id={`${this.$props.componentId}-downshift`}
                        handleChange={this.onSuggestionSelected}
                        handleMouseup={this.handleStateChange}
                        isOpen={this.$data.isOpen}
                        scopedSlots={{
                            default: ({
                                getInputEvents,
                                getInputProps,

                                getItemProps,
                                getItemEvents,

                                isOpen,
                                highlightedIndex
                            }) => (
                                <div class={suggestionsContainer}>
                                    <Input
                                        id={`${this.$props.componentId}-input`}
                                        showIcon={this.$props.showIcon}
                                        showClear={this.$props.showClear}
                                        iconPosition={this.$props.iconPosition}
                                        innerRef={this.$props.innerRef}
                                        class={getClassName(this.$props.innerClass, 'input')}
                                        placeholder={this.$props.placeholder}
                                        {...{
                                            on: getInputEvents({
                                                onInput: this.onInputChange,
                                                onBlur: e => {
                                                    this.$emit('blur', e);
                                                },
                                                onFocus: this.handleFocus,
                                                onKeyPress: e => {
                                                    this.$emit('keyPress', e);
                                                },
                                                onKeyDown: e => this.handleKeyDown(e, highlightedIndex),
                                                onKeyUp: e => {
                                                    this.$emit('keyUp', e);
                                                }
                                            })
                                        }}
                                        {...{
                                            domProps: getInputProps({
                                                value:
                                                    this.$data.currentValue === null
                                                        ? ''
                                                        : this.$data.currentValue
                                            })
                                        }}
                                        themePreset={this.themePreset}
                                    />
                                    {this.renderIcons()}
                                    {!renderSuggestions
                                    && isOpen
                                    && this.suggestionsList.length ? (
                                            <ul
                                                class={`${suggestions(
                                                    this.themePreset,
                                                    theme
                                                )} ${getClassName(this.$props.innerClass, 'list')}`}
                                            >
                                                {this.suggestionsList.slice(0, 10).map((item, index) => (
                                                    <li
                                                        {...{
                                                            domProps: getItemProps({ item })
                                                        }}
                                                        {...{
                                                            on: getItemEvents({
                                                                item
                                                            })
                                                        }}
                                                        key={`${index + 1}-${item.value}`}
                                                        style={{
                                                            backgroundColor: this.getBackgroundColor(
                                                                highlightedIndex,
                                                                index
                                                            )
                                                        }}
                                                    >
                                                        {typeof item.label === 'string' ? (
                                                            <div class="trim" domPropsInnerHTML={item.label} />
                                                        ) : (
                                                            item.label
                                                        )}
                                                    </li>
                                                ))}
                                            </ul>
                                        ) : null}{' '}
                                </div>
                            )
                        }}
                    />
                ) : (
                    <div class={suggestionsContainer}>
                        <Input
                            class={getClassName(this.$props.innerClass, 'input') || ''}
                            placeholder={this.$props.placeholder}
                            {...{
                                on: {
                                    blur: e => {
                                        this.$emit('blur', e);
                                    },
                                    keypress: e => {
                                        this.$emit('keyPress', e);
                                    },
                                    input: this.onInputChange,
                                    focus: e => {
                                        this.$emit('focus', e);
                                    },
                                    keydown: e => {
                                        this.$emit('keyDown', e);
                                    },
                                    keyup: e => {
                                        this.$emit('keyUp', e);
                                    }
                                }
                            }}
                            {...{
                                domProps: {
                                    autofocus: this.$props.autoFocus,
                                    value: this.$data.currentValue ? this.$data.currentValue : ''
                                }
                            }}
                            iconPosition={this.$props.iconPosition}
                            showIcon={this.$props.showIcon}
                            showClear={this.$props.showClear}
                            innerRef={this.$props.innerRef}
                            themePreset={this.themePreset}
                        />
                        {this.renderIcons()}
                    </div>
                )}
            </Container>
        );
    }
};

DataSearch.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: {}
        };
    }

    return finalQuery;
};
DataSearch.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'
            }
        }
    ];
};
DataSearch.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
        }
    };
};

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
});
const mapDispatchtoProps = {
    addComponent,
    removeComponent,
    setQueryOptions,
    updateQuery,
    watchComponent,
    setQueryListener
};
const DSConnected = connect(
    mapStateToProps,
    mapDispatchtoProps
)(DataSearch);

DataSearch.install = function(Vue) {
    Vue.component(DataSearch.name, DSConnected);
};
export default DataSearch;