Merge pull request #1500 from influxdata/dropdown-autocomplete

Dropdown Autocomplete
pull/10616/head
Alex Paxton 2017-05-18 12:52:47 -07:00 committed by GitHub
commit d57b51f113
10 changed files with 313 additions and 69 deletions

View File

@ -12,8 +12,8 @@
### Features
1. [#1477](https://github.com/influxdata/chronograf/pull/1477): Add ability to log alerts
1. [#1474](https://github.com/influxdata/chronograf/pull/1474): Change behavior of template variable autocomplete to filter by exact match from front of string
1. [#1491](https://github.com/influxdata/chronograf/pull/1491): Update go vendoring to dep and committed vendor directory
1. [#1500](https://github.com/influxdata/chronograf/pull/1500): Add autocomplete functionality to Template Variable dropdowns
1. [#1498](https://github.com/influxdata/chronograf/pull/1498): Notify user via UI when local settings are cleared
### UI Improvements

View File

@ -34,15 +34,16 @@ const AlertsTable = React.createClass({
filterAlerts(searchTerm, newAlerts) {
const alerts = newAlerts || this.props.alerts
const filterText = searchTerm.toLowerCase()
const filteredAlerts = alerts.filter(h => {
if (h.host === null || h.name === null || h.level === null) {
return false
}
return (
h.name.toLowerCase().search(searchTerm.toLowerCase()) !== -1 ||
h.host.toLowerCase().search(searchTerm.toLowerCase()) !== -1 ||
h.level.toLowerCase().search(searchTerm.toLowerCase()) !== -1
h.name.toLowerCase().includes(filterText) ||
h.host.toLowerCase().includes(filterText) ||
h.level.toLowerCase().includes(filterText)
)
})
this.setState({searchTerm, filteredAlerts})

View File

@ -24,6 +24,7 @@ const TemplateControlBar = ({
<Dropdown
items={items}
buttonSize="btn-xs"
useAutoComplete={true}
selected={selectedText || 'Loading...'}
onChoose={item =>
onSelectTemplate(id, [item].map(x => omit(x, 'text')))}

View File

@ -115,8 +115,9 @@ const MeasurementList = React.createClass({
)
}
const filterText = this.state.filterText.toLowerCase()
const measurements = this.state.measurements.filter(m =>
m.match(this.state.filterText)
m.toLowerCase().includes(filterText)
)
return (

View File

@ -162,9 +162,12 @@ class QueryEditor extends Component {
if (matched && !_.isEmpty(templates)) {
// maintain cursor poition
const start = this.editor.selectionStart
const end = this.editor.selectionEnd
const filterText = matched[0].substr(1).toLowerCase()
const filteredTemplates = templates.filter(t =>
t.tempVar.startsWith(matched[0])
t.tempVar.toLowerCase().includes(filterText)
)
const found = filteredTemplates.find(

View File

@ -52,7 +52,8 @@ const TagListItem = React.createClass({
return <div>no tag values</div>
}
const filtered = tagValues.filter(v => v.match(this.state.filterText))
const filterText = this.state.filterText.toLowerCase()
const filtered = tagValues.filter(v => v.toLowerCase().includes(filterText))
return (
<div className="query-builder--sub-list">

View File

@ -35,20 +35,21 @@ const HostsTable = React.createClass({
},
filter(allHosts, searchTerm) {
const filterText = searchTerm.toLowerCase()
return allHosts.filter(h => {
const apps = h.apps ? h.apps.join(', ') : ''
// search each tag for the presence of the search term
let tagResult = false
if (h.tags) {
tagResult = Object.keys(h.tags).reduce((acc, key) => {
return acc || h.tags[key].search(searchTerm) !== -1
return acc || h.tags[key].toLowerCase().includes(filterText)
}, false)
} else {
tagResult = false
}
return (
h.name.search(searchTerm) !== -1 ||
apps.search(searchTerm) !== -1 ||
h.name.toLowerCase().includes(filterText) ||
apps.toLowerCase().includes(filterText) ||
tagResult
)
})

View File

@ -3,13 +3,19 @@ import {Link} from 'react-router'
import classnames from 'classnames'
import OnClickOutside from 'shared/components/OnClickOutside'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import {DROPDOWN_MENU_MAX_HEIGHT, DROPDOWN_MENU_ITEM_THRESHOLD} from 'shared/constants/index'
import {
DROPDOWN_MENU_MAX_HEIGHT,
DROPDOWN_MENU_ITEM_THRESHOLD,
} from 'shared/constants/index'
class Dropdown extends Component {
constructor(props) {
super(props)
this.state = {
isOpen: false,
searchTerm: '',
filteredItems: this.props.items,
highlightedItemIndex: null,
}
this.handleClickOutside = ::this.handleClickOutside
@ -17,6 +23,10 @@ class Dropdown extends Component {
this.handleSelection = ::this.handleSelection
this.toggleMenu = ::this.toggleMenu
this.handleAction = ::this.handleAction
this.handleFilterChange = ::this.handleFilterChange
this.applyFilter = ::this.applyFilter
this.handleFilterKeyPress = ::this.handleFilterKeyPress
this.handleHighlight = ::this.handleHighlight
}
static defaultProps = {
@ -24,6 +34,7 @@ class Dropdown extends Component {
buttonSize: 'btn-sm',
buttonColor: 'btn-info',
menuWidth: '100%',
useAutoComplete: false,
}
handleClickOutside() {
@ -42,10 +53,21 @@ class Dropdown extends Component {
this.props.onChoose(item)
}
handleHighlight(itemIndex) {
this.setState({highlightedItemIndex: itemIndex})
}
toggleMenu(e) {
if (e) {
e.stopPropagation()
}
if (!this.state.isOpen) {
this.setState({
searchTerm: '',
filteredItems: this.props.items,
highlightedItemIndex: null,
})
}
this.setState({isOpen: !this.state.isOpen})
}
@ -54,21 +76,94 @@ class Dropdown extends Component {
action.handler(item)
}
handleFilterKeyPress(e) {
const {filteredItems, highlightedItemIndex} = this.state
if (e.key === 'Enter' && filteredItems.length) {
this.setState({isOpen: false})
this.props.onChoose(filteredItems[highlightedItemIndex])
}
if (e.key === 'Escape') {
this.setState({isOpen: false})
}
if (e.key === 'ArrowUp' && highlightedItemIndex > 0) {
this.setState({highlightedItemIndex: highlightedItemIndex - 1})
}
if (e.key === 'ArrowDown') {
if (highlightedItemIndex < filteredItems.length - 1) {
this.setState({highlightedItemIndex: highlightedItemIndex + 1})
}
if (highlightedItemIndex === null && filteredItems.length) {
this.setState({highlightedItemIndex: 0})
}
}
}
handleFilterChange(e) {
if (e.target.value === null || e.target.value === '') {
this.setState({
searchTerm: '',
filteredItems: this.props.items,
highlightedItemIndex: null,
})
} else {
this.setState({searchTerm: e.target.value}, () =>
this.applyFilter(this.state.searchTerm)
)
}
}
applyFilter(searchTerm) {
const {items} = this.props
const filterText = searchTerm.toLowerCase()
const matchingItems = items.filter(item =>
item.text.toLowerCase().includes(filterText)
)
this.setState({
filteredItems: matchingItems,
highlightedItemIndex: 0,
})
}
renderShortMenu() {
const {actions, addNew, items, menuWidth, menuLabel} = this.props
const {
actions,
addNew,
items,
menuWidth,
menuLabel,
useAutoComplete,
selected,
} = this.props
const {filteredItems, highlightedItemIndex} = this.state
const menuItems = useAutoComplete ? filteredItems : items
return (
<ul className="dropdown-menu" style={{width: menuWidth}}>
{menuLabel
? <li className="dropdown-header">{menuLabel}</li>
: null
}
{items.map((item, i) => {
<ul
className={classnames('dropdown-menu', {
'dropdown-menu--no-highlight': useAutoComplete,
})}
style={{width: menuWidth}}
>
{menuLabel ? <li className="dropdown-header">{menuLabel}</li> : null}
{menuItems.map((item, i) => {
if (item.text === 'SEPARATOR') {
return <li key={i} role="separator" className="divider" />
}
return (
<li className="dropdown-item" key={i}>
<a href="#" onClick={() => this.handleSelection(item)}>
<li
className={classnames('dropdown-item', {
highlight: i === highlightedItemIndex,
active: item.text === selected,
})}
key={i}
>
<a
href="#"
onClick={() => this.handleSelection(item)}
onMouseOver={() => this.handleHighlight(i)}
>
{item.text}
</a>
{actions.length > 0
@ -78,8 +173,7 @@ class Dropdown extends Component {
<button
key={action.text}
className="dropdown-item__action"
onClick={e =>
this.handleAction(e, action, item)}
onClick={e => this.handleAction(e, action, item)}
>
<span
title={action.text}
@ -105,21 +199,44 @@ class Dropdown extends Component {
}
renderLongMenu() {
const {actions, addNew, items, menuWidth, menuLabel} = this.props
const {
actions,
addNew,
items,
menuWidth,
menuLabel,
useAutoComplete,
selected,
} = this.props
const {filteredItems, highlightedItemIndex} = this.state
const menuItems = useAutoComplete ? filteredItems : items
return (
<ul className="dropdown-menu" style={{width: menuWidth, height: DROPDOWN_MENU_MAX_HEIGHT}}>
<ul
className={classnames('dropdown-menu', {
'dropdown-menu--no-highlight': useAutoComplete,
})}
style={{width: menuWidth, height: DROPDOWN_MENU_MAX_HEIGHT}}
>
<FancyScrollbar autoHide={false}>
{menuLabel
? <li className="dropdown-header">{menuLabel}</li>
: null
}
{items.map((item, i) => {
{menuLabel ? <li className="dropdown-header">{menuLabel}</li> : null}
{menuItems.map((item, i) => {
if (item.text === 'SEPARATOR') {
return <li key={i} role="separator" className="divider" />
}
return (
<li className="dropdown-item" key={i}>
<a href="#" onClick={() => this.handleSelection(item)}>
<li
className={classnames('dropdown-item', {
highlight: i === highlightedItemIndex,
active: item.text === selected,
})}
key={i}
>
<a
href="#"
onClick={() => this.handleSelection(item)}
onMouseOver={() => this.handleHighlight(i)}
>
{item.text}
</a>
{actions.length > 0
@ -129,8 +246,7 @@ class Dropdown extends Component {
<button
key={action.text}
className="dropdown-item__action"
onClick={e =>
this.handleAction(e, action, item)}
onClick={e => this.handleAction(e, action, item)}
>
<span
title={action.text}
@ -164,33 +280,57 @@ class Dropdown extends Component {
iconName,
buttonSize,
buttonColor,
useAutoComplete,
} = this.props
const {isOpen} = this.state
const {isOpen, searchTerm, filteredItems} = this.state
const menuItems = useAutoComplete ? filteredItems : items
return (
<div
onClick={this.handleClick}
className={classnames(`dropdown ${className}`, {open: isOpen})}
>
<div className={`btn dropdown-toggle ${buttonSize} ${buttonColor}`}>
{iconName
? <span className={classnames('icon', {[iconName]: true})} />
: null}
<span className="dropdown-selected">{selected}</span>
<span className="caret" />
</div>
{(isOpen && items.length < DROPDOWN_MENU_ITEM_THRESHOLD)
{useAutoComplete && isOpen
? <div
className={`dropdown-autocomplete dropdown-toggle ${buttonSize} ${buttonColor}`}
>
<input
ref="dropdownAutoComplete"
className="dropdown-autocomplete--input"
type="text"
autoFocus={true}
placeholder="Filter items..."
spellCheck={false}
onChange={this.handleFilterChange}
onKeyDown={this.handleFilterKeyPress}
value={searchTerm}
/>
<span className="caret" />
</div>
: <div className={`btn dropdown-toggle ${buttonSize} ${buttonColor}`}>
{iconName
? <span className={classnames('icon', {[iconName]: true})} />
: null}
<span className="dropdown-selected">{selected}</span>
<span className="caret" />
</div>}
{isOpen && menuItems.length < DROPDOWN_MENU_ITEM_THRESHOLD
? this.renderShortMenu()
: null}
{(isOpen && items.length >= DROPDOWN_MENU_ITEM_THRESHOLD)
{isOpen && menuItems.length >= DROPDOWN_MENU_ITEM_THRESHOLD
? this.renderLongMenu()
: null}
{isOpen && !menuItems.length
? <ul className="dropdown-menu">
<li className="dropdown-empty">No matching items</li>
</ul>
: null}
</div>
)
}
}
const {arrayOf, shape, string, func} = PropTypes
const {arrayOf, bool, shape, string, func} = PropTypes
Dropdown.propTypes = {
actions: arrayOf(
@ -218,6 +358,7 @@ Dropdown.propTypes = {
buttonColor: string,
menuWidth: string,
menuLabel: string,
useAutoComplete: bool,
}
export default OnClickOutside(Dropdown)

View File

@ -12,30 +12,16 @@ $dropdown-menu-max-height: 270px;
Generic width modifiers
Use instead of creating new classes if possible
*/
.dropdown .dropdown-toggle {
.dropdown .dropdown-toggle,
.dropdown .dropdown-autocomplete {
width: 120px; /* Default width */
}
.dropdown {
&-80 .dropdown-toggle {width: 80px;}
&-90 .dropdown-toggle {width: 90px;}
&-100 .dropdown-toggle {width: 100px;}
&-110 .dropdown-toggle {width: 110px;}
&-120 .dropdown-toggle {width: 120px;}
&-130 .dropdown-toggle {width: 130px;}
&-140 .dropdown-toggle {width: 140px;}
&-150 .dropdown-toggle {width: 150px;}
&-160 .dropdown-toggle {width: 160px;}
&-170 .dropdown-toggle {width: 170px;}
&-180 .dropdown-toggle {width: 180px;}
&-190 .dropdown-toggle {width: 190px;}
&-200 .dropdown-toggle {width: 200px;}
&-210 .dropdown-toggle {width: 210px;}
&-220 .dropdown-toggle {width: 220px;}
&-230 .dropdown-toggle {width: 230px;}
&-240 .dropdown-toggle {width: 240px;}
&-250 .dropdown-toggle {width: 250px;}
@for $i from 8 through 30 {
&-#{$i * 10} .dropdown-toggle,
&-#{$i * 10} .dropdown-autocomplete { width: #{$i * 10}px; }
}
}
.dropdown-toggle {
position: relative;
text-align: left;
@ -65,7 +51,54 @@ $dropdown-menu-max-height: 270px;
.dropdown .dropdown-toggle.btn-xs {
height: 22px !important;
line-height: 22px !important;
padding: 0 9px !important;
padding: 0 9px;
}
/*
AutoComplete Field
----------------------------------------------
*/
.dropdown-autocomplete {
position: relative;
padding: 0 !important;
&.btn-xs {height: 22px;}
&.btn-sm {height: 30px;}
&.btn-md {height: 36px;}
&.btn-lg {height: 50px;}
}
.dropdown-autocomplete--input {
position: absolute;
width: 100%;
height: 100%;
outline: none;
background-color: transparent;
border: 0;
color: $g20-white;
padding: 0;
font-weight: 500;
.btn-xs & {padding: 0 18px 0 9px; font-size: 12px;}
.btn-sm & {padding: 0 18px 0 9px; font-size: 13px;}
.btn-md & {padding: 0 34px 0 17px; font-size: 14px;}
.btn-lg & {padding: 0 48px 0 24px; font-size: 18px;}
&::-webkit-input-placeholder { color: rgba(255,255,255,0.5); font-weight: 500 !important; }
&::-moz-placeholder { color: rgba(255,255,255,0.5); font-weight: 500 !important; }
&:-ms-input-placeholder { color: rgba(255,255,255,0.5); font-weight: 500 !important; }
&:-moz-placeholder { color: rgba(255,255,255,0.5); font-weight: 500 !important; }
&:focus {
color: $g20-white;
}
}
.dropdown-empty {
padding: 7px 9px;
font-size: 13px;
color: rgba(255,255,255,0.4);
font-weight: 500;
line-height: 15px;
@include no-user-select();
}
/*
@ -86,6 +119,9 @@ $dropdown-menu-max-height: 270px;
position: relative;
width: 100%;
&.active {
@include gradient-h($c-sapphire, $c-pool);
}
&:hover {
@include gradient-h($c-laser, $c-pool);
}
@ -99,7 +135,7 @@ $dropdown-menu-max-height: 270px;
padding: 7px 9px;
font-size: 13px;
line-height: 15px;
font-weight: 500;
font-weight: 500 !important;
color: $c-yeti !important;
background-color: transparent;
transition:
@ -118,6 +154,16 @@ $dropdown-menu-max-height: 270px;
@include gradient-h($c-ocean, $c-pool);
}
}
li.dropdown-item.highlight {
&, &:hover {
@include gradient-h($c-laser, $c-pool);
}
> a {
background: none;
background-color: transparent;
color: $g20-white;
}
}
}
.dropdown.dropdown-kapacitor .dropdown-toggle {
color: $c-rainforest !important;
@ -137,6 +183,16 @@ $dropdown-menu-max-height: 270px;
color: $g20-white !important;
}
}
li.dropdown-item.highlight {
&, &:hover {
@include gradient-h($c-laser, $c-rainforest);
}
> a {
background: none;
background-color: transparent;
color: $g20-white;
}
}
}
.dropdown.dropdown-chronograf .dropdown-menu {
@include custom-scrollbar($c-comet, $c-potassium);
@ -151,8 +207,36 @@ $dropdown-menu-max-height: 270px;
color: $g20-white !important;
}
}
li.dropdown-item.highlight {
&, &:hover {
@include gradient-h($c-laser, $c-comet);
}
> a {
background: none;
background-color: transparent;
color: $g20-white;
}
}
}
/*
Dropdown Menu (only js highlighting, works with autocomplete feature)
----------------------------------------------
*/
.dropdown-menu.dropdown-menu--no-highlight {
li.dropdown-item {
&:hover {
background: none;
background-color: transparent;
}
}
li.dropdown-item.highlight {
&, &:hover {
@include gradient-h($c-laser, $c-pool);
}
}
}
/*
Dropdown Header
----------------------------------------------

View File

@ -57,10 +57,13 @@ $template-control--min-height: 52px;
}
.dropdown-menu {
@include gradient-h($c-star,$c-pool);
@include custom-scrollbar-round($c-pool,$c-laser);
li.dropdown-item {
&:hover {@include gradient-h($c-comet,$c-pool);}
&, &:hover {
background: none;
background-color: transparent;
}
&.active {@include gradient-h($c-amethyst,$c-pool);}
}
li.dropdown-item > a {
&, &:focus {background: none;}
@ -68,6 +71,14 @@ $template-control--min-height: 52px;
font-size: 12px;
font-family: $code-font;
}
li.dropdown-item.highlight {
&, &:hover {@include gradient-h($c-comet,$c-pool);}
> a {
color: $g20-white;
background: none;
background-color: transparent;
}
}
}
}
.template-control--label {