Repository: uber-web/baseui
Pull request #182 (2018-09-04 17:47:10)

feat(select): support custom option filter
This PR has two main changes:

1. A `filterOption` prop with type `(option: Object, query: String) => boolean`.

This very simple addition allows the user for far more control over how options are filtered. For example, if using the new MenuProfileOption (#132), you can customize the search so that it looks at the title, subtitle, and any other metadata. This is something that react-select and many other component libraries support.

2. Rename the `simpleFilter` boolean prop to `filterable`.

Now that we allow for more complicated filtering, the name `simpleFilter` doesn't really seem to make sense. `filterable` seems more appropriate.

Even though this diff renamed a prop, we haven't published a release with the current Select API yet, so this shouldn't be considered a breaking change.


Patch:
diff --git a/rfcs/select-component.md b/rfcs/select-component.md
index 8c35530d0..11d1e56ff 100644
--- a/rfcs/select-component.md
+++ b/rfcs/select-component.md
@@ -29,10 +29,16 @@
   Error message and error state. Default is `''`
 - `multiple: ?boolean`:
   Sets if multiple choices are allowed in Select component. Default is `false`
-- `simpleFilter: ?boolean`:
-  Sets if simple String lookup should filter options by default on keyUp event. It works only in Search mode and with `string` labels (case-insensitive). Custom keyUp handler is still called if provided. Default is `false`
+- `filterable?: boolean`:
+  Sets if options should be filtered when the input changes. It works only in Search mode. If your option labels aren't strings, or you need custom filtering logic, use the `filterOption` prop. Custom keyUp handler is still called if provided. Default is `false`
+- `filterOption?: (option: Object, query: String) => boolean`:
+  Custom method to filter whether an option should be displayed in the menu. Defaults to a simple lower-case string match.
+- `getOptionLabel?: (option: Object) => React$Node`:
+  Lets you control what label is rendered for the specified option. The default behavior returns `option.label`.
+- `getSelectedOptionLabel?: (option: Object) => React$Node`:
+  Lets you control what label is rendered for the selected option. If not specified, defaults to `getOptionLabel`.
 - `autoFocus: boolean`:
-  make the control focused (active). Default is `false`
+  Make the control focused (active). Default is `false`
 - `disabled: boolean`:
   Disable control from being changed
 - `required: boolean`:
diff --git a/src/select/examples.js b/src/select/examples.js
index a2c25a43f..490334cee 100644
--- a/src/select/examples.js
+++ b/src/select/examples.js
@@ -16,7 +16,7 @@ import {STATE_CHANGE_TYPE} from './constants';
 type ExamplePropsT = {
   multiple?: boolean,
   options?: Array<OptionT>,
-  simpleFilter?: boolean,
+  filterable?: boolean,
   getOptionLabel?: LabelT => React$Node,
   getSelectedOptionLabel?: LabelT => React$Node,
 };
@@ -42,7 +42,7 @@ class ParentSearch extends React.Component<ExamplePropsT, ExampleStateT> {
   constructor(props: ExamplePropsT) {
     super(props);
     this.state = {
-      options: props.simpleFilter ? generateOptions('text to search') : [],
+      options: props.filterable ? generateOptions('text to search') : [],
       selectedOptions: [
         {
           id: '123',
@@ -58,7 +58,7 @@ class ParentSearch extends React.Component<ExamplePropsT, ExampleStateT> {
           rows={8}
           options={this.state.options}
           multiple={true}
-          simpleFilter={this.props.simpleFilter}
+          filterable={this.props.filterable}
           type={TYPE.search}
           initialState={{
             selectedOptions: this.state.selectedOptions,
@@ -83,7 +83,7 @@ class ParentSearch extends React.Component<ExamplePropsT, ExampleStateT> {
                 // $FlowFixMe
                 let text = e.target.value;
                 let options = [];
-                if (!this.props.simpleFilter && text.length > 5) {
+                if (!this.props.filterable && text.length > 5) {
                   options = generateOptions(text);
                   this.setState({options: options});
                 }
@@ -177,7 +177,7 @@ export default {
     return <ParentSearch />;
   },
   [tests.AS_SEARCH_WITH_SIMPLE_SEARCH]: () => {
-    return <ParentSearch simpleFilter />;
+    return <ParentSearch filterable />;
   },
   [tests.AS_SELECT_WITH_MULTIPLE_CHOICE]: () => {
     return <ParentSelect multiple={true} />;
diff --git a/src/select/select.js b/src/select/select.js
index e0430ce62..092e96d39 100644
--- a/src/select/select.js
+++ b/src/select/select.js
@@ -42,6 +42,13 @@ class Select extends React.Component<PropsT, StatelessStateT> {
     onMouseUp: () => {},
     error: '',
     autoFocus: false,
+    filterable: false,
+    filterOption: (option: OptionT, query: string) => {
+      return (
+        typeof option.label === 'string' &&
+        option.label.toLowerCase().indexOf(query.toLowerCase()) >= 0
+      );
+    },
   };
 
   state = {
@@ -143,12 +150,9 @@ class Select extends React.Component<PropsT, StatelessStateT> {
         // $FlowFixMe
         const newTextValue = e.target.value;
         this.setState({textValue: newTextValue});
-        if (this.props.simpleFilter) {
-          let filteredOptions = this.props.options.filter(
-            option =>
-              typeof option.label === 'string' &&
-              option.label.toLowerCase().indexOf(newTextValue.toLowerCase()) >=
-                0,
+        if (this.props.filterable) {
+          let filteredOptions = this.props.options.filter(option =>
+            this.props.filterOption(option, newTextValue),
           );
           filteredOptions = filteredOptions.length
             ? filteredOptions
diff --git a/src/select/types.js b/src/select/types.js
index d366bfb85..c003caac3 100644
--- a/src/select/types.js
+++ b/src/select/types.js
@@ -69,7 +69,8 @@ export type PropsT = {
   error?: string,
   autoFocus?: boolean,
   type?: string,
-  simpleFilter?: boolean,
+  filterable: boolean,
+  filterOption: (OptionT, string) => boolean,
   label?: string,
   placeholder?: string,
   getOptionLabel?: OptionT => React$Node,


Test patch:
diff --git a/src/select/__tests__/__snapshots__/select.test.js.snap b/src/select/__tests__/__snapshots__/select.test.js.snap
index 52471f954..fe714f1cd 100644
--- a/src/select/__tests__/__snapshots__/select.test.js.snap
+++ b/src/select/__tests__/__snapshots__/select.test.js.snap
@@ -4,6 +4,8 @@ exports[`Stateless select should render component in search mode and false for m
 <Select
   autoFocus={false}
   error=""
+  filterOption={[Function]}
+  filterable={false}
   multiple={false}
   onBlur={[MockFunction]}
   onChange={[MockFunction]}
@@ -206,6 +208,8 @@ exports[`Stateless select should render component in search mode and true for mu
 <Select
   autoFocus={false}
   error=""
+  filterOption={[Function]}
+  filterable={false}
   multiple={true}
   onBlur={[MockFunction]}
   onChange={[MockFunction]}
@@ -408,6 +412,8 @@ exports[`Stateless select should render component in select mode and false for m
 <Select
   autoFocus={false}
   error=""
+  filterOption={[Function]}
+  filterable={false}
   multiple={false}
   onBlur={[MockFunction]}
   onChange={[MockFunction]}
@@ -589,6 +595,8 @@ exports[`Stateless select should render component in select mode and true for mu
 <Select
   autoFocus={false}
   error=""
+  filterOption={[Function]}
+  filterable={false}
   multiple={true}
   onBlur={[MockFunction]}
   onChange={[MockFunction]}
diff --git a/src/select/__tests__/select.test.js b/src/select/__tests__/select.test.js
index 2c14a6848..4016e979d 100644
--- a/src/select/__tests__/select.test.js
+++ b/src/select/__tests__/select.test.js
@@ -7,7 +7,7 @@ LICENSE file in the root directory of this source tree.
 // @flow
 import React from 'react';
 import {mount, shallow} from 'enzyme';
-import {Select, StyledSearchIcon, StyledInput} from '../index';
+import {Select, StyledSearchIcon, StyledInput, StyledOption} from '../index';
 import {STATE_CHANGE_TYPE, TYPE, ICON} from '../constants';
 
 describe('Stateless select', function() {
@@ -250,6 +250,87 @@ describe('Stateless select', function() {
         textValue: value,
       });
     });
+
+    test('should support simple filtering', function() {
+      allProps = Object.assign({}, allProps, {
+        type: TYPE.search,
+        filterable: true,
+        options: [
+          {
+            id: 'aaa',
+            label: 'AAA',
+          },
+          {
+            id: 'aab',
+            label: 'AAB',
+          },
+          {
+            id: 'abb',
+            label: 'ABB',
+          },
+        ],
+      });
+      wrapper = mount(<Select {...allProps} />);
+
+      let input = wrapper
+        .find(StyledInput)
+        .first()
+        .find('input');
+
+      input.simulate('keyup', {target: {value: 'a'}});
+      expect(wrapper.find(StyledOption)).toHaveLength(3);
+
+      input.simulate('keyup', {target: {value: 'aa'}});
+      expect(wrapper.find(StyledOption)).toHaveLength(2);
+
+      input.simulate('keyup', {target: {value: 'aaa'}});
+      expect(wrapper.find(StyledOption)).toHaveLength(1);
+
+      input.simulate('keyup', {target: {value: 'aaaa'}});
+      expect(wrapper.find(StyledOption)).toHaveLength(0);
+    });
+
+    test('should support custom filter option', function() {
+      allProps = Object.assign({}, allProps, {
+        type: TYPE.search,
+        filterable: true,
+        getOptionLabel: option => option.title,
+        options: [
+          {
+            id: 'a',
+            label: {
+              title: 'foo',
+              subtitle: 'bar',
+            },
+          },
+          {
+            id: 'b',
+            label: {
+              title: 'abc',
+              subtitle: 'bar',
+            },
+          },
+        ],
+        filterOption: jest.fn().mockImplementation((option, query) => {
+          return (
+            option.label.title.indexOf(query) >= 0 ||
+            option.label.subtitle.indexOf(query) >= 0
+          );
+        }),
+      });
+      wrapper = mount(<Select {...allProps} />);
+
+      let input = wrapper
+        .find(StyledInput)
+        .first()
+        .find('input');
+
+      input.simulate('keyup', {target: {value: 'xyz'}});
+      expect(wrapper.find(StyledOption).length).toBe(0);
+
+      input.simulate('keyup', {target: {value: 'ar'}});
+      expect(wrapper.find(StyledOption).length).toBe(2);
+    });
   });
   describe('Select mode', function() {
     test('should toggle dropdown if input is clicked', function() {