Przeglądaj źródła

Use a searchable dropdown component for long lists

Applicable to long lists in Wizard Options page, like Azure's VM sizes,
Openstack's migration flavour names, OCI's shape names etc.
Sergiu Miclea 8 lat temu
rodzic
commit
553e41b21f

+ 34 - 0
src/components/atoms/AutocompleteInput/images/arrow.js

@@ -0,0 +1,34 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+const arrow = `<?xml version="1.0" encoding="UTF-8"?>
+  <svg width="12px" height="7px" viewBox="0 0 12 6" version="1.1" 
+  xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+      <!-- Generator: Sketch 47 (45396) - http://www.bohemiancoding.com/sketch -->
+      <title>Chevron-Grey</title>
+      <desc>Created with Sketch.</desc>
+      <defs></defs>
+      <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" 
+      stroke-linecap="round" stroke-linejoin="round">
+          <g id="dropdown-arrow-image" transform="translate(-171.000000, -13.000000)">
+              <g id="Icon/Chevron/Grey" transform="translate(169.000000, 8.000000)">
+                  <polyline id="Rectangle-Copy" transform="translate(8.000000, 5.500000) 
+                  rotate(-315.000000) translate(-8.000000, -5.500000) " 
+                  points="11.8890873 1.6109127 11.8890873 9.3890873 4.1109127 9.3890873"></polyline>
+              </g>
+          </g>
+      </g>
+  </svg>`
+
+export default arrow

+ 120 - 0
src/components/atoms/AutocompleteInput/index.jsx

@@ -0,0 +1,120 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled, { css } from 'styled-components'
+
+import arrowImage from './images/arrow.js'
+
+import TextInput from '../TextInput'
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+
+const getWidth = props => {
+  if (props.width) {
+    return props.width - 2
+  }
+
+  if (props.large) {
+    return StyleProps.inputSizes.large.width - 2
+  }
+
+  return StyleProps.inputSizes.regular.width - 2
+}
+
+const Wrapper = styled.div`
+  display: flex;
+  align-items: center;
+  position: relative;
+  width: ${props => getWidth(props)}px;
+  height: ${props => props.large ? StyleProps.inputSizes.large.height - 2
+    : StyleProps.inputSizes.regular.height - 2}px;
+  border: 1px solid ${props => props.disabled ? Palette.grayscale[0] : Palette.grayscale[3]};
+  border-radius: ${StyleProps.borderRadius};
+  cursor: ${props => props.disabled ? 'default' : 'pointer'};
+  transition: all ${StyleProps.animations.swift};
+  background: ${props => props.disabled ? Palette.grayscale[0] : 'white'};
+
+  #dropdown-arrow-image {stroke: ${props => props.disabled ? Palette.grayscale[0] : Palette.grayscale[4]};}
+  ${props => props.focus ? css`border-color: ${Palette.primary};` : ''}
+`
+const Arrow = styled.div`
+  position: absolute;
+  top: 0;
+  right: 0;
+  width: 30px;
+  height: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+`
+type Props = {
+  value: string,
+  customRef?: (ref: HTMLElement) => void,
+  innerRef?: (ref: HTMLElement) => void,
+  onChange: (value: string) => void,
+  onClick?: () => void,
+  disabled?: boolean,
+  width?: number,
+  large?: boolean,
+  onFocus?: () => void,
+}
+type State = {
+  textInputFocus: boolean,
+}
+class AutocompleteInput extends React.Component<Props, State> {
+  state = {
+    textInputFocus: false,
+  }
+
+  render() {
+    return (
+      <Wrapper
+        large={this.props.large}
+        width={this.props.width}
+        focus={this.state.textInputFocus}
+        innerRef={e => {
+          if (this.props.customRef) {
+            this.props.customRef(e)
+          } else if (this.props.innerRef) {
+            this.props.innerRef(e)
+          }
+        }}
+      >
+        <TextInput
+          disabled={this.props.disabled}
+          value={this.props.value}
+          onChange={e => { this.props.onChange(e.target.value) }}
+          embedded
+          width={this.props.width ? `${this.props.width - 40}px` : '180px'}
+          style={{ marginLeft: '16px' }}
+          height="29px"
+          lineHeight="30px"
+          placeholder="Type to search ..."
+          onFocus={() => { if (this.props.onFocus) { this.props.onFocus() } this.setState({ textInputFocus: true }) }}
+          onBlur={() => { this.setState({ textInputFocus: false }) }}
+        />
+        <Arrow
+          disabled={this.props.disabled}
+          dangerouslySetInnerHTML={{ __html: arrowImage }}
+          onClick={this.props.onClick}
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default AutocompleteInput

+ 43 - 0
src/components/atoms/AutocompleteInput/story.jsx

@@ -0,0 +1,43 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import AutocompleteInput from '.'
+
+type State = {
+  value: string,
+}
+class Wrapper extends React.Component<{}, State> {
+  state = {
+    value: '',
+  }
+
+  render() {
+    return (
+      <AutocompleteInput
+        large
+        value={this.state.value}
+        onChange={value => { this.setState({ value }) }}
+      />
+    )
+  }
+}
+
+storiesOf('AutocompleteInput', module)
+  .add('default', () => (
+    <Wrapper />
+  ))

+ 4 - 2
src/components/atoms/TextInput/index.jsx

@@ -39,8 +39,9 @@ const getInputWidth = props => {
 const borderColor = (props, defaultColor = Palette.grayscale[3]) => props.highlight ? Palette.alert : defaultColor
 const Input = styled.input`
   width: ${props => getInputWidth(props)};
-  height: ${StyleProps.inputSizes.regular.height}px;
-  line-height: ${StyleProps.inputSizes.regular.height}px;
+  height: ${props => props.height || `${StyleProps.inputSizes.regular.height}px`};
+  line-height: ${props => props.lineHeight || `${StyleProps.inputSizes.regular.height}px`};
+  border-radius: ${StyleProps.borderRadius};
   background-color: #FFF;
   border: ${props => props.embedded ? 0 : css`1px solid ${props => borderColor(props)}`};
   border-top-left-radius: ${props => props.embedded ? 0 : StyleProps.borderRadius};
@@ -102,6 +103,7 @@ type Props = {
   onCloseClick?: () => void,
   embedded?: boolean,
   requiredStyle?: any,
+  height?: string,
   'data-test-id'?: string,
 }
 const TextInput = (props: Props) => {

+ 450 - 0
src/components/molecules/AutocompleteDropdown/index.jsx

@@ -0,0 +1,450 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled, { css } from 'styled-components'
+import ReactDOM from 'react-dom'
+
+import AutocompleteInput from '../../atoms/AutocompleteInput'
+
+import Palette from '../../styleUtils/Palette'
+import DomUtils from '../../../utils/DomUtils'
+import StyleProps from '../../styleUtils/StyleProps'
+
+const getWidth = props => {
+  if (props.width) {
+    return props.width - 2
+  }
+
+  if (props.large) {
+    return StyleProps.inputSizes.large.width - 2
+  }
+
+  return StyleProps.inputSizes.regular.width - 2
+}
+const Wrapper = styled.div`
+  position: relative;
+  ${props => props.width ? css`width: ${props.width}px;` : ''}
+`
+const List = styled.div`
+  position: absolute;
+  background: white;
+  cursor: pointer;
+  width: ${props => getWidth(props)}px;
+  border: 1px solid ${Palette.grayscale[3]};
+  border-radius: ${StyleProps.borderRadius};
+  z-index: 1000;
+`
+const ListItems = styled.div`
+  max-height: 400px;
+  overflow: auto;
+`
+const Tip = styled.div`
+  position: absolute;
+  width: 10px;
+  height: 10px;
+  background: ${props => props.primary ? Palette.primary : 'white'};
+  border-top: 1px solid ${Palette.grayscale[3]};
+  border-left: 1px solid ${Palette.grayscale[3]};
+  border-bottom: 1px solid ${props => props.primary ? Palette.primary : 'white'};
+  border-right: 1px solid ${props => props.primary ? Palette.primary : 'white'};
+  transform: rotate(45deg);
+  right: 8px;
+  top: -6px;
+  z-index: 11;
+  transition: all ${StyleProps.animations.swift};
+`
+const SearchNotFound = styled.div`
+  padding: 8px;
+  cursor: default;
+`
+const ListItem = styled.div`
+  position: relative;
+  color: ${props => props.selected ? 'white' : props.dim ? Palette.grayscale[3] : Palette.grayscale[4]};
+  ${props => props.selected ? css`background: ${Palette.primary};` : ''}
+  ${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
+
+  padding: 8px 16px;
+  transition: all ${StyleProps.animations.swift};
+
+  &:first-child {
+    border-top-left-radius: ${StyleProps.borderRadius};
+    border-top-right-radius: ${StyleProps.borderRadius};
+  }
+
+  &:last-child {
+    border-bottom-left-radius: ${StyleProps.borderRadius};
+    border-bottom-right-radius: ${StyleProps.borderRadius};
+  }
+
+  &:hover {
+    background: ${Palette.primary};
+    color: white;
+  }
+`
+const DuplicatedLabel = styled.div`
+  display: flex;
+  font-size: 11px;
+  span {
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+  }
+`
+
+type Props = {
+  selectedItem?: any,
+  items?: any[],
+  labelField?: string,
+  valueField?: string,
+  className?: string,
+  onChange?: (item: any) => void,
+  onInputChange?: (value: string, filteredItems: any[]) => void,
+  noItemsMessage?: string,
+  disabled?: boolean,
+  width?: number,
+  dimNullValue?: boolean,
+}
+type State = {
+  showDropdownList: boolean,
+  firstItemHover: boolean,
+  searchValue: string,
+  filteredItems: any[],
+}
+@observer
+class AutocompleteDropdown extends React.Component<Props, State> {
+  static defaultProps: $Shape<Props> = {
+    noItemsMessage: 'No results found',
+  }
+
+  buttonRef: HTMLElement
+  listRef: HTMLElement
+  listItemsRef: HTMLElement
+  tipRef: HTMLElement
+  scrollableParent: HTMLElement
+  buttonRect: ClientRect
+  itemMouseDown: boolean
+
+  constructor() {
+    super()
+
+    this.state = {
+      showDropdownList: false,
+      firstItemHover: false,
+      searchValue: '',
+      filteredItems: [],
+    }
+
+    // $FlowIssue
+    this.handlePageClick = this.handlePageClick.bind(this)
+
+    // $FlowIssue
+    this.handleScroll = this.handleScroll.bind(this)
+  }
+
+  componentWillMount() {
+    this.setState({
+      filteredItems: this.props.items,
+      searchValue: this.props.selectedItem ? this.getLabel(this.props.selectedItem) : '',
+    })
+  }
+
+  componentDidMount() {
+    window.addEventListener('mousedown', this.handlePageClick, false)
+    if (this.buttonRef) {
+      this.scrollableParent = DomUtils.getScrollableParent(this.buttonRef)
+      this.scrollableParent.addEventListener('scroll', this.handleScroll)
+      window.addEventListener('resize', this.handleScroll)
+      this.buttonRect = this.buttonRef.getBoundingClientRect()
+    }
+  }
+
+  componentWillReceiveProps(newProps: Props) {
+    this.setState({ filteredItems: this.getFilteredItems(newProps) })
+  }
+
+  componentWillUpdate() {
+    if (this.buttonRef) this.buttonRect = this.buttonRef.getBoundingClientRect()
+  }
+
+  componentDidUpdate() {
+    this.updateListPosition()
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('mousedown', this.handlePageClick, false)
+    window.removeEventListener('resize', this.handleScroll, false)
+    this.scrollableParent.removeEventListener('scroll', this.handleScroll, false)
+  }
+
+  getLabel(item: any) {
+    let labelField = this.props.labelField || 'label'
+
+    if (item === null || item === undefined) {
+      return ''
+    }
+
+    return (item[labelField] !== null && item[labelField] !== undefined && item[labelField].toString()) || item.toString()
+  }
+
+  getValue(item: any) {
+    let valueField = this.props.valueField || 'value'
+
+    if (item === null || item === undefined) {
+      return null
+    }
+
+    return (item[valueField] !== null && item[valueField] !== undefined && item[valueField].toString()) || null
+  }
+
+  getFilteredItems(props?: ?Props, searchValue?: string): any[] {
+    let useProps = props || this.props
+    let useSearch = searchValue === undefined ? this.state.searchValue : searchValue
+    if (!useProps.items) {
+      return []
+    }
+    return useProps.items.filter(i => {
+      const label = this.getLabel(i).toLowerCase()
+      const value = this.getValue(i) || ''
+      return label.indexOf(useSearch.toLowerCase()) > -1 || value.indexOf(useSearch.toLowerCase()) > -1
+    })
+  }
+
+  handleScroll() {
+    if (this.buttonRef) {
+      if (DomUtils.isElementInViewport(this.buttonRef, this.scrollableParent)) {
+        this.buttonRect = this.buttonRef.getBoundingClientRect()
+        this.updateListPosition()
+      } else if (this.state.showDropdownList) {
+        this.setState({ showDropdownList: false })
+      }
+    }
+  }
+
+  handlePageClick() {
+    if (!this.itemMouseDown) {
+      this.setState({ showDropdownList: false })
+    }
+  }
+
+  handleButtonClick() {
+    if (this.props.disabled) {
+      return
+    }
+
+    this.setState({
+      showDropdownList: !this.state.showDropdownList,
+      filteredItems: this.props.items,
+    }, () => {
+      this.scrollIntoView()
+    })
+  }
+
+  handleItemClick(item: any) {
+    this.setState({
+      showDropdownList: false,
+      firstItemHover: false,
+      searchValue: this.getLabel(item),
+      filteredItems: this.getFilteredItems(null, this.getLabel(item)),
+    })
+
+    if (this.props.onChange) {
+      this.props.onChange(item)
+    }
+  }
+
+  handleItemMouseEnter(index: number) {
+    if (index === 0) {
+      this.setState({ firstItemHover: true })
+    }
+  }
+
+  handleItemMouseLeave(index: number) {
+    if (index === 0) {
+      this.setState({ firstItemHover: false })
+    }
+  }
+
+  handleSearchInputChange(searchValue: string, isFocus?: boolean) {
+    let filteredItems = isFocus ? this.props.items || [] : this.getFilteredItems(null, searchValue)
+
+    this.setState({
+      searchValue,
+      filteredItems,
+      showDropdownList: true,
+    }, () => {
+      if (isFocus) {
+        this.scrollIntoView()
+      }
+    })
+
+    if (this.props.onInputChange) {
+      this.props.onInputChange(searchValue, filteredItems)
+    }
+  }
+
+  scrollIntoView() {
+    if (!this.listRef || !this.listItemsRef) {
+      return
+    }
+
+    let itemIndex = this.state.filteredItems.findIndex(i => this.getValue(i) === this.getValue(this.props.selectedItem))
+    if (itemIndex === -1 || !this.listItemsRef.children[itemIndex]) {
+      return
+    }
+
+    // $FlowIssue
+    this.listItemsRef.children[itemIndex].parentNode.scrollTop = this.listItemsRef.children[itemIndex].offsetTop - this.listItemsRef.children[itemIndex].parentNode.offsetTop - 32
+  }
+
+  updateListPosition() {
+    if (!this.state.showDropdownList || !this.listRef || !this.buttonRef || !document.body) {
+      return
+    }
+
+    let buttonHeight = this.buttonRef.offsetHeight
+    let tipHeight = 8
+    let listTop = this.buttonRect.top + buttonHeight + tipHeight
+    let listHeight = this.listRef.offsetHeight
+
+    if (listTop + listHeight + 16 > window.innerHeight) {
+      listHeight = window.innerHeight - listTop - 16
+    } else {
+      listHeight = 400
+    }
+
+    // If a modal is opened, body scroll is removed and body top is set to replicate scroll position
+    let scrollOffset = 0
+    if (parseInt(document.body.style.top, 10) < 0) {
+      scrollOffset = -parseInt(document.body && document.body.style.top, 10)
+    }
+
+    this.listRef.style.top = `${listTop + (window.pageYOffset || scrollOffset)}px`
+    this.listRef.style.left = `${this.buttonRect.left + window.pageXOffset}px`
+
+    if (this.listItemsRef) {
+      this.listItemsRef.style.maxHeight = `${listHeight}px`
+    }
+  }
+
+  renderItems() {
+    if (this.state.filteredItems.length === 0) {
+      return null
+    }
+
+    let selectedValue = this.getValue(this.props.selectedItem)
+    let duplicatedLabels = []
+    this.state.filteredItems.forEach((item, i) => {
+      let label = this.getLabel(item)
+      for (let j = i + 1; j < this.state.filteredItems.length; j += 1) {
+        if (label === this.getLabel(this.state.filteredItems[j]) && !duplicatedLabels.find(item2 => this.getLabel(item2) === label)) {
+          duplicatedLabels.push(label)
+        }
+      }
+    })
+
+    return (
+      <ListItems innerRef={ref => { this.listItemsRef = ref }}>
+        {this.state.filteredItems.map((item, i) => {
+          let label = this.getLabel(item)
+          let value = this.getValue(item)
+          let duplicatedLabel = duplicatedLabels.find(l => l === label)
+          let listItem = (
+            <ListItem
+              key={value}
+              onMouseDown={() => { this.itemMouseDown = true }}
+              onMouseUp={() => { this.itemMouseDown = false }}
+              onMouseEnter={() => { this.handleItemMouseEnter(i) }}
+              onMouseLeave={() => { this.handleItemMouseLeave(i) }}
+              onClick={() => { this.handleItemClick(item) }}
+              selected={value !== null && value === selectedValue}
+              dim={this.props.dimNullValue && (value === null || value === undefined)}
+            >
+              {label}
+              {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
+            </ListItem>
+          )
+
+          return listItem
+        })}
+      </ListItems>
+    )
+  }
+
+  renderSearchNotFound() {
+    if (this.state.searchValue === '' || !this.props.items || this.props.items.length === 0 || this.state.filteredItems.length > 0) {
+      return null
+    }
+
+    return (
+      <ListItems>
+        <SearchNotFound onClick={() => { this.setState({ showDropdownList: false }) }}>
+          {this.props.noItemsMessage}
+        </SearchNotFound>
+      </ListItems>
+    )
+  }
+
+  renderList() {
+    if (!this.state.showDropdownList) {
+      return null
+    }
+
+    const body: any = document.body
+    const selectedItemValue = this.getValue(this.props.selectedItem)
+    const firstItemValue = this.state.filteredItems.length > 0 ? this.getValue(this.state.filteredItems[0]) : null
+    const isFirstItemSelected = selectedItemValue !== null && selectedItemValue === firstItemValue
+
+    let list = ReactDOM.createPortal((
+      <List {...this.props} innerRef={ref => { this.listRef = ref }}>
+        <Tip innerRef={ref => { this.tipRef = ref }} primary={this.state.firstItemHover || isFirstItemSelected} />
+        {this.renderItems()}
+        {this.renderSearchNotFound()}
+      </List>
+    ), body)
+
+    return list
+  }
+
+  render() {
+    let nullLabel = this.props.items && this.getValue(this.props.items[0]) === null ? this.getLabel(this.props.items[0]) : ''
+    let inputValue = this.getValue(this.props.selectedItem) === null && this.state.searchValue === nullLabel ? '' : this.state.searchValue
+
+    return (
+      <Wrapper
+        className={this.props.className}
+        onMouseDown={() => { this.itemMouseDown = true }}
+        onMouseUp={() => { this.itemMouseDown = false }}
+        width={this.props.width}
+      >
+        <AutocompleteInput
+          width={this.props.width}
+          innerRef={ref => { this.buttonRef = ref }}
+          onMouseDown={() => { this.itemMouseDown = true }}
+          onMouseUp={() => { this.itemMouseDown = false }}
+          value={inputValue}
+          onClick={() => this.handleButtonClick()}
+          onChange={searchValue => { this.handleSearchInputChange(searchValue) }}
+          onFocus={() => { this.handleSearchInputChange(this.state.searchValue, true) }}
+        />
+        {this.renderList()}
+      </Wrapper>
+    )
+  }
+}
+
+export default AutocompleteDropdown

+ 68 - 0
src/components/molecules/AutocompleteDropdown/story.jsx

@@ -0,0 +1,68 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import AutocompleteDropdown from '.'
+
+const generateItem = (item: string, value?: string) => {
+  return {
+    value: value || item.replace(/ /g, '_').toLowerCase(),
+    label: item,
+  }
+}
+
+const items = [
+  generateItem('Item 1'),
+  generateItem('Item 2'),
+  generateItem('Item 3'),
+  generateItem('Item 4'),
+  generateItem('Item 5'),
+  generateItem('Item 6'),
+  generateItem('Item 7'),
+  generateItem('Item 8'),
+  generateItem('Item 8', 'item_8_2'),
+]
+
+type State = {
+  selectedItem: string,
+}
+
+class Wrapper extends React.Component<{}, State> {
+  state = {
+    selectedItem: '',
+  }
+
+  render() {
+    return (
+      <AutocompleteDropdown
+        items={items}
+        selectedItem={this.state.selectedItem}
+        onChange={selectedItem => { this.setState({ selectedItem }) }}
+        onInputChange={(value, filteredItems) => {
+          if (filteredItems.length === 0) {
+            console.log('input value', value)
+          }
+        }}
+      />
+    )
+  }
+}
+
+storiesOf('AutocompleteDropdown', module)
+  .add('default', () => (
+    <Wrapper />
+  ))

+ 16 - 4
src/components/molecules/WizardOptionsField/index.jsx

@@ -20,6 +20,7 @@ import { observer } from 'mobx-react'
 
 import Switch from '../../atoms/Switch'
 import TextInput from '../../atoms/TextInput'
+import AutocompleteDropdown from '../../molecules/AutocompleteDropdown'
 import Dropdown from '../../molecules/Dropdown'
 import InfoIcon from '../../atoms/InfoIcon'
 import PropertiesTable from '../../molecules/PropertiesTable'
@@ -136,14 +137,25 @@ class WizardOptionsField extends React.Component<Props> {
 
     let selectedItem = items.find(i => i.value === this.props.value)
 
+    if (items.length < 10) {
+      return (
+        <Dropdown
+          width={this.props.width || StyleProps.inputSizes.wizard.width}
+          noSelectionMessage="Choose a value"
+          selectedItem={selectedItem}
+          items={items}
+          dimFirstItem
+          onChange={item => this.props.onChange(item.value)}
+        />
+      )
+    }
+
     return (
-      <Dropdown
+      <AutocompleteDropdown
         width={this.props.width || StyleProps.inputSizes.wizard.width}
-        data-test-id={`wOptionsField-dropdown-${this.props.name}`}
-        noSelectionMessage="Choose a value"
         selectedItem={selectedItem}
         items={items}
-        dimFirstItem
+        dimNullValue
         onChange={item => this.props.onChange(item.value)}
       />
     )

+ 11 - 5
src/components/organisms/WizardPageContent/index.jsx

@@ -196,11 +196,17 @@ class WizardPageContent extends React.Component<Props, State> {
 
   isOptionsPageValid() {
     const isValid = (field: Field): boolean => {
-      return (this.props.wizardData.options &&
-        this.props.wizardData.options[field.name] !== null &&
-        this.props.wizardData.options[field.name] !== undefined &&
-        this.props.wizardData.options[field.name] !== ''
-      ) || (field.default !== null && field.default !== undefined)
+      if (this.props.wizardData.options) {
+        let fieldValue = this.props.wizardData.options[field.name]
+        if (fieldValue === null) {
+          return false
+        }
+        if (fieldValue === undefined) {
+          return field.default !== null && field.default !== undefined
+        }
+        return Boolean(fieldValue)
+      }
+      return field.default !== null && field.default !== undefined
     }
 
     let schema = this.props.providerStore.optionsSchema

+ 10 - 1
src/components/pages/WizardPage/index.jsx

@@ -270,7 +270,16 @@ class WizardPage extends React.Component<Props, State> {
       let findFieldInSchema = (name: string) => providerStore.optionsSchema.find(f => f.name === name)
       let validFields = providerWithExtraOptions.envRequiredFields.filter(fn => {
         let schemaField = findFieldInSchema(fn)
-        return wizardStore.data.options ? O.isValid(wizardStore.data.options[fn]) || (schemaField && schemaField.default) : false
+        if (wizardStore.data.options) {
+          if (wizardStore.data.options[fn] === null) {
+            return false
+          }
+          if (wizardStore.data.options[fn] === undefined && schemaField && schemaField.default) {
+            return true
+          }
+          return wizardStore.data.options[fn]
+        }
+        return false
       })
       let currentFieldValied = field ? validFields.find(fn => field ? fn === field.name : false) : true
       if (