diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b1a4b321035..f0ce27e6e10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +**Breaking changes** + +- `EuiSearchBar` no longer has an `onParse` callback, and now passes an object to `onChange` with the shape `{ query, queryText, error }` ([#863](https://github.com/elastic/eui/pull/863)) +- `EuiInMemoryTable`'s `search.onChange` callback now passes an object with `{ query, queryText, error }` instead of only the query ([#863](https://github.com/elastic/eui/pull/863)) + **Bug fixes** - `EuiButton`, `EuiButtonEmpty`, and `EuiButtonIcon` now look and behave disabled when `isDisabled={true}` ([#862](https://github.com/elastic/eui/pull/862)) diff --git a/src-docs/src/views/search_bar/controlled_search_bar.js b/src-docs/src/views/search_bar/controlled_search_bar.js new file mode 100644 index 000000000000..de5800148f31 --- /dev/null +++ b/src-docs/src/views/search_bar/controlled_search_bar.js @@ -0,0 +1,312 @@ +import React, { Component, Fragment } from 'react'; +import { times } from 'lodash'; +import { Random } from '../../../../src/services/random'; +import { + EuiHealth, + EuiCallOut, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiBasicTable, + EuiSearchBar, + EuiButton, +} from '../../../../src/components'; + +const random = new Random(); + +const tags = [ + { name: 'marketing', color: 'danger' }, + { name: 'finance', color: 'success' }, + { name: 'eng', color: 'success' }, + { name: 'sales', color: 'warning' }, + { name: 'ga', color: 'success' } +]; + +const types = [ + 'dashboard', + 'visualization', + 'watch', +]; + +const users = [ + 'dewey', + 'wanda', + 'carrie', + 'jmack', + 'gabic', +]; + +const items = times(10, (id) => { + return { + id, + status: random.oneOf(['open', 'closed']), + type: random.oneOf(types), + tag: random.setOf(tags.map(tag => tag.name), { min: 0, max: 3 }), + active: random.boolean(), + owner: random.oneOf(users), + followers: random.integer({ min: 0, max: 20 }), + comments: random.integer({ min: 0, max: 10 }), + stars: random.integer({ min: 0, max: 5 }) + }; +}); + +const loadTags = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(tags.map(tag => ({ + value: tag.name, + view: {tag.name} + }))); + }, 2000); + }); +}; + +const initialQuery = EuiSearchBar.Query.MATCH_ALL; + +export class ControlledSearchBar extends Component { + + constructor(props) { + super(props); + this.state = { + query: initialQuery, + result: items, + error: null, + incremental: false + }; + } + + onChange = ({ query, error }) => { + if (error) { + this.setState({ error }); + } else { + this.setState({ + error: null, + result: EuiSearchBar.Query.execute(query, items, { defaultFields: ['owner', 'tag', 'type'] }), + query + }); + } + }; + + toggleIncremental = () => { + this.setState(prevState => ({ incremental: !prevState.incremental })); + }; + + setQuery = query => { + this.setState({ query }); + } + + renderBookmarks() { + return ( + +

Enter a query, or select one from a bookmark

+ + + + this.setQuery('status:open owner:dewey')}>mine, open + + + this.setQuery('status:closed owner:dewey')}>mine, closed + + + +
+ ); + } + + renderSearch() { + const { incremental } = this.state; + + const filters = [ + { + type: 'field_value_toggle_group', + field: 'status', + items: [ + { + value: 'open', + name: 'Open' + }, + { + value: 'closed', + name: 'Closed' + } + ] + }, + { + type: 'is', + field: 'active', + name: 'Active', + negatedName: 'Inactive' + }, + { + type: 'field_value_toggle', + name: 'Mine', + field: 'owner', + value: 'dewey' + }, + { + type: 'field_value_selection', + field: 'tag', + name: 'Tag', + multiSelect: 'or', + cache: 10000, // will cache the loaded tags for 10 sec + options: () => loadTags() + } + ]; + + const schema = { + strict: true, + fields: { + active: { + type: 'boolean' + }, + status: { + type: 'string' + }, + followers: { + type: 'number' + }, + comments: { + type: 'number' + }, + stars: { + type: 'number' + }, + created: { + type: 'date' + }, + owner: { + type: 'string' + }, + tag: { + type: 'string', + validate: (value) => { + if (!tags.some(tag => tag.name === value)) { + throw new Error(`unknown tag (possible values: ${tags.map(tag => tag.name).join(',')})`); + } + } + } + } + }; + + return ( + + ); + } + + renderError() { + const { error } = this.state; + if (!error) { + return; + } + return ( + + + + + ); + } + + renderTable() { + const columns = [ + { + name: 'Type', + field: 'type' + }, + { + name: 'Open', + field: 'status', + render: (status) => status === 'open' ? 'Yes' : 'No' + }, + { + name: 'Active', + field: 'active', + dataType: 'boolean' + }, + { + name: 'Tags', + field: 'tag' + }, + { + name: 'Owner', + field: 'owner' + }, + { + name: 'Stats', + width: '150px', + render: (item) => { + return ( +
+
{`${item.stars} Stars`}
+
{`${item.followers} Followers`}
+
{`${item.comments} Comments`}
+
+ ); + } + } + ]; + + const queriedItems = EuiSearchBar.Query.execute(this.state.query, items, { + defaultFields: ['owner', 'tag', 'type'] + }); + + return ( + + ); + } + + render() { + const { + incremental, + } = this.state; + + const content = this.renderError() || ( + + + {this.renderTable()} + + + ); + + return ( + + + + {this.renderBookmarks()} + + + + + {this.renderSearch()} + + + + + + + + {content} + + ); + } +} diff --git a/src-docs/src/views/search_bar/props_info.js b/src-docs/src/views/search_bar/props_info.js index a6abf55c7a62..fc9bea07463a 100644 --- a/src-docs/src/views/search_bar/props_info.js +++ b/src-docs/src/views/search_bar/props_info.js @@ -4,15 +4,10 @@ export const propsInfo = { __docgenInfo: { props: { onChange: { - description: 'Called every time the query behind the search bar changes', - required: true, - type: { name: '(query: #Query) => void' } - }, - onParse: { description: 'Called every time the text query in the search box is parsed. When parsing is successful ' + 'the callback will receive both the query text and the parsed query. When it fails ' + 'the callback ill receive the query text and an error object (holding the error message)', - required: false, + required: true, type: { name: '({ query?: #Query, queryText: string, error?: { message: string } }) => void' } }, query: { diff --git a/src-docs/src/views/search_bar/search_bar.js b/src-docs/src/views/search_bar/search_bar.js index 96d1b28f11ee..9ef817d670a7 100644 --- a/src-docs/src/views/search_bar/search_bar.js +++ b/src-docs/src/views/search_bar/search_bar.js @@ -77,16 +77,16 @@ export class SearchBar extends Component { }; } - onParse = ({ error }) => { - this.setState({ error }); - }; - - onChange = (query) => { - this.setState({ - error: null, - result: EuiSearchBar.Query.execute(query, items, { defaultFields: ['owner', 'tag', 'type'] }), - query - }); + onChange = ({ query, error }) => { + if (error) { + this.setState({ error }); + } else { + this.setState({ + error: null, + result: EuiSearchBar.Query.execute(query, items, { defaultFields: ['owner', 'tag', 'type'] }), + query + }); + } }; toggleIncremental = () => { @@ -178,7 +178,6 @@ export class SearchBar extends Component { }} filters={filters} onChange={this.onChange} - onParse={this.onParse} /> ); } diff --git a/src-docs/src/views/search_bar/search_bar_example.js b/src-docs/src/views/search_bar/search_bar_example.js index 89a91c79dccc..6be26cf0582a 100644 --- a/src-docs/src/views/search_bar/search_bar_example.js +++ b/src-docs/src/views/search_bar/search_bar_example.js @@ -12,10 +12,14 @@ import { } from '../../../../src/components'; import { SearchBar } from './search_bar'; +import { ControlledSearchBar } from './controlled_search_bar'; const searchBarSource = require('!!raw-loader!./search_bar'); const searchBarHtml = renderToHtml(SearchBar); +const controlledSearchBarSource = require('!!raw-loader!./controlled_search_bar'); +const controlledSearchBarHtml = renderToHtml(ControlledSearchBar); + export const SearchBarExample = { title: 'Search Bar', sections: [ @@ -89,6 +93,26 @@ export const SearchBarExample = { ), props: propsInfo, demo: + }, + { + title: 'Controlled Search Bar', + source: [ + { + type: GuideSectionTypes.JS, + code: controlledSearchBarSource, + }, { + type: GuideSectionTypes.HTML, + code: controlledSearchBarHtml, + } + ], + text: ( +
+

+ A EuiSearchBar can have its query controlled by a parent component by passing the query prop. Changes to the query will be passed back up through the onChange callback where the new Query must be stored in state and passed back into the search bar. +

+
+ ), + demo: } ], }; diff --git a/src-docs/src/views/tables/in_memory/in_memory_search_callback.js b/src-docs/src/views/tables/in_memory/in_memory_search_callback.js index 05a3ba8f6952..5e8c4fc4ff4d 100644 --- a/src-docs/src/views/tables/in_memory/in_memory_search_callback.js +++ b/src-docs/src/views/tables/in_memory/in_memory_search_callback.js @@ -44,7 +44,7 @@ export class Table extends React.Component { }; } - onQueryChange = query => { + onQueryChange = ({ query }) => { clearTimeout(debounceTimeoutId); clearTimeout(requestTimeoutId); diff --git a/src/components/basic_table/in_memory_table.js b/src/components/basic_table/in_memory_table.js index f449a2b0fdd6..396b4c0b9a6a 100644 --- a/src/components/basic_table/in_memory_table.js +++ b/src/components/basic_table/in_memory_table.js @@ -160,9 +160,9 @@ export class EuiInMemoryTable extends Component { }); }; - onQueryChange = (query) => { + onQueryChange = ({ query, queryText, error }) => { if (this.props.search.onChange) { - const shouldQueryInMemory = this.props.search.onChange(query); + const shouldQueryInMemory = this.props.search.onChange({ query, queryText, error }); if (!shouldQueryInMemory) { return; } diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js index 5b1e00caab28..9e5a19a1b8e4 100644 --- a/src/components/search_bar/search_bar.js +++ b/src/components/search_bar/search_bar.js @@ -16,15 +16,10 @@ import { EuiFlexItem } from '../flex/flex_item'; export const QueryType = PropTypes.oneOfType([ PropTypes.instanceOf(Query), PropTypes.string ]); export const SearchBarPropTypes = { - /** - * (query: Query) => void - */ - onChange: PropTypes.func.isRequired, - /** (query?: Query, queryText: string, error?: string) => void */ - onParse: PropTypes.func, + onChange: PropTypes.func.isRequired, /** The initial query the bar will hold when first mounted @@ -57,15 +52,17 @@ export const SearchBarPropTypes = { * Tools which go to the right of the search bar. */ toolsRight: PropTypes.node, + + /** + * Date formatter to use when parsing date values + */ + dateFormat: PropTypes.object }; const parseQuery = (query, props) => { - const parseDate = props.box ? props.box.parseDate : undefined; const schema = props.box ? props.box.schema : undefined; - const parseOptions = { - parseDate, - schema - }; + const dateFormat = props.dateFormat; + const parseOptions = { schema, dateFormat }; if (!query) { return Query.parse('', parseOptions); } @@ -74,7 +71,7 @@ const parseQuery = (query, props) => { export class EuiSearchBar extends Component { - static propTypes = SearchBoxConfigPropTypes; + static propTypes = SearchBarPropTypes; static Query = Query; @@ -88,31 +85,38 @@ export class EuiSearchBar extends Component { }; } - // TODO: React 16.3 - getDerivedStateFromProps - componentWillReceiveProps(nextProps) { + static getDerivedStateFromProps(nextProps) { if (nextProps.query) { - const query = parseQuery(nextProps.query, this.props); - this.setState({ + const query = parseQuery(nextProps.query, nextProps); + return { query, queryText: query.text, error: null - }); + }; + } + return null; + } + + componentDidUpdate(oldProps, oldState) { + const { query, queryText, error } = this.state; + + const isQueryDifferent = oldState.queryText !== queryText; + + const oldError = oldState.error ? oldState.error.message : null; + const newError = error ? error.message : null; + const isErrorDifferent = oldError !== newError; + + if (isQueryDifferent || isErrorDifferent) { + this.props.onChange({ query, queryText, error }); } } onSearch = (queryText) => { try { const query = parseQuery(queryText, this.props); - if (this.props.onParse) { - this.props.onParse({ query, queryText }); - } this.setState({ query, queryText, error: null }); - this.props.onChange(query); } catch (e) { const error = { message: e.message }; - if (this.props.onParse) { - this.props.onParse({ queryText, error }); - } this.setState({ queryText, error }); } }; @@ -123,7 +127,6 @@ export class EuiSearchBar extends Component { queryText: query.text, error: null }); - this.props.onChange(query); }; renderTools(tools) { diff --git a/src/components/search_bar/search_bar.test.js b/src/components/search_bar/search_bar.test.js index 8289a5c2f300..5e25eac0dcaf 100644 --- a/src/components/search_bar/search_bar.test.js +++ b/src/components/search_bar/search_bar.test.js @@ -1,7 +1,9 @@ +/* eslint-disable react/no-multi-comp */ import React from 'react'; import { requiredProps } from '../../test'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { EuiSearchBar } from './search_bar'; +import { Query } from './query'; describe('SearchBar', () => { test('render - no config, no query', () => { @@ -75,4 +77,40 @@ describe('SearchBar', () => { expect(component).toMatchSnapshot(); }); + + describe('controlled input', () => { + test('calls onChange callback when a new query is passed', () => { + const onChange = jest.fn(); + + const component = mount( + + ); + + component.setProps({ query: 'is:active' }); + + expect(onChange).toHaveBeenCalledTimes(1); + const [[{ query, queryText }]] = onChange.mock.calls; + expect(query).toBeInstanceOf(Query); + expect(queryText).toBe('is:active'); + }); + + test('does not call onChange when an unwatched prop changes', () => { + const onChange = jest.fn(); + + const component = mount( + + ); + + component.setProps({ isFoo: true }); + + expect(onChange).toHaveBeenCalledTimes(0); + }); + }); });