diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fad31a71a..27df2cd651e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Decorated `pagination` _next_ and _previous_ buttons with `data-test-subj`. ([#1182](https://github.com/elastic/eui/pull/1182)) - Added `euiFacetButton` and `euiFacetGroup` ([#1167](https://github.com/elastic/eui/pull/1167)) - Added `width` prop to `EuiContextMenu` panels ([#1173](https://github.com/elastic/eui/pull/1173)) +- Added patterns for global query and filters ([#1137](https://github.com/elastic/eui/pull/1137)) **Bug fixes** diff --git a/src-docs/src/theme_dark.scss b/src-docs/src/theme_dark.scss index 37e1be026cf..4c26933b660 100644 --- a/src-docs/src/theme_dark.scss +++ b/src-docs/src/theme_dark.scss @@ -1,2 +1,3 @@ @import "../../src/theme_dark"; @import "./components/guide_components"; +@import "./views/header/global_filter_group"; diff --git a/src-docs/src/theme_k6_dark.scss b/src-docs/src/theme_k6_dark.scss index c42c1f6f630..270a2dbea0e 100644 --- a/src-docs/src/theme_k6_dark.scss +++ b/src-docs/src/theme_k6_dark.scss @@ -1,2 +1,3 @@ @import "../../src/theme_k6_dark"; @import "./components/guide_components"; +@import "./views/header/global_filter_group"; diff --git a/src-docs/src/theme_k6_light.scss b/src-docs/src/theme_k6_light.scss index 3a483817792..a899b00f3d6 100644 --- a/src-docs/src/theme_k6_light.scss +++ b/src-docs/src/theme_k6_light.scss @@ -1,2 +1,3 @@ @import "../../src/theme_k6_light"; @import "./components/guide_components"; +@import "./views/header/global_filter_group"; diff --git a/src-docs/src/theme_light.scss b/src-docs/src/theme_light.scss index ca47427d340..ce8c5b7e19f 100644 --- a/src-docs/src/theme_light.scss +++ b/src-docs/src/theme_light.scss @@ -1,3 +1,4 @@ @import "../../src/theme_light"; @import "./components/guide_components"; +@import "./views/header/global_filter_group"; diff --git a/src-docs/src/views/header/_global_filter_form.scss b/src-docs/src/views/header/_global_filter_form.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src-docs/src/views/header/_global_filter_group.scss b/src-docs/src/views/header/_global_filter_group.scss new file mode 100644 index 00000000000..8389257876a --- /dev/null +++ b/src-docs/src/views/header/_global_filter_group.scss @@ -0,0 +1,18 @@ +@import 'global_filter_item'; +@import 'global_filter_form'; + +.globalFilterGroup__filterBar { + margin-top: $euiSizeM; +} + +.globalFilterGroup__branch { + padding: $euiSize $euiSize $euiSizeS $euiSizeS; + background-repeat: no-repeat; + background-position: right top; + background-image: url("data:image/svg+xml,%0A%3Csvg width='28px' height='28px' viewBox='0 0 28 28' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='#{hexToRGB($euiColorLightShade)}'%3E%3Crect x='14' y='27' width='14' height='1'%3E%3C/rect%3E%3Crect x='0' y='0' width='1' height='14'%3E%3C/rect%3E%3C/g%3E%3C/svg%3E"); +} + +.globalFilterGroup__wrapper { + overflow: hidden; + transition: height $euiAnimSpeedNormal $euiAnimSlightResistance; +} diff --git a/src-docs/src/views/header/_global_filter_item.scss b/src-docs/src/views/header/_global_filter_item.scss new file mode 100644 index 00000000000..d0fb76ad63d --- /dev/null +++ b/src-docs/src/views/header/_global_filter_item.scss @@ -0,0 +1,30 @@ +.globalFilterItem { + line-height: $euiSizeL + $euiSizeXS; + border: none; + color: $euiTextColor; + + &:not(.globalFilterItem-isDisabled) { + @include euiFormControlDefaultShadow; + } +} + +.globalFilterItem-isDisabled { + background-color: transparentize($euiColorLightShade, .4); + text-decoration: line-through; + font-weight: $euiFontWeightRegular; + font-style: italic; +} + +.globalFilterItem-isPinned { + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: $euiSizeXS; + background-color: $euiColorVis0; + } +} diff --git a/src-docs/src/views/header/global_filter_add.js b/src-docs/src/views/header/global_filter_add.js new file mode 100644 index 00000000000..33707525984 --- /dev/null +++ b/src-docs/src/views/header/global_filter_add.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; + +import { + EuiButtonEmpty, + EuiPopover, + EuiPopoverTitle, + EuiFlexGroup, + EuiFlexItem, +} from '../../../../src/components'; + +import GlobalFilterForm from './global_filter_form'; + +export default class GlobalFilterAdd extends Component { + static propTypes = { + } + + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + closePopover = () => { + this.setState({ isPopoverOpen: false }); + }; + + render() { + const { isPopoverOpen } = this.state; + + return ( + + + Add filter + + } + anchorPosition="downCenter" + withTitle + > + + + Add a filter + + {/* This button should open a modal */} + Edit as Query DSL + + + + + + + ); + } +} diff --git a/src-docs/src/views/header/global_filter_bar.js b/src-docs/src/views/header/global_filter_bar.js new file mode 100644 index 00000000000..87f5ce586c4 --- /dev/null +++ b/src-docs/src/views/header/global_filter_bar.js @@ -0,0 +1,61 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { + EuiFlexGroup, + EuiFlexItem, +} from '../../../../src/components'; +import GlobalFilterAdd from './global_filter_add'; +import { GlobalFilterItem } from './global_filter_item'; + +export const GlobalFilterBar = ({ + filters, + className, + ...rest, +}) => { + + const classes = classNames( + 'globalFilterBar', + className, + ); + + const pinnedFilters = filters.filter(filter => filter.isPinned).map((filter) => { + return ( + + + + ); + }); + + const unpinnedFilters = filters.filter(filter => !filter.isPinned).map((filter) => { + return ( + + + + ); + }); + + return ( + + + {/* Show pinned filters first and in a specific group */} + {pinnedFilters} + {unpinnedFilters} + + + + ); +}; + + +GlobalFilterBar.propTypes = { + filters: PropTypes.array, +}; diff --git a/src-docs/src/views/header/global_filter_form.js b/src-docs/src/views/header/global_filter_form.js new file mode 100644 index 00000000000..9c4d6cd326a --- /dev/null +++ b/src-docs/src/views/header/global_filter_form.js @@ -0,0 +1,246 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +// import { pull } from 'lodash'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiFormRow, + EuiComboBox, + EuiButton, + EuiSpacer, + EuiSwitch, + EuiFieldText, +} from '../../../../src/components'; + +const fieldOptions = [ + { + label: 'Fields', + isGroupLabelOption: true, + }, + { + label: 'field_1', + }, + { + label: 'field_2', + }, + { + label: 'field_3', + }, + { + label: 'field_4', + }, +]; +const operatorOptions = [ + { + label: 'Operators', + isGroupLabelOption: true, + }, + { + label: 'IS', + }, + { + label: 'IS NOT', + }, + { + label: 'IS ONE OF', + }, + { + label: 'EXISTS', + }, +]; +const valueOptions = [ + { + label: 'Values', + isGroupLabelOption: true, + }, + { + label: 'Value 1', + }, + { + label: 'Value 2', + }, + { + label: 'Value 3', + }, + { + label: 'Value 4', + }, +]; + +export default class GlobalFilterForm extends Component { + static propTypes = { + onAdd: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + selectedObject: PropTypes.object, + }; + + constructor(props) { + super(props); + + this.state = { + fieldOptions: fieldOptions, + operandOptions: operatorOptions, + valueOptions: valueOptions, + selectedField: this.props.selectedObject ? this.props.selectedObject.field : [], + selectedOperand: this.props.selectedObject ? this.props.selectedObject.operand : [], + selectedValues: this.props.selectedObject ? this.props.selectedObject.values : [], + useCustomLabel: false, + customLabel: '', + }; + } + + onFieldChange = selectedOptions => { + // We should only get back either 0 or 1 options. + this.setState({ + selectedField: selectedOptions, + }); + }; + + onOperandChange = selectedOptions => { + // We should only get back either 0 or 1 options. + this.setState({ + selectedOperand: selectedOptions, + }); + }; + + onValuesChange = selectedOptions => { + this.setState({ + selectedValues: selectedOptions, + }); + }; + + onCustomLabelSwitchChange = e => { + this.setState({ + useCustomLabel: e.target.checked, + }); + }; + + onFieldSearchChange = searchValue => { + this.setState({ + fieldOptions: fieldOptions.filter(option => option.label.toLowerCase().includes(searchValue.toLowerCase())), + }); + }; + + onOperandSearchChange = searchValue => { + this.setState({ + operandOptions: operatorOptions.filter(option => option.label.toLowerCase().includes(searchValue.toLowerCase())), + }); + }; + + onValuesSearchChange = searchValue => { + this.setState({ + valueOptions: valueOptions.filter(option => option.label.toLowerCase().includes(searchValue.toLowerCase())), + }); + }; + + resetForm = () => { + this.setState({ + selectedField: [], + selectedOperand: [], + selectedValues: [], + useCustomLabel: false, + customLabel: null, + }); + } + + render() { + const { + onAdd, + onCancel, + selectedObject, + ...rest + } = this.props; + + return ( +
+ + + + + + + + + + + + + + + +
+ + + +
+ + + + + + {this.state.useCustomLabel && +
+ + + + +
+ } + + + + + + + Add + + + + + {selectedObject ? 'Cancel' : 'Reset form'} + + + + + {selectedObject && Delete} + + +
+ ); + } +} diff --git a/src-docs/src/views/header/global_filter_item.js b/src-docs/src/views/header/global_filter_item.js new file mode 100644 index 00000000000..b128197a83e --- /dev/null +++ b/src-docs/src/views/header/global_filter_item.js @@ -0,0 +1,195 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { EuiBadge, EuiPopover, EuiContextMenu } from '../../../../src/components'; +import GlobalFilterForm from './global_filter_form'; + +function flattenPanelTree(tree, array = []) { + array.push(tree); + + if (tree.items) { + tree.items.forEach(item => { + if (item.panel) { + flattenPanelTree(item.panel, array); + item.panel = item.panel.id; + } + }); + } + + return array; +} + +export class GlobalFilterItem extends Component { + static propTypes = { + className: PropTypes.string, + id: PropTypes.string.isRequired, + field: PropTypes.string.isRequired, + operator: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isDisabled: PropTypes.bool.isRequired, + isPinned: PropTypes.bool.isRequired, + isExcluded: PropTypes.bool.isRequired, + }; + + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + deleteFilter = (e) => { + window.alert('Filter would have been deleted.'); + // Make sure it doesn't also trigger the onclick for the whole badge + e.stopPropagation(); + } + + + render() { + const { + className, + id, + field, + operator, // eslint-disable-line no-unused-vars + value, + isDisabled, + isPinned, + isExcluded, + ...rest + } = this.props; + + const classes = classNames( + 'globalFilterItem', + { + 'globalFilterItem-isDisabled': isDisabled, + 'globalFilterItem-isPinned': isPinned, + 'globalFilterItem-isExcluded': isExcluded, + }, + className + ); + + let prefix = null; + if (isExcluded) { + prefix = NOT ; + } + + let title = `Filter: ${field}: "${value}". Select for more filter actions.`; + if (isPinned) { + title = `Pinned ${title}`; + } else if (isDisabled) { + title = `Disabled ${title}`; + } + + const badge = ( + + {prefix} + {field}: + "{value}" + + ); + + return this._createFilterContextMenu(this.props, badge); + } + + _createFilterContextMenu = (filter, button) => { + const selectedObject = { + field: [{ label: filter.field }], + operand: [{ label: filter.operator }], + values: [{ label: filter.value }], + }; + + const panelTree = { + id: 0, + items: [ + { + name: `${filter.isPinned ? 'Unpin' : 'Pin across all apps'}`, + icon: 'pin', + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Edit filter query', + icon: 'pencil', + panel: { + id: 1, + width: 400, + content: ( +
+ +
+ ), + }, + }, + { + name: `${filter.isExcluded ? 'Include results' : 'Exclude results'}`, + icon: `${filter.isExcluded ? 'plusInCircle' : 'minusInCircle'}`, + onClick: () => { + this.closePopover(); + }, + }, + { + name: `${filter.isDisabled ? 'Re-enable' : 'Temporarily disable'}`, + icon: `${filter.isDisabled ? 'eye' : 'eyeClosed'}`, + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Delete', + icon: 'trash', + onClick: () => { + this.closePopover(); + }, + }, + ], + }; + + return ( + + + + ); + }; +} diff --git a/src-docs/src/views/header/global_filter_options.js b/src-docs/src/views/header/global_filter_options.js new file mode 100644 index 00000000000..12e69b78635 --- /dev/null +++ b/src-docs/src/views/header/global_filter_options.js @@ -0,0 +1,127 @@ +import React, { Component } from 'react'; + +import { + EuiButtonIcon, + EuiPopover, + EuiContextMenu, + EuiPopoverTitle, +} from '../../../../src/components'; + +function flattenPanelTree(tree, array = []) { + array.push(tree); + + if (tree.items) { + tree.items.forEach(item => { + if (item.panel) { + flattenPanelTree(item.panel, array); + item.panel = item.panel.id; + } + }); + } + + return array; +} + +export default class GlobalFilterOptions extends Component { + static propTypes = { + } + + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + closePopover = () => { + this.setState({ isPopoverOpen: false }); + }; + + render() { + const { isPopoverOpen } = this.state; + + const panelTree = { + id: 0, + items: [ + { + name: 'Enable all', + icon: 'eye', + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Disable all', + icon: 'eyeClosed', + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Pin all', + icon: 'pin', + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Unpin all', + icon: 'pin', + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Invert inclusion', + icon: 'invert', + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Invert visibility', + icon: 'eye', + onClick: () => { + this.closePopover(); + }, + }, + { + name: 'Remove all', + icon: 'trash', + onClick: () => { + this.closePopover(); + }, + }, + ], + }; + + return ( + + } + anchorPosition="downCenter" + panelPaddingSize="none" + withTitle + > + Change all filters + + + ); + } +} diff --git a/src-docs/src/views/header/global_query.js b/src-docs/src/views/header/global_query.js new file mode 100644 index 00000000000..72f2068eaf5 --- /dev/null +++ b/src-docs/src/views/header/global_query.js @@ -0,0 +1,159 @@ +import React, { Component } from 'react'; +import classNames from 'classnames'; +import ResizeObserver from 'resize-observer-polyfill'; +import { + EuiFilterButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, +} from '../../../../src/components'; + +import { GlobalFilterBar } from './global_filter_bar'; +import GlobalFilterOptions from './global_filter_options'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + isFiltersVisible: true, + filters: [ + { + id: 'filter0', + field: '@tags.keyword', + operator: 'IS', + value: 'value', + isDisabled: false, + isPinned: true, + isExcluded: false, + }, + { + id: 'filter1', + field: '@tags.keyword', + operator: 'IS', + value: 'value', + isDisabled: true, + isPinned: false, + isExcluded: false, + }, + { + id: 'filter2', + field: '@tags.keyword', + operator: 'IS NOT', + value: 'value', + isDisabled: false, + isPinned: true, + isExcluded: true, + }, + { + id: 'filter3', + field: '@tags.keyword', + operator: 'IS', + value: 'value', + isDisabled: false, + isPinned: false, + isExcluded: false, + }, + ], + query: '', + }; + + this.ro = new ResizeObserver(this.setFilterBarHeight); + } + + setFilterBarHeight = () => { + requestAnimationFrame(() => { + const height = this.filterBar && this.state.isFiltersVisible ? this.filterBar.clientHeight + 4 : 0; + this.filterBarWrapper && this.filterBarWrapper.setAttribute('style', `height: ${height}px`); + }); + } + + componentDidMount() { + this.setFilterBarHeight(); + this.ro.observe(this.filterBar); + } + + componentDidUpdate() { + this.setFilterBarHeight(); + this.ro.unobserve(this.filterBar); + } + + toggleFilterVisibility = () => { + this.setState(prevState => ({ + isFiltersVisible: !prevState.isFiltersVisible, + })); + }; + + onQueryChange = e => { + this.setState({ + query: e.target.value, + }); + }; + + setFilterBarRef = (node) => { + this.filterBar = node; + } + + + render() { + const filterButtonTitle = `${this.state.filters.length} filters applied. Select to ${this.state.isFiltersVisible ? 'hide' : 'show'}.`; + + const filterTriggerButton = ( + 0 ? this.state.filters.length : null} + aria-controls="GlobalFilterGroup" + aria-expanded={!!this.state.isFiltersVisible} + title={filterButtonTitle} + > + Filters + + ); + + const classes = classNames( + 'globalFilterGroup__wrapper', + { + 'globalFilterGroup__wrapper-isVisible': this.state.isFiltersVisible, + }, + ); + + return ( + + + +
{ this.filterBarWrapper = node; }} + className={classes} + > +
+ + + + + + + + + +
+
+ +
+ ); + } +} diff --git a/src-docs/src/views/header/header_example.js b/src-docs/src/views/header/header_example.js index 3bbb1fa6970..8f9a91bceca 100644 --- a/src-docs/src/views/header/header_example.js +++ b/src-docs/src/views/header/header_example.js @@ -16,8 +16,15 @@ import { EuiCode, EuiHeaderLinks, EuiHeaderLink, + EuiCallOut, } from '../../../../src/components'; +import { GlobalFilterBar } from './global_filter_bar'; +import GlobalFilterAdd from './global_filter_add'; +import GlobalFilterOptions from './global_filter_options'; +import GlobalFilterForm from './global_filter_form'; +import { GlobalFilterItem } from './global_filter_item'; + import Header from './header'; const headerSource = require('!!raw-loader!./header'); const headerHtml = renderToHtml(Header); @@ -26,6 +33,10 @@ import HeaderLinks from './header_links'; const headerLinksSource = require('!!raw-loader!./header_links'); const headerLinksHtml = renderToHtml(HeaderLinks); +import GlobalQuery from './global_query'; +const globalQuerySource = require('!!raw-loader!./global_query'); +const globalQueryHtml = renderToHtml(GlobalQuery); + export const HeaderExample = { title: 'Header', sections: [{ @@ -70,5 +81,34 @@ export const HeaderExample = { EuiHeaderLink }, demo: , + }, { + title: 'Global query and filters', + source: [{ + type: GuideSectionTypes.JS, + code: globalQuerySource, + }, { + type: GuideSectionTypes.HTML, + code: globalQueryHtml, + }], + text: ( +
+ +

+ This documents a visual pattern for the eventual replacement of Kibana's + global query and filter bars. The filter bar has been broken down into multiple components. There + are still bugs and not all the logical is well-formed. +

+
+
+ ), + props: { + GlobalQuery, + GlobalFilterBar, + GlobalFilterOptions, + GlobalFilterAdd, + GlobalFilterForm, + GlobalFilterItem, + }, + demo: , }], };