Skip to content

Commit

Permalink
[Maps] Autocomplete for custom color palettes and custom icon palettes (
Browse files Browse the repository at this point in the history
elastic#56446)

* [Maps] type ahead for stop values for custom color maps and custom icon maps

* use Popover to show type ahead suggestions

* datalist version

* use EuiComboBox

* clean up

* wire ColorStopsCategorical to use StopInput component for autocomplete

* clean up

* cast suggestion values to string so boolean fields work

* review feedback

* fix problem with stall suggestions from previous field

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
nreese and elasticmachine committed Feb 12, 2020
1 parent 3194cd0 commit 22fd2a9
Show file tree
Hide file tree
Showing 15 changed files with 264 additions and 65 deletions.
1 change: 1 addition & 0 deletions x-pack/legacy/plugins/maps/public/kibana_services.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { npStart } from 'ui/new_platform';
export const SPATIAL_FILTER_TYPE = esFilters.FILTERS.SPATIAL_FILTER;
export { SearchSource } from '../../../../../src/plugins/data/public';
export const indexPatternService = npStart.plugins.data.indexPatterns;
export const autocompleteService = npStart.plugins.data.autocomplete;

let licenseId;
export const setLicenseId = latestLicenseId => (licenseId = latestLicenseId);
Expand Down
22 changes: 22 additions & 0 deletions x-pack/legacy/plugins/maps/public/layers/sources/es_source.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { AbstractVectorSource } from './vector_source';
import {
autocompleteService,
fetchSearchSourceAndRecordWithInspector,
indexPatternService,
SearchSource,
Expand Down Expand Up @@ -344,4 +345,25 @@ export class AbstractESSource extends AbstractVectorSource {

return resp.aggregations;
}

getValueSuggestions = async (fieldName, query) => {
if (!fieldName) {
return [];
}

try {
const indexPattern = await this.getIndexPattern();
const field = indexPattern.fields.getByName(fieldName);
return await autocompleteService.getValueSuggestions({
indexPattern,
field,
query,
});
} catch (error) {
console.warn(
`Unable to fetch suggestions for field: ${fieldName}, query: ${query}, error: ${error.message}`
);
return [];
}
};
}
4 changes: 4 additions & 0 deletions x-pack/legacy/plugins/maps/public/layers/sources/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,8 @@ export class AbstractSource {
async loadStylePropsMeta() {
throw new Error(`Source#loadStylePropsMeta not implemented`);
}

async getValueSuggestions(/* fieldName, query */) {
return [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export class ColorMapSelect extends Component {
<EuiSpacer size="s" />
<ColorStopsCategorical
colorStops={this.state.customColorMap}
field={this.props.styleProperty.getField()}
getValueSuggestions={this.props.styleProperty.getValueSuggestions}
onChange={this._onCustomColorMapChange}
/>
</Fragment>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,26 +59,23 @@ export const ColorStops = ({
onChange,
colorStops,
isStopsInvalid,
sanitizeStopInput,
getStopError,
renderStopInput,
addNewRow,
canDeleteStop,
}) => {
function getStopInput(stop, index) {
const onStopChange = e => {
const onStopChange = newStopValue => {
const newColorStops = _.cloneDeep(colorStops);
newColorStops[index].stop = sanitizeStopInput(e.target.value);
const invalid = isStopsInvalid(newColorStops);
newColorStops[index].stop = newStopValue;
onChange({
colorStops: newColorStops,
isInvalid: invalid,
isInvalid: isStopsInvalid(newColorStops),
});
};

const error = getStopError(stop, index);
return {
stopError: error,
stopError: getStopError(stop, index),
stopInput: renderStopInput(stop, onStopChange, index),
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,17 @@ import {
import { i18n } from '@kbn/i18n';
import { ColorStops } from './color_stops';
import { getOtherCategoryLabel } from '../../style_util';
import { StopInput } from '../stop_input';

export const ColorStopsCategorical = ({
colorStops = [
{ stop: null, color: DEFAULT_CUSTOM_COLOR }, //first stop is the "other" color
{ stop: '', color: DEFAULT_NEXT_COLOR },
],
field,
onChange,
getValueSuggestions,
}) => {
const sanitizeStopInput = value => {
return value;
};

const getStopError = (stop, index) => {
let count = 0;
for (let i = 1; i < colorStops.length; i++) {
Expand All @@ -49,34 +48,23 @@ export const ColorStopsCategorical = ({
if (index === 0) {
return (
<EuiFieldText
aria-label={i18n.translate(
'xpack.maps.styles.colorStops.categoricalStop.defaultCategoryAriaLabel',
{
defaultMessage: 'Default stop',
}
)}
value={stopValue}
aria-label={getOtherCategoryLabel()}
placeholder={getOtherCategoryLabel()}
disabled
onChange={onStopChange}
compressed
/>
);
} else {
return (
<EuiFieldText
aria-label={i18n.translate(
'xpack.maps.styles.colorStops.categoricalStop.categoryAriaLabel',
{
defaultMessage: 'Category',
}
)}
value={stopValue}
onChange={onStopChange}
compressed
/>
);
}

return (
<StopInput
key={field.getName()} // force new component instance when field changes
field={field}
getValueSuggestions={getValueSuggestions}
value={stopValue}
onChange={onStopChange}
/>
);
};

const canDeleteStop = (colorStops, index) => {
Expand All @@ -88,7 +76,6 @@ export const ColorStopsCategorical = ({
onChange={onChange}
colorStops={colorStops}
isStopsInvalid={isCategoricalStopsInvalid}
sanitizeStopInput={sanitizeStopInput}
getStopError={getStopError}
renderStopInput={renderStopInput}
canDeleteStop={canDeleteStop}
Expand All @@ -114,4 +101,8 @@ ColorStopsCategorical.propTypes = {
* Callback for when the color stops changes. Called with { colorStops, isInvalid }
*/
onChange: PropTypes.func.isRequired,
/**
* Callback for fetching stop value suggestions. Called with query.
*/
getValueSuggestions: PropTypes.func.isRequired,
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ export const ColorStopsOrdinal = ({
colorStops = [{ stop: 0, color: DEFAULT_CUSTOM_COLOR }],
onChange,
}) => {
const sanitizeStopInput = value => {
const sanitizedValue = parseFloat(value);
return isNaN(sanitizedValue) ? '' : sanitizedValue;
};

const getStopError = (stop, index) => {
let error;
if (isOrdinalStopInvalid(stop)) {
Expand All @@ -44,13 +39,18 @@ export const ColorStopsOrdinal = ({
};

const renderStopInput = (stop, onStopChange) => {
function handleOnChangeEvent(event) {
const sanitizedValue = parseFloat(event.target.value);
const newStopValue = isNaN(sanitizedValue) ? '' : sanitizedValue;
onStopChange(newStopValue);
}
return (
<EuiFieldNumber
aria-label={i18n.translate('xpack.maps.styles.colorStops.ordinalStop.stopLabel', {
defaultMessage: 'Stop',
})}
value={stop}
onChange={onStopChange}
onChange={handleOnChangeEvent}
compressed
/>
);
Expand All @@ -65,7 +65,6 @@ export const ColorStopsOrdinal = ({
onChange={onChange}
colorStops={colorStops}
isStopsInvalid={isOrdinalStopsInvalid}
sanitizeStopInput={sanitizeStopInput}
getStopError={getStopError}
renderStopInput={renderStopInput}
canDeleteStop={canDeleteStop}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function DynamicColorForm({
color={styleOptions.color}
customColorMap={styleOptions.customColorRamp}
useCustomColorMap={_.get(styleOptions, 'useCustomColorRamp', false)}
compressed
styleProperty={styleProperty}
/>
);
}
Expand All @@ -83,7 +83,7 @@ export function DynamicColorForm({
color={styleOptions.colorCategory}
customColorMap={styleOptions.customColorPalette}
useCustomColorMap={_.get(styleOptions, 'useCustomColorPalette', false)}
compressed
styleProperty={styleProperty}
/>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import _ from 'lodash';
import React, { Component } from 'react';

import { EuiComboBox, EuiFieldText } from '@elastic/eui';

export class StopInput extends Component {
constructor(props) {
super(props);
this.state = {
suggestions: [],
isLoadingSuggestions: false,
hasPrevFocus: false,
fieldDataType: undefined,
localFieldTextValue: props.value,
};
}

componentDidMount() {
this._isMounted = true;
this._loadFieldDataType();
}

componentWillUnmount() {
this._isMounted = false;
this._loadSuggestions.cancel();
}

async _loadFieldDataType() {
const fieldDataType = await this.props.field.getDataType();
if (this._isMounted) {
this.setState({ fieldDataType });
}
}

_onFocus = () => {
if (!this.state.hasPrevFocus) {
this.setState({ hasPrevFocus: true });
this._onSearchChange('');
}
};

_onChange = selectedOptions => {
this.props.onChange(_.get(selectedOptions, '[0].label', ''));
};

_onCreateOption = newValue => {
this.props.onChange(newValue);
};

_onSearchChange = async searchValue => {
this.setState(
{
isLoadingSuggestions: true,
searchValue,
},
() => {
this._loadSuggestions(searchValue);
}
);
};

_loadSuggestions = _.debounce(async searchValue => {
let suggestions = [];
try {
suggestions = await this.props.getValueSuggestions(searchValue);
} catch (error) {
// ignore suggestions error
}

if (this._isMounted && searchValue === this.state.searchValue) {
this.setState({
isLoadingSuggestions: false,
suggestions,
});
}
}, 300);

_onFieldTextChange = event => {
this.setState({ localFieldTextValue: event.target.value });
// onChange can cause UI lag, ensure smooth input typing by debouncing onChange
this._debouncedOnFieldTextChange();
};

_debouncedOnFieldTextChange = _.debounce(() => {
this.props.onChange(this.state.localFieldTextValue);
}, 500);

_renderSuggestionInput() {
const suggestionOptions = this.state.suggestions.map(suggestion => {
return { label: `${suggestion}` };
});

const selectedOptions = [];
if (this.props.value) {
let option = suggestionOptions.find(({ label }) => {
return label === this.props.value;
});
if (!option) {
option = { label: this.props.value };
suggestionOptions.unshift(option);
}
selectedOptions.push(option);
}

return (
<EuiComboBox
options={suggestionOptions}
selectedOptions={selectedOptions}
singleSelection={{ asPlainText: true }}
onChange={this._onChange}
onSearchChange={this._onSearchChange}
onCreateOption={this._onCreateOption}
isClearable={false}
isLoading={this.state.isLoadingSuggestions}
onFocus={this._onFocus}
compressed
/>
);
}

_renderTextInput() {
return (
<EuiFieldText
value={this.state.localFieldTextValue}
onChange={this._onFieldTextChange}
compressed
/>
);
}

render() {
if (!this.state.fieldDataType) {
return null;
}

// autocomplete service can not provide suggestions for non string fields (and boolean) because it uses
// term aggregation include parameter. Include paramerter uses a regular expressions that only supports string type
return this.state.fieldDataType === 'string' || this.state.fieldDataType === 'boolean'
? this._renderSuggestionInput()
: this._renderTextInput();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function DynamicIconForm({
return (
<IconMapSelect
{...styleOptions}
styleProperty={styleProperty}
onChange={onIconMapChange}
isDarkMode={isDarkMode}
symbolOptions={symbolOptions}
Expand Down
Loading

0 comments on commit 22fd2a9

Please sign in to comment.