mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Discover] EUI-fication of the filters (#48452)
* New filter UI * Prototype of functionality * EUIFication of filters in Discover * Adding tests for field_selector component * Replacing FieldSelector with EuiForm components * Adding more discover field search tests * Removing console statement * Fixing failing functional test * Removing obsolete snapshot * design tweaks for filter popover * use compressed style inputs * Changing selectors to be EuiButtonGroup * Removing unnecessary if statement * Getting rid of ts-ignore warning
This commit is contained in:
parent
f4f43f5ad7
commit
559ac840c5
6 changed files with 345 additions and 90 deletions
|
@ -27,3 +27,11 @@
|
|||
padding-left: $euiSizeXS;
|
||||
margin-left: $euiSizeXS;
|
||||
}
|
||||
|
||||
.dscFieldSearch__filterWrapper {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.dscFieldSearch__formWrapper {
|
||||
padding: $euiSizeM;
|
||||
}
|
||||
|
|
|
@ -17,54 +17,123 @@
|
|||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
// @ts-ignore
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { DiscoverFieldSearch } from './discover_field_search';
|
||||
import { DiscoverFieldSearch, Props } from './discover_field_search';
|
||||
import { EuiButtonGroupProps } from '@elastic/eui';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
|
||||
describe('DiscoverFieldSearch', () => {
|
||||
function mountComponent() {
|
||||
const props = {
|
||||
onChange: jest.fn(),
|
||||
onShowFilter: jest.fn(),
|
||||
showFilter: false,
|
||||
filtersActive: 0,
|
||||
value: 'test',
|
||||
};
|
||||
const comp = mountWithIntl(<DiscoverFieldSearch {...props} />);
|
||||
const input = findTestSubject(comp, 'fieldFilterSearchInput');
|
||||
const btn = findTestSubject(comp, 'toggleFieldFilterButton');
|
||||
return { comp, input, btn, props };
|
||||
const defaultProps = {
|
||||
onChange: jest.fn(),
|
||||
value: 'test',
|
||||
types: ['any', 'string', '_source'],
|
||||
};
|
||||
|
||||
function mountComponent(props?: Props) {
|
||||
const compProps = props || defaultProps;
|
||||
const comp = mountWithIntl(<DiscoverFieldSearch {...compProps} />);
|
||||
return comp;
|
||||
}
|
||||
|
||||
function findButtonGroup(component: ReactWrapper, id: string) {
|
||||
return component.find(`[data-test-subj="${id}ButtonGroup"]`).first();
|
||||
}
|
||||
|
||||
test('enter value', () => {
|
||||
const { input, props } = mountComponent();
|
||||
const component = mountComponent();
|
||||
const input = findTestSubject(component, 'fieldFilterSearchInput');
|
||||
input.simulate('change', { target: { value: 'new filter' } });
|
||||
expect(props.onChange).toBeCalledTimes(1);
|
||||
expect(defaultProps.onChange).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
// this should work, but doesn't, have to do some research
|
||||
test('click toggle filter button', () => {
|
||||
const { btn, props } = mountComponent();
|
||||
btn.simulate('click');
|
||||
expect(props.onShowFilter).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('change showFilter value should change aria label', () => {
|
||||
const { comp } = mountComponent();
|
||||
let btn = findTestSubject(comp, 'toggleFieldFilterButton');
|
||||
expect(btn.prop('aria-label')).toEqual('Show field filter settings');
|
||||
comp.setProps({ showFilter: true });
|
||||
btn = findTestSubject(comp, 'toggleFieldFilterButton');
|
||||
expect(btn.prop('aria-label')).toEqual('Hide field filter settings');
|
||||
});
|
||||
|
||||
test('change filtersActive should change facet selection', () => {
|
||||
const { comp } = mountComponent();
|
||||
let btn = findTestSubject(comp, 'toggleFieldFilterButton');
|
||||
test('change in active filters should change facet selection and call onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
const component = mountComponent({ ...defaultProps, ...{ onChange } });
|
||||
let btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
expect(btn.hasClass('euiFacetButton--isSelected')).toBeFalsy();
|
||||
comp.setProps({ filtersActive: 3 });
|
||||
btn = findTestSubject(comp, 'toggleFieldFilterButton');
|
||||
btn.simulate('click');
|
||||
const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable');
|
||||
act(() => {
|
||||
// @ts-ignore
|
||||
(aggregatableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null);
|
||||
});
|
||||
component.update();
|
||||
btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
expect(btn.hasClass('euiFacetButton--isSelected')).toBe(true);
|
||||
expect(onChange).toBeCalledWith('aggregatable', true);
|
||||
});
|
||||
|
||||
test('change in active filters should change filters count', () => {
|
||||
const component = mountComponent();
|
||||
let btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
btn.simulate('click');
|
||||
btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
const badge = btn.find('.euiNotificationBadge');
|
||||
// no active filters
|
||||
expect(badge.text()).toEqual('0');
|
||||
// change value of aggregatable select
|
||||
const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable');
|
||||
act(() => {
|
||||
// @ts-ignore
|
||||
(aggregatableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null);
|
||||
});
|
||||
component.update();
|
||||
expect(badge.text()).toEqual('1');
|
||||
// change value of searchable select
|
||||
const searchableButtonGroup = findButtonGroup(component, 'searchable');
|
||||
act(() => {
|
||||
// @ts-ignore
|
||||
(searchableButtonGroup.props() as EuiButtonGroupProps).onChange('searchable-true', null);
|
||||
});
|
||||
component.update();
|
||||
expect(badge.text()).toEqual('2');
|
||||
// change value of searchable select
|
||||
act(() => {
|
||||
// @ts-ignore
|
||||
(searchableButtonGroup.props() as EuiButtonGroupProps).onChange('searchable-any', null);
|
||||
});
|
||||
component.update();
|
||||
expect(badge.text()).toEqual('1');
|
||||
});
|
||||
|
||||
test('change in missing fields switch should not change filter count', () => {
|
||||
const component = mountComponent();
|
||||
const btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
btn.simulate('click');
|
||||
const badge = btn.find('.euiNotificationBadge');
|
||||
expect(badge.text()).toEqual('0');
|
||||
const missingSwitch = findTestSubject(component, 'missingSwitch');
|
||||
missingSwitch.simulate('change', { target: { value: false } });
|
||||
expect(badge.text()).toEqual('0');
|
||||
});
|
||||
|
||||
test('change in filters triggers onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
const component = mountComponent({ ...defaultProps, ...{ onChange } });
|
||||
const btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
btn.simulate('click');
|
||||
const aggregtableButtonGroup = findButtonGroup(component, 'aggregatable');
|
||||
const missingSwitch = findTestSubject(component, 'missingSwitch');
|
||||
act(() => {
|
||||
// @ts-ignore
|
||||
(aggregtableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null);
|
||||
});
|
||||
missingSwitch.simulate('change', { target: { value: false } });
|
||||
expect(onChange).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
test('change in type filters triggers onChange with appropriate value', () => {
|
||||
const onChange = jest.fn();
|
||||
const component = mountComponent({ ...defaultProps, ...{ onChange } });
|
||||
const btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
btn.simulate('click');
|
||||
const typeSelector = findTestSubject(component, 'typeSelect');
|
||||
typeSelector.simulate('change', { target: { value: 'string' } });
|
||||
expect(onChange).toBeCalledWith('type', 'string');
|
||||
typeSelector.simulate('change', { target: { value: 'any' } });
|
||||
expect(onChange).toBeCalledWith('type', 'any');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,61 +16,234 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import React, { OptionHTMLAttributes, ReactNode, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiFacetButton, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
|
||||
import {
|
||||
EuiFacetButton,
|
||||
EuiFieldSearch,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiIcon,
|
||||
EuiPopover,
|
||||
EuiPopoverFooter,
|
||||
EuiPopoverTitle,
|
||||
EuiSelect,
|
||||
EuiSwitch,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiButtonGroup,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
export interface State {
|
||||
searchable: string;
|
||||
aggregatable: string;
|
||||
type: string;
|
||||
missing: boolean;
|
||||
[index: string]: string | boolean;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
/**
|
||||
* triggered on input of user into search field
|
||||
*/
|
||||
onChange: (field: string, value: string) => void;
|
||||
/**
|
||||
* triggered when the "additional filter btn" is clicked
|
||||
*/
|
||||
onShowFilter: () => void;
|
||||
/**
|
||||
* determines whether additional filter fields are displayed
|
||||
*/
|
||||
showFilter: boolean;
|
||||
onChange: (field: string, value: string | boolean | undefined) => void;
|
||||
|
||||
/**
|
||||
* the input value of the user
|
||||
*/
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* the number of selected filters
|
||||
* types for the type filter
|
||||
*/
|
||||
filtersActive: number;
|
||||
types: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component is Discover's side bar to search of available fields
|
||||
* Additionally there's a button displayed that allows the user to show/hide more filter fields
|
||||
*/
|
||||
export function DiscoverFieldSearch({
|
||||
showFilter,
|
||||
onChange,
|
||||
onShowFilter,
|
||||
value,
|
||||
filtersActive,
|
||||
}: Props) {
|
||||
export function DiscoverFieldSearch({ onChange, value, types }: Props) {
|
||||
if (typeof value !== 'string') {
|
||||
// at initial rendering value is undefined (angular related), this catches the warning
|
||||
// should be removed once all is react
|
||||
return null;
|
||||
}
|
||||
const filterBtnAriaLabel = showFilter
|
||||
const searchPlaceholder = i18n.translate('kbn.discover.fieldChooser.searchPlaceHolder', {
|
||||
defaultMessage: 'Search field names',
|
||||
});
|
||||
const aggregatableLabel = i18n.translate('kbn.discover.fieldChooser.filter.aggregatableLabel', {
|
||||
defaultMessage: 'Aggregatable',
|
||||
});
|
||||
const searchableLabel = i18n.translate('kbn.discover.fieldChooser.filter.searchableLabel', {
|
||||
defaultMessage: 'Searchable',
|
||||
});
|
||||
const typeLabel = i18n.translate('kbn.discover.fieldChooser.filter.typeLabel', {
|
||||
defaultMessage: 'Type',
|
||||
});
|
||||
const typeOptions = types
|
||||
? types.map(type => {
|
||||
return { value: type, text: type };
|
||||
})
|
||||
: [{ value: 'any', text: 'any' }];
|
||||
|
||||
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
||||
const [isPopoverOpen, setPopoverOpen] = useState(false);
|
||||
const [values, setValues] = useState<State>({
|
||||
searchable: 'any',
|
||||
aggregatable: 'any',
|
||||
type: 'any',
|
||||
missing: true,
|
||||
});
|
||||
|
||||
const filterBtnAriaLabel = isPopoverOpen
|
||||
? i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', {
|
||||
defaultMessage: 'Hide field filter settings',
|
||||
})
|
||||
: i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel', {
|
||||
defaultMessage: 'Show field filter settings',
|
||||
});
|
||||
const searchPlaceholder = i18n.translate('kbn.discover.fieldChooser.searchPlaceHolder', {
|
||||
defaultMessage: 'Search field names',
|
||||
});
|
||||
|
||||
const handleFacetButtonClicked = () => {
|
||||
setPopoverOpen(!isPopoverOpen);
|
||||
};
|
||||
|
||||
const applyFilterValue = (id: string, filterValue: string | boolean) => {
|
||||
switch (filterValue) {
|
||||
case 'any':
|
||||
if (id !== 'type') {
|
||||
onChange(id, undefined);
|
||||
} else {
|
||||
onChange(id, filterValue);
|
||||
}
|
||||
break;
|
||||
case 'true':
|
||||
onChange(id, true);
|
||||
break;
|
||||
case 'false':
|
||||
onChange(id, false);
|
||||
break;
|
||||
default:
|
||||
onChange(id, filterValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isFilterActive = (name: string, filterValue: string | boolean) => {
|
||||
return name !== 'missing' && filterValue !== 'any';
|
||||
};
|
||||
|
||||
const handleValueChange = (name: string, filterValue: string | boolean) => {
|
||||
const previousValue = values[name];
|
||||
updateFilterCount(name, previousValue, filterValue);
|
||||
const updatedValues = { ...values };
|
||||
updatedValues[name] = filterValue;
|
||||
setValues(updatedValues);
|
||||
applyFilterValue(name, filterValue);
|
||||
};
|
||||
|
||||
const updateFilterCount = (
|
||||
name: string,
|
||||
previousValue: string | boolean,
|
||||
currentValue: string | boolean
|
||||
) => {
|
||||
const previouslyFilterActive = isFilterActive(name, previousValue);
|
||||
const filterActive = isFilterActive(name, currentValue);
|
||||
const diff = Number(filterActive) - Number(previouslyFilterActive);
|
||||
setActiveFiltersCount(activeFiltersCount + diff);
|
||||
};
|
||||
|
||||
const handleMissingChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const missingValue = e.target.checked;
|
||||
handleValueChange('missing', missingValue);
|
||||
};
|
||||
|
||||
const buttonContent = (
|
||||
<EuiFacetButton
|
||||
aria-label={filterBtnAriaLabel}
|
||||
data-test-subj="toggleFieldFilterButton"
|
||||
className="dscToggleFieldFilterButton"
|
||||
icon={<EuiIcon type="filter" />}
|
||||
isSelected={activeFiltersCount > 0}
|
||||
quantity={activeFiltersCount}
|
||||
onClick={handleFacetButtonClicked}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="kbn.discover.fieldChooser.fieldFilterFacetButtonLabel"
|
||||
defaultMessage="Filter by type"
|
||||
/>
|
||||
</EuiFacetButton>
|
||||
);
|
||||
|
||||
const select = (
|
||||
id: string,
|
||||
selectOptions: Array<{ text: ReactNode } & OptionHTMLAttributes<HTMLOptionElement>>,
|
||||
selectValue: string
|
||||
) => {
|
||||
return (
|
||||
<EuiSelect
|
||||
id={`${id}-select`}
|
||||
options={selectOptions}
|
||||
value={selectValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
handleValueChange(id, e.target.value)
|
||||
}
|
||||
aria-label={i18n.translate('kbn.discover.fieldChooser.filter.fieldSelectorLabel', {
|
||||
defaultMessage: 'Selection of {id} filter options',
|
||||
values: { id },
|
||||
})}
|
||||
data-test-subj={`${id}Select`}
|
||||
compressed
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleButtons = (id: string) => {
|
||||
return [
|
||||
{
|
||||
id: `${id}-any`,
|
||||
label: 'any',
|
||||
},
|
||||
{
|
||||
id: `${id}-true`,
|
||||
label: 'yes',
|
||||
},
|
||||
{
|
||||
id: `${id}-false`,
|
||||
label: 'no',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const buttonGroup = (id: string, legend: string) => {
|
||||
return (
|
||||
<EuiButtonGroup
|
||||
legend={legend}
|
||||
options={toggleButtons(id)}
|
||||
idSelected={`${id}-${values[id]}`}
|
||||
onChange={optionId => handleValueChange(id, optionId.replace(`${id}-`, ''))}
|
||||
buttonSize="compressed"
|
||||
isFullWidth
|
||||
data-test-subj={`${id}ButtonGroup`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const selectionPanel = (
|
||||
<div className="dscFieldSearch__formWrapper">
|
||||
<EuiForm data-test-subj="filterSelectionPanel">
|
||||
<EuiFormRow fullWidth label={aggregatableLabel} display="columnCompressed">
|
||||
{buttonGroup('aggregatable', aggregatableLabel)}
|
||||
</EuiFormRow>
|
||||
<EuiFormRow fullWidth label={searchableLabel} display="columnCompressed">
|
||||
{buttonGroup('searchable', searchableLabel)}
|
||||
</EuiFormRow>
|
||||
<EuiFormRow fullWidth label={typeLabel} display="columnCompressed">
|
||||
{select('type', typeOptions, values.type)}
|
||||
</EuiFormRow>
|
||||
</EuiForm>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiFlexGroup responsive={false} gutterSize={'s'}>
|
||||
|
@ -86,20 +259,35 @@ export function DiscoverFieldSearch({
|
|||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFacetButton
|
||||
aria-label={filterBtnAriaLabel}
|
||||
data-test-subj="toggleFieldFilterButton"
|
||||
className="dscToggleFieldFilterButton"
|
||||
icon={<EuiIcon type="filter" />}
|
||||
isSelected={filtersActive > 0}
|
||||
quantity={filtersActive}
|
||||
onClick={() => onShowFilter()}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="kbn.discover.fieldChooser.fieldFilterFacetButtonLabel"
|
||||
defaultMessage="Filter by type"
|
||||
/>
|
||||
</EuiFacetButton>
|
||||
<div className="dscFieldSearch__filterWrapper">
|
||||
<EuiPopover
|
||||
id="dataPanelTypeFilter"
|
||||
panelClassName="euiFilterGroup__popoverPanel"
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
display="block"
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={() => {}}
|
||||
button={buttonContent}
|
||||
>
|
||||
<EuiPopoverTitle>
|
||||
{i18n.translate('kbn.discover.fieldChooser.filter.filterByTypeLabel', {
|
||||
defaultMessage: 'Filter by type',
|
||||
})}
|
||||
</EuiPopoverTitle>
|
||||
{selectionPanel}
|
||||
<EuiPopoverFooter>
|
||||
<EuiSwitch
|
||||
label={i18n.translate('kbn.discover.fieldChooser.filter.hideMissingFieldsLabel', {
|
||||
defaultMessage: 'Hide missing fields',
|
||||
})}
|
||||
checked={values.missing}
|
||||
onChange={handleMissingChange}
|
||||
data-test-subj="missingSwitch"
|
||||
/>
|
||||
</EuiPopoverFooter>
|
||||
</EuiPopover>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,9 +27,7 @@ const app = uiModules.get('apps/discover');
|
|||
app.directive('discoverFieldSearch', function(reactDirective: any) {
|
||||
return reactDirective(wrapInI18nContext(DiscoverFieldSearch), [
|
||||
['onChange', { watchDepth: 'reference' }],
|
||||
['onShowFilter', { watchDepth: 'reference' }],
|
||||
['showFilter', { watchDepth: 'value' }],
|
||||
['value', { watchDepth: 'value' }],
|
||||
['filtersActive', { watchDepth: 'value' }],
|
||||
['types', { watchDepth: 'value' }],
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -9,10 +9,8 @@
|
|||
<form>
|
||||
<discover-field-search
|
||||
on-change="setFilterValue"
|
||||
on-show-filter="toggleShowFilter"
|
||||
show-filter="showFilter"
|
||||
value="filter.vals.name"
|
||||
filters-active="filtersActive"
|
||||
types="fieldTypes"
|
||||
>
|
||||
</discover-field-search>
|
||||
<div data-test-subj="discoverFieldFilter" class="dscFieldFilter" ng-show="showFilter">
|
||||
|
|
|
@ -283,19 +283,13 @@ export function DiscoverPageProvider({ getService, getPageObjects }) {
|
|||
}
|
||||
|
||||
async openSidebarFieldFilter() {
|
||||
const fieldFilterFormExists = await testSubjects.exists('discoverFieldFilter');
|
||||
if (!fieldFilterFormExists) {
|
||||
await testSubjects.click('toggleFieldFilterButton');
|
||||
await testSubjects.existOrFail('discoverFieldFilter');
|
||||
}
|
||||
await testSubjects.click('toggleFieldFilterButton');
|
||||
await testSubjects.existOrFail('filterSelectionPanel');
|
||||
}
|
||||
|
||||
async closeSidebarFieldFilter() {
|
||||
const fieldFilterFormExists = await testSubjects.exists('discoverFieldFilter');
|
||||
if (fieldFilterFormExists) {
|
||||
await testSubjects.click('toggleFieldFilterButton');
|
||||
await testSubjects.missingOrFail('discoverFieldFilter', { allowHidden: true });
|
||||
}
|
||||
await testSubjects.click('toggleFieldFilterButton');
|
||||
await testSubjects.missingOrFail('filterSelectionPanel', { allowHidden: true });
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue