[Management] Source filters table in React (#16649)

* Source filters table

* Updates

* Handle no source filters

* PR feedback

* Fix merge issues

* PR feedback

* PR feedback

* PR feedback

* Upgrade to 3.3.0 which allows us to use Fragments in enzyme tests

* Use EuiInMemoryTable instead

* Remove dead code and simplify some tests

* Dynamically update the matches as the user edits the filter

* Apparently, this has been using the wrong parameter name

* Restructure to stop storing computed data and add reselect helper

* Reselect is tiny, just use that

* PR feedback

* Fix merge issues

* PR feedback
This commit is contained in:
Chris Roberson 2018-03-09 17:20:00 -05:00 committed by GitHub
parent 33033adb1d
commit 0caa5a8acf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1514 additions and 265 deletions

View file

@ -81,9 +81,10 @@
"@elastic/numeral": "2.3.1",
"@elastic/ui-ace": "0.2.3",
"@kbn/babel-preset": "link:packages/kbn-babel-preset",
"@kbn/pm": "link:packages/kbn-pm",
"@kbn/datemath": "link:packages/kbn-datemath",
"@kbn/pm": "link:packages/kbn-pm",
"@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector",
"JSONStream": "1.1.1",
"accept-language-parser": "1.2.0",
"angular": "1.6.5",
"angular-aria": "1.6.6",
@ -143,7 +144,6 @@
"js-yaml": "3.4.1",
"json-stringify-pretty-compact": "1.0.4",
"json-stringify-safe": "5.0.1",
"JSONStream": "1.1.1",
"jstimezonedetect": "1.0.5",
"leaflet": "1.0.3",
"leaflet-draw": "0.4.10",

View file

@ -141,11 +141,7 @@
<div id="reactScriptedFieldsTable"></div>
<source-filters-table
ng-show="state.tab == 'sourceFilters'"
index-pattern="indexPattern"
class="fields source-filters"
></source-filters-table>
<div id="reactSourceFiltersTable"></div>
</div>
</div>
</kbn-management-indices>

View file

@ -1,7 +1,6 @@
import _ from 'lodash';
import './index_header';
import './scripted_field_editor';
import './source_filters_table';
import { KbnUrlProvider } from 'ui/url';
import { IndicesEditSectionsProvider } from './edit_sections';
import { fatalError } from 'ui/notify';
@ -9,15 +8,49 @@ import uiRoutes from 'ui/routes';
import { uiModules } from 'ui/modules';
import template from './edit_index_pattern.html';
import { FieldWildcardProvider } from 'ui/field_wildcard';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { SourceFiltersTable } from './source_filters_table';
import { IndexedFieldsTable } from './indexed_fields_table';
import { ScriptedFieldsTable } from './scripted_fields_table';
const REACT_SOURCE_FILTERS_DOM_ELEMENT_ID = 'reactSourceFiltersTable';
const REACT_INDEXED_FIELDS_DOM_ELEMENT_ID = 'reactIndexedFieldsTable';
const REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID = 'reactScriptedFieldsTable';
function updateSourceFiltersTable($scope, $state) {
if ($state.tab === 'sourceFilters') {
$scope.$$postDigest(() => {
const node = document.getElementById(REACT_SOURCE_FILTERS_DOM_ELEMENT_ID);
if (!node) {
return;
}
render(
<SourceFiltersTable
indexPattern={$scope.indexPattern}
filterFilter={$scope.fieldFilter}
fieldWildcardMatcher={$scope.fieldWildcardMatcher}
onAddOrRemoveFilter={() => {
$scope.editSections = $scope.editSectionsProvider($scope.indexPattern);
$scope.refreshFilters();
$scope.$apply();
}}
/>,
node,
);
});
} else {
destroySourceFiltersTable();
}
}
function destroySourceFiltersTable() {
const node = document.getElementById(REACT_SOURCE_FILTERS_DOM_ELEMENT_ID);
node && unmountComponentAtNode(node);
}
function updateScriptedFieldsTable($scope, $state) {
if ($state.tab === 'scriptedFields') {
$scope.$$postDigest(() => {
@ -167,6 +200,7 @@ uiModules.get('apps/management')
$state.tab = obj.index;
updateIndexedFieldsTable($scope, $state);
updateScriptedFieldsTable($scope, $state);
updateSourceFiltersTable($scope, $state);
$state.save();
};
@ -249,6 +283,9 @@ uiModules.get('apps/management')
if ($scope.indexedFieldTypeFilter !== undefined && $state.tab === 'indexedFields') {
updateIndexedFieldsTable($scope, $state);
}
if ($scope.fieldFilter !== undefined && $state.tab === 'sourceFilters') {
updateSourceFiltersTable($scope, $state);
}
});
$scope.$watch('scriptedFieldLanguageFilter', () => {
@ -261,4 +298,7 @@ uiModules.get('apps/management')
destroyIndexedFieldsTable();
destroyScriptedFieldsTable();
});
updateScriptedFieldsTable($scope, $state);
updateSourceFiltersTable($scope, $state);
});

View file

@ -10,7 +10,7 @@ export function IndicesEditSectionsProvider() {
_.defaults(fieldCount, {
indexed: 0,
scripted: 0,
sourceFilters: 0
sourceFilters: indexPattern.sourceFilters ? indexPattern.sourceFilters.length : 0,
});
return [

View file

@ -0,0 +1,307 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SourceFiltersTable should add a filter 1`] = `
<div>
<header />
<AddFilter
onAddFilter={[Function]}
/>
<eui-spacer
size="l"
/>
<Table
deleteFilter={[Function]}
fieldWildcardMatcher={[Function]}
indexPattern={
Object {
"save": [MockFunction] {
"calls": Array [
Array [],
],
},
"sourceFilters": Array [
Object {
"value": "tim*",
},
Object {
"value": "na*",
},
],
}
}
isSaving={false}
items={
Array [
Object {
"clientId": 2,
"value": "tim*",
},
Object {
"clientId": 3,
"value": "na*",
},
]
}
saveFilter={[Function]}
/>
</div>
`;
exports[`SourceFiltersTable should filter based on the query bar 1`] = `
<div>
<header />
<AddFilter
onAddFilter={[Function]}
/>
<eui-spacer
size="l"
/>
<Table
deleteFilter={[Function]}
fieldWildcardMatcher={[Function]}
indexPattern={
Object {
"sourceFilters": Array [
Object {
"value": "time*",
},
Object {
"value": "nam*",
},
Object {
"value": "age*",
},
],
}
}
isSaving={false}
items={
Array [
Object {
"clientId": 1,
"value": "time*",
},
]
}
saveFilter={[Function]}
/>
</div>
`;
exports[`SourceFiltersTable should remove a filter 1`] = `
<div>
<header />
<AddFilter
onAddFilter={[Function]}
/>
<eui-spacer
size="l"
/>
<Table
deleteFilter={[Function]}
fieldWildcardMatcher={[Function]}
indexPattern={
Object {
"save": [MockFunction] {
"calls": Array [
Array [],
],
},
"sourceFilters": Array [
Object {
"clientId": 1,
"value": "tim*",
},
Object {
"clientId": 2,
"value": "na*",
},
],
}
}
isSaving={false}
items={
Array [
Object {
"clientId": 3,
"value": "tim*",
},
Object {
"clientId": 4,
"value": "na*",
},
]
}
saveFilter={[Function]}
/>
</div>
`;
exports[`SourceFiltersTable should render normally 1`] = `
<div>
<header />
<AddFilter
onAddFilter={[Function]}
/>
<eui-spacer
size="l"
/>
<Table
deleteFilter={[Function]}
fieldWildcardMatcher={[Function]}
indexPattern={
Object {
"sourceFilters": Array [
Object {
"value": "time*",
},
Object {
"value": "nam*",
},
Object {
"value": "age*",
},
],
}
}
isSaving={false}
items={
Array [
Object {
"clientId": 1,
"value": "time*",
},
Object {
"clientId": 2,
"value": "nam*",
},
Object {
"clientId": 3,
"value": "age*",
},
]
}
saveFilter={[Function]}
/>
</div>
`;
exports[`SourceFiltersTable should should a loading indicator when saving 1`] = `
<div>
<header />
<AddFilter
onAddFilter={[Function]}
/>
<eui-spacer
size="l"
/>
<Table
deleteFilter={[Function]}
fieldWildcardMatcher={[Function]}
indexPattern={
Object {
"sourceFilters": Array [
Object {
"value": "tim*",
},
],
}
}
isSaving={true}
items={
Array [
Object {
"clientId": 1,
"value": "tim*",
},
]
}
saveFilter={[Function]}
/>
</div>
`;
exports[`SourceFiltersTable should show a delete modal 1`] = `
<div>
<header />
<AddFilter
onAddFilter={[Function]}
/>
<eui-spacer
size="l"
/>
<Table
deleteFilter={[Function]}
fieldWildcardMatcher={[Function]}
indexPattern={
Object {
"sourceFilters": Array [
Object {
"value": "tim*",
},
],
}
}
isSaving={false}
items={
Array [
Object {
"clientId": 1,
"value": "tim*",
},
]
}
saveFilter={[Function]}
/>
<eui-overlay-mask>
<eui-confirm-modal
cancelButtonText="Cancel"
confirmButtonText="Delete"
onCancel={[Function]}
onConfirm={[Function]}
title="Delete source filter 'tim*'?"
/>
</eui-overlay-mask>
</div>
`;
exports[`SourceFiltersTable should update a filter 1`] = `
<div>
<header />
<AddFilter
onAddFilter={[Function]}
/>
<eui-spacer
size="l"
/>
<Table
deleteFilter={[Function]}
fieldWildcardMatcher={[Function]}
indexPattern={
Object {
"save": [MockFunction] {
"calls": Array [
Array [],
],
},
"sourceFilters": Array [
Object {
"clientId": 1,
"value": "tim*",
},
],
}
}
isSaving={false}
items={
Array [
Object {
"clientId": 2,
"value": "tim*",
},
]
}
saveFilter={[Function]}
/>
</div>
`;

View file

@ -0,0 +1,152 @@
import React from 'react';
import { shallow } from 'enzyme';
import { SourceFiltersTable } from '../source_filters_table';
jest.mock('@elastic/eui', () => ({
EuiButton: 'eui-button',
EuiTableOfRecords: 'eui-table-of-records',
EuiTitle: 'eui-title',
EuiText: 'eui-text',
EuiButton: 'eui-button',
EuiHorizontalRule: 'eui-horizontal-rule',
EuiSpacer: 'eui-spacer',
EuiCallOut: 'eui-call-out',
EuiLink: 'eui-link',
EuiOverlayMask: 'eui-overlay-mask',
EuiConfirmModal: 'eui-confirm-modal',
EuiLoadingSpinner: 'eui-loading-spinner',
Comparators: {
property: () => {},
default: () => {},
},
}));
jest.mock('../components/header', () => ({ Header: 'header' }));
jest.mock('../components/table', () => ({
// Note: this seems to fix React complaining about non lowercase attributes
Table: () => {
return 'table';
}
}));
const indexPattern = {
sourceFilters: [
{ value: 'time*' },
{ value: 'nam*' },
{ value: 'age*' },
],
};
describe('SourceFiltersTable', () => {
it('should render normally', async () => {
const component = shallow(
<SourceFiltersTable
indexPattern={indexPattern}
fieldWildcardMatcher={() => {}}
/>
);
expect(component).toMatchSnapshot();
});
it('should filter based on the query bar', async () => {
const component = shallow(
<SourceFiltersTable
indexPattern={indexPattern}
fieldWildcardMatcher={() => {}}
/>
);
component.setProps({ filterFilter: 'ti' });
expect(component).toMatchSnapshot();
});
it('should should a loading indicator when saving', async () => {
const component = shallow(
<SourceFiltersTable
indexPattern={{
sourceFilters: [{ value: 'tim*' }]
}}
fieldWildcardMatcher={() => {}}
/>
);
component.setState({ isSaving: true });
expect(component).toMatchSnapshot();
});
it('should show a delete modal', async () => {
const component = shallow(
<SourceFiltersTable
indexPattern={{
sourceFilters: [{ value: 'tim*' }]
}}
fieldWildcardMatcher={() => {}}
/>
);
component.instance().startDeleteFilter({ value: 'tim*' });
component.update(); // We are not calling `.setState` directly so we need to re-render
expect(component).toMatchSnapshot();
});
it('should remove a filter', async () => {
const save = jest.fn();
const component = shallow(
<SourceFiltersTable
indexPattern={{
save,
sourceFilters: [{ value: 'tim*' }, { value: 'na*' }]
}}
fieldWildcardMatcher={() => {}}
/>
);
component.instance().startDeleteFilter({ value: 'tim*' });
component.update(); // We are not calling `.setState` directly so we need to re-render
await component.instance().deleteFilter();
component.update(); // We are not calling `.setState` directly so we need to re-render
expect(save).toBeCalled();
expect(component).toMatchSnapshot();
});
it('should add a filter', async () => {
const save = jest.fn();
const component = shallow(
<SourceFiltersTable
indexPattern={{
save,
sourceFilters: [{ value: 'tim*' }]
}}
fieldWildcardMatcher={() => {}}
/>
);
await component.instance().onAddFilter('na*');
component.update(); // We are not calling `.setState` directly so we need to re-render
expect(save).toBeCalled();
expect(component).toMatchSnapshot();
});
it('should update a filter', async () => {
const save = jest.fn();
const component = shallow(
<SourceFiltersTable
indexPattern={{
save,
sourceFilters: [{ value: 'tim*' }]
}}
fieldWildcardMatcher={() => {}}
/>
);
await component.instance().saveFilter({ oldFilterValue: 'tim*', newFilterValue: 'ti*' });
component.update(); // We are not calling `.setState` directly so we need to re-render
expect(save).toBeCalled();
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,79 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddFilter should ignore strings with just spaces 1`] = `
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={10}
>
<EuiFieldText
fullWidth={true}
isLoading={false}
onChange={[Function]}
placeholder="source filter, accepts wildcards (e.g., \`user*\` to filter fields starting with 'user')"
value=""
/>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<EuiButton
color="primary"
fill={false}
iconSide="left"
isDisabled={true}
onClick={[Function]}
type="button"
>
Add
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`AddFilter should render normally 1`] = `
<EuiFlexGroup
alignItems="stretch"
component="div"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={10}
>
<EuiFieldText
fullWidth={true}
isLoading={false}
onChange={[Function]}
placeholder="source filter, accepts wildcards (e.g., \`user*\` to filter fields starting with 'user')"
value=""
/>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<EuiButton
color="primary"
fill={false}
iconSide="left"
isDisabled={true}
onClick={[Function]}
type="button"
>
Add
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -0,0 +1,42 @@
import React from 'react';
import { shallow } from 'enzyme';
import { AddFilter } from '../add_filter';
describe('AddFilter', () => {
it('should render normally', async () => {
const component = shallow(
<AddFilter onAddFilter={() => {}}/>
);
expect(component).toMatchSnapshot();
});
it('should allow adding a filter', async () => {
const onAddFilter = jest.fn();
const component = shallow(
<AddFilter onAddFilter={onAddFilter}/>
);
// Set a value in the input field
component.setState({ filter: 'tim*' });
// Click the button
component.find('EuiButton').simulate('click');
component.update();
expect(onAddFilter).toBeCalledWith('tim*');
});
it('should ignore strings with just spaces', async () => {
const component = shallow(
<AddFilter onAddFilter={() => {}}/>
);
// Set a value in the input field
component.find('EuiFieldText').simulate('keypress', ' ');
component.update();
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,52 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFieldText,
EuiButton,
} from '@elastic/eui';
export class AddFilter extends Component {
static propTypes = {
onAddFilter: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = {
filter: '',
};
}
onAddFilter = () => {
this.props.onAddFilter(this.state.filter);
this.setState({ filter: '' });
}
render() {
const { filter } = this.state;
return (
<EuiFlexGroup>
<EuiFlexItem grow={10}>
<EuiFieldText
fullWidth
value={filter}
onChange={e => this.setState({ filter: e.target.value.trim() })}
placeholder="source filter, accepts wildcards (e.g., `user*` to filter fields starting with 'user')"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
isDisabled={filter.length === 0}
onClick={this.onAddFilter}
>
Add
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
}
}

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header should render normally 1`] = `
<div>
<EuiTitle
size="s"
>
<h3>
Source filters
</h3>
</EuiTitle>
<EuiText>
<p>
Source filters can be used to exclude one or more fields when fetching the document source. This happens when viewing a document in the Discover app, or with a table displaying results from a saved search in the Dashboard app. Each row is built using the source of a single document, and if you have documents with large or unimportant fields you may benefit from filtering those out at this lower level.
</p>
<p>
Note that multi-fields will incorrectly appear as matches in the table below. These filters only actually apply to fields in the original source document, so matching multi-fields are not actually being filtered.
</p>
</EuiText>
<EuiSpacer
size="s"
/>
</div>
`;

View file

@ -0,0 +1,14 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Header } from '../header';
describe('Header', () => {
it('should render normally', async () => {
const component = shallow(
<Header/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,31 @@
import React from 'react';
import {
EuiTitle,
EuiText,
EuiSpacer,
} from '@elastic/eui';
export const Header = () => (
<div>
<EuiTitle size="s">
<h3>Source filters</h3>
</EuiTitle>
<EuiText>
<p>
Source filters can be used to exclude one or more fields when fetching the document source.
This happens when viewing a document in the Discover app, or with a table displaying results
from a saved search in the Dashboard app. Each row is built using the source of a single
document, and if you have documents with large or unimportant fields you may benefit from
filtering those out at this lower level.
</p>
<p>
Note that multi-fields will incorrectly appear as matches in the table below.
These filters only actually apply to fields in the original source document,
so matching multi-fields are not actually being filtered.
</p>
</EuiText>
<EuiSpacer size="s" />
</div>
);

View file

@ -0,0 +1,107 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Table editing should show a save button 1`] = `
<div>
<React.Fragment>
<EuiButtonIcon
aria-label="Save"
color="primary"
iconType="checkInCircleFilled"
onClick={[Function]}
size="s"
type="button"
/>
<EuiButtonIcon
aria-label="Cancel"
color="primary"
iconType="cross"
onClick={[Function]}
size="s"
type="button"
/>
</React.Fragment>
</div>
`;
exports[`Table editing should show an input field 1`] = `
<EuiFormControlLayout
fullWidth={false}
iconSide="left"
isLoading={false}
>
<EuiValidatableControl>
<input
autoFocus={true}
className="euiFieldText"
onChange={[Function]}
onKeyDown={[Function]}
type="text"
value="tim*"
/>
</EuiValidatableControl>
</EuiFormControlLayout>
`;
exports[`Table editing should update the matches dynamically as input value is changed 1`] = `
<div>
<span>
time, value
</span>
</div>
`;
exports[`Table should render filter matches 1`] = `
<span>
time
</span>
`;
exports[`Table should render normally 1`] = `
<EuiInMemoryTable
columns={
Array [
Object {
"dataType": "string",
"description": "Filter name",
"field": "value",
"name": "Filter",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"description": "Language used for the field",
"field": "value",
"name": "Matches",
"render": [Function],
"sortable": true,
},
Object {
"align": "right",
"name": "",
"render": [Function],
"width": "100",
},
]
}
items={
Array [
Object {
"value": "tim*",
},
]
}
loading={true}
pagination={
Object {
"pageSizeOptions": Array [
5,
10,
25,
50,
],
}
}
sorting={true}
/>
`;

View file

@ -0,0 +1,304 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Table } from '../table';
import { keyCodes } from '@elastic/eui';
const indexPattern = {};
const items = [{ value: 'tim*' }];
describe('Table', () => {
it('should render normally', async () => {
const component = shallow(
<Table
indexPattern={indexPattern}
items={items}
deleteFilter={() => {}}
fieldWildcardMatcher={() => {}}
saveFilter={() => {}}
isSaving={true}
/>
);
expect(component).toMatchSnapshot();
});
it('should render filter matches', async () => {
const component = shallow(
<Table
indexPattern={{
getNonScriptedFields: () => [{ name: 'time' }, { name: 'value' }],
}}
items={items}
deleteFilter={() => {}}
fieldWildcardMatcher={filter => field => field.includes(filter[0])}
saveFilter={() => {}}
isSaving={false}
/>
);
const matchesTableCell = shallow(
component.prop('columns')[1].render('tim', { clientId: 1 })
);
expect(matchesTableCell).toMatchSnapshot();
});
describe('editing', () => {
const saveFilter = jest.fn();
const clientId = 1;
let component;
beforeEach(() => {
component = shallow(
<Table
indexPattern={indexPattern}
items={items}
deleteFilter={() => {}}
fieldWildcardMatcher={() => {}}
saveFilter={saveFilter}
isSaving={false}
/>
);
});
it('should show an input field', () => {
// Start the editing process
const editingComponent = shallow(
// Wrap in a div because: https://github.com/airbnb/enzyme/issues/1213
<div>
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
</div>
);
editingComponent
.find('EuiButtonIcon')
.at(1)
.simulate('click');
// Ensure the state change propagates
component.update();
// Ensure the table cell switches to an input
const filterNameTableCell = shallow(
component.prop('columns')[0].render('tim*', { clientId })
);
expect(filterNameTableCell).toMatchSnapshot();
});
it('should show a save button', () => {
// Start the editing process
const editingComponent = shallow(
// Fixes: Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
</div>
);
editingComponent
.find('EuiButtonIcon')
.at(1)
.simulate('click');
// Ensure the state change propagates
component.update();
// Verify save button
const saveTableCell = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
</div>
);
expect(saveTableCell).toMatchSnapshot();
});
it('should update the matches dynamically as input value is changed', () => {
const localComponent = shallow(
<Table
indexPattern={{
getNonScriptedFields: () => [{ name: 'time' }, { name: 'value' }],
}}
items={items}
deleteFilter={() => {}}
fieldWildcardMatcher={query => () => {
return query.includes('time*');
}}
saveFilter={saveFilter}
isSaving={false}
/>
);
// Start the editing process
const editingComponent = shallow(
// Fixes: Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{localComponent
.prop('columns')[2]
.render({ clientId, value: 'tim*' })}
</div>
);
editingComponent
.find('EuiButtonIcon')
.at(1)
.simulate('click');
// Update the value
localComponent.setState({ editingFilterValue: 'time*' });
// Ensure the state change propagates
localComponent.update();
// Verify updated matches
const matchesTableCell = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{localComponent.prop('columns')[1].render('tim*', { clientId })}
</div>
);
expect(matchesTableCell).toMatchSnapshot();
});
it('should exit on save', () => {
// Change the value to something else
component.setState({
editingFilterId: clientId,
editingFilterValue: 'ti*',
});
// Click the save button
const editingComponent = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
</div>
);
editingComponent
.find('EuiButtonIcon')
.at(0)
.simulate('click');
// Ensure we call saveFilter properly
expect(saveFilter).toBeCalledWith({
filterId: clientId,
newFilterValue: 'ti*',
});
// Ensure the state is properly reset
expect(component.state('editingFilterId')).toBe(null);
});
});
it('should allow deletes', () => {
const deleteFilter = jest.fn();
const component = shallow(
<Table
indexPattern={indexPattern}
items={items}
deleteFilter={deleteFilter}
fieldWildcardMatcher={() => {}}
saveFilter={() => {}}
isSaving={false}
/>
);
// Click the delete button
const deleteCellComponent = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{component.prop('columns')[2].render({ clientId: 1, value: 'tim*' })}
</div>
);
deleteCellComponent
.find('EuiButtonIcon')
.at(0)
.simulate('click');
expect(deleteFilter).toBeCalled();
});
it('should save when in edit mode and the enter key is pressed', () => {
const saveFilter = jest.fn();
const clientId = 1;
const component = shallow(
<Table
indexPattern={indexPattern}
items={items}
deleteFilter={() => {}}
fieldWildcardMatcher={() => {}}
saveFilter={saveFilter}
isSaving={false}
/>
);
// Start the editing process
const editingComponent = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
</div>
);
editingComponent
.find('EuiButtonIcon')
.at(1)
.simulate('click');
// Ensure the state change propagates
component.update();
// Get the rendered input cell
const filterNameTableCell = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>{component.prop('columns')[0].render('tim*', { clientId })}</div>
);
// Press the enter key
filterNameTableCell
.find('EuiFieldText')
.simulate('keydown', { keyCode: keyCodes.ENTER });
expect(saveFilter).toBeCalled();
// It should reset
expect(component.state('editingFilterId')).toBe(null);
});
it('should cancel when in edit mode and the esc key is pressed', () => {
const saveFilter = jest.fn();
const clientId = 1;
const component = shallow(
<Table
indexPattern={indexPattern}
items={items}
deleteFilter={() => {}}
fieldWildcardMatcher={() => {}}
saveFilter={saveFilter}
isSaving={false}
/>
);
// Start the editing process
const editingComponent = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>
{component.prop('columns')[2].render({ clientId, value: 'tim*' })}
</div>
);
editingComponent
.find('EuiButtonIcon')
.at(1)
.simulate('click');
// Ensure the state change propagates
component.update();
// Get the rendered input cell
const filterNameTableCell = shallow(
// Fixes Invariant Violation: ReactShallowRenderer render(): Shallow rendering works only with custom components, but the provided element type was `symbol`.
<div>{component.prop('columns')[0].render('tim*', { clientId })}</div>
);
// Press the enter key
filterNameTableCell
.find('EuiFieldText')
.simulate('keydown', { keyCode: keyCodes.ESCAPE });
expect(saveFilter).not.toBeCalled();
// It should reset
expect(component.state('editingFilterId')).toBe(null);
});
});

View file

@ -0,0 +1,178 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import {
EuiInMemoryTable,
EuiFieldText,
EuiButtonIcon,
keyCodes,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
export class Table extends Component {
static propTypes = {
indexPattern: PropTypes.object.isRequired,
items: PropTypes.array.isRequired,
deleteFilter: PropTypes.func.isRequired,
fieldWildcardMatcher: PropTypes.func.isRequired,
saveFilter: PropTypes.func.isRequired,
isSaving: PropTypes.bool.isRequired,
};
constructor(props) {
super(props);
this.state = {
editingFilterId: null,
editingFilterValue: null,
};
}
startEditingFilter = (id, value) =>
this.setState({ editingFilterId: id, editingFilterValue: value });
stopEditingFilter = () => this.setState({ editingFilterId: null });
onEditingFilterChange = e =>
this.setState({ editingFilterValue: e.target.value });
onEditFieldKeyDown = ({ keyCode }) => {
if (keyCodes.ENTER === keyCode) {
this.props.saveFilter({
filterId: this.state.editingFilterId,
newFilterValue: this.state.editingFilterValue,
});
this.stopEditingFilter();
}
if (keyCodes.ESCAPE === keyCode) {
this.stopEditingFilter();
}
};
getColumns() {
const {
deleteFilter,
fieldWildcardMatcher,
indexPattern,
saveFilter,
} = this.props;
return [
{
field: 'value',
name: 'Filter',
description: `Filter name`,
dataType: 'string',
sortable: true,
render: (value, filter) => {
if (this.state.editingFilterId === filter.clientId) {
return (
<EuiFieldText
autoFocus
value={this.state.editingFilterValue}
onChange={this.onEditingFilterChange}
onKeyDown={this.onEditFieldKeyDown}
/>
);
}
return <span>{value}</span>;
},
},
{
field: 'value',
name: 'Matches',
description: `Language used for the field`,
dataType: 'string',
sortable: true,
render: (value, filter) => {
const realtimeValue =
this.state.editingFilterId === filter.clientId
? this.state.editingFilterValue
: value;
const matcher = fieldWildcardMatcher([realtimeValue]);
const matches = indexPattern
.getNonScriptedFields()
.map(f => f.name)
.filter(matcher)
.sort();
if (matches.length) {
return <span>{matches.join(', ')}</span>;
}
return (
<em>The source filter doesn&apos;t match any known fields.</em>
);
},
},
{
name: '',
align: RIGHT_ALIGNMENT,
width: '100',
render: filter => {
if (this.state.editingFilterId === filter.clientId) {
return (
<Fragment>
<EuiButtonIcon
size="s"
onClick={() => {
saveFilter({
filterId: this.state.editingFilterId,
newFilterValue: this.state.editingFilterValue,
});
this.stopEditingFilter();
}}
iconType="checkInCircleFilled"
aria-label="Save"
/>
<EuiButtonIcon
size="s"
onClick={() => {
this.stopEditingFilter();
}}
iconType="cross"
aria-label="Cancel"
/>
</Fragment>
);
}
return (
<Fragment>
<EuiButtonIcon
size="s"
color="danger"
onClick={() => deleteFilter(filter)}
iconType="trash"
aria-label="Delete"
/>
<EuiButtonIcon
size="s"
onClick={() =>
this.startEditingFilter(filter.clientId, filter.value)
}
iconType="pencil"
aria-label="Edit"
/>
</Fragment>
);
},
},
];
}
render() {
const { items, isSaving } = this.props;
const columns = this.getColumns();
const pagination = {
pageSizeOptions: [5, 10, 25, 50],
};
return (
<EuiInMemoryTable
loading={isSaving}
items={items}
columns={columns}
pagination={pagination}
sorting={true}
/>
);
}
}

View file

@ -1,31 +0,0 @@
<div class="actions">
<button
aria-label="Edit"
ng-if="sourceFilters.editing !== filter"
ng-click="sourceFilters.editing = filter"
type="button"
class="kuiButton kuiButton--basic kuiButton--small"
>
<span aria-hidden="true" class="kuiIcon fa-pencil"></span>
</button>
<button
aria-label="Save"
ng-if="sourceFilters.editing === filter"
ng-click="sourceFilters.save()"
ng-disabled="!filter.value"
type="button"
class="kuiButton kuiButton--primary kuiButton--small"
>
<span aria-hidden="true" class="kuiIcon fa-save"></span>
</button>
<button
aria-label="Delete"
ng-click="sourceFilters.delete(filter)"
type="button"
class="kuiButton kuiButton--danger kuiButton--small"
>
<span aria-hidden="true" class="kuiIcon fa-trash"></span>
</button>
</div>

View file

@ -1,12 +0,0 @@
<div class="value">
<span ng-if="sourceFilters.editing !== filter">{{ filter.value }}</span>
<input
ng-model="filter.value"
input-focus
ng-if="sourceFilters.editing === filter"
placeholder="{{ sourceFilters.placeHolder }}"
type="text"
required
class="form-control">
</div>

View file

@ -1 +1 @@
import './source_filters_table';
export { SourceFiltersTable } from './source_filters_table';

View file

@ -1,56 +0,0 @@
<h3 class="kuiTextTitle kuiVerticalRhythm">
Source Filters
</h3>
<p class="kuiText kuiVerticalRhythm">
Source filters can be used to exclude one or more fields when fetching the document source. This happens when viewing a document in the Discover app, or with a table displaying results from a saved search in the Dashboard app. Each row is built using the source of a single document, and if you have documents with large or unimportant fields you may benefit from filtering those out at this lower level.
</p>
<p class="kuiText kuiVerticalRhythm">
Note that multi-fields will incorrectly appear as matches in the table below. These filters only actually apply to fields in the original source document, so matching multi-fields are not actually being filtered.
</p>
<div
ng-class="{ saving: sourceFilters.saving }"
class="source-filters-container kuiVerticalRhythm"
>
<form
name="form"
ng-submit="sourceFilters.create()"
class="kuiVerticalRhythm"
>
<div class="kuiFieldGroup">
<div class="kuiFieldGroupSection kuiFieldGroupSection--wide">
<input
ng-model="sourceFilters.newValue"
placeholder="{{ sourceFilters.placeHolder }}"
type="text"
class="kuiTextInput"
>
</div>
<div
class="kuiFieldGroupSection"
aria-label="Source Filter Editor Controls"
>
<button
type="submit"
class="kuiButton kuiButton--primary"
ng-disabled="!sourceFilters.newValue"
>
Add
</button>
</div>
</div>
</form>
<div class="kuiVerticalRhythm">
<paginated-table
columns="columns"
rows="rows"
link-to-top="true"
per-page="perPage"
show-blank-rows="false"
></paginated-table>
</div>
</div>

View file

@ -1,128 +1,181 @@
import { find, each, escape, invoke, size, without } from 'lodash';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { createSelector } from 'reselect';
import { uiModules } from 'ui/modules';
import { Notifier } from 'ui/notify';
import { FieldWildcardProvider } from 'ui/field_wildcard';
import {
EuiSpacer,
EuiOverlayMask,
EuiConfirmModal,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import controlsHtml from './controls.html';
import filterHtml from './filter.html';
import template from './source_filters_table.html';
import './source_filters_table.less';
import { Table } from './components/table';
import { Header } from './components/header';
import { AddFilter } from './components/add_filter';
const notify = new Notifier();
export class SourceFiltersTable extends Component {
static propTypes = {
indexPattern: PropTypes.object.isRequired,
filterFilter: PropTypes.string,
fieldWildcardMatcher: PropTypes.func.isRequired,
onAddOrRemoveFilter: PropTypes.func,
};
uiModules.get('kibana')
.directive('sourceFiltersTable', function (Private, $filter, confirmModal) {
const angularFilter = $filter('filter');
const { fieldWildcardMatcher } = Private(FieldWildcardProvider);
const rowScopes = []; // track row scopes, so they can be destroyed as needed
constructor(props) {
super(props);
return {
restrict: 'E',
scope: {
indexPattern: '='
},
template,
controllerAs: 'sourceFilters',
controller: class FieldFiltersController {
constructor($scope) {
if (!$scope.indexPattern) {
throw new Error('index pattern is required');
}
// Source filters do not have any unique ids, only the value is stored.
// To ensure we can create a consistent and expected UX when managing
// source filters, we are assigning a unique id to each filter on the
// client side only
this.clientSideId = 0;
$scope.perPage = 25;
$scope.columns = [
{
title: 'filter'
},
{
title: 'matches',
sortable: false,
info: 'The source fields that match the filter.'
},
{
title: 'controls',
sortable: false
}
];
this.$scope = $scope;
this.saving = false;
this.editing = null;
this.newValue = null;
this.placeHolder = 'source filter, accepts wildcards (e.g., `user*` to filter fields starting with \'user\')';
$scope.$watchMulti([ '[]indexPattern.sourceFilters', '$parent.fieldFilter' ], () => {
invoke(rowScopes, '$destroy');
rowScopes.length = 0;
if ($scope.indexPattern.sourceFilters) {
$scope.rows = [];
each($scope.indexPattern.sourceFilters, (filter) => {
const matcher = fieldWildcardMatcher([ filter.value ]);
// compute which fields match a filter
const matches = $scope.indexPattern.getNonScriptedFields().map(f => f.name).filter(matcher).sort();
if ($scope.$parent.fieldFilter && !angularFilter(matches, $scope.$parent.fieldFilter).length) {
return;
}
// compute the rows
const rowScope = $scope.$new();
rowScope.filter = filter;
rowScopes.push(rowScope);
$scope.rows.push([
{
markup: filterHtml,
scope: rowScope
},
size(matches) ? escape(matches.join(', ')) : '<em>The source filter doesn\'t match any known fields.</em>',
{
markup: controlsHtml,
scope: rowScope
}
]);
});
// Update the tab count
find($scope.$parent.editSections, { index: 'sourceFilters' }).count = $scope.rows.length;
}
});
}
all() {
return this.$scope.indexPattern.sourceFilters || [];
}
delete(filter) {
const doDelete = () => {
if (this.editing === filter) {
this.editing = null;
}
this.$scope.indexPattern.sourceFilters = without(this.all(), filter);
return this.save();
};
const confirmModalOptions = {
confirmButtonText: 'Delete',
onConfirm: doDelete,
title: 'Delete source filter?'
};
confirmModal('', confirmModalOptions);
}
create() {
const value = this.newValue;
this.newValue = null;
this.$scope.indexPattern.sourceFilters = [...this.all(), { value }];
return this.save();
}
save() {
this.saving = true;
this.$scope.indexPattern.save()
.then(() => this.editing = null)
.catch(notify.error)
.finally(() => this.saving = false);
}
}
this.state = {
filterToDelete: undefined,
isDeleteConfirmationModalVisible: false,
isSaving: false,
filters: [],
};
});
}
componentWillMount() {
this.updateFilters();
}
updateFilters = () => {
const sourceFilters = this.props.indexPattern.sourceFilters || [];
const filters = sourceFilters.map(filter => ({
...filter,
clientId: ++this.clientSideId,
}));
this.setState({ filters });
};
getFilteredFilters = createSelector(
state => state.filters,
(state, props) => props.filterFilter,
(filters, filterFilter) => {
if (filterFilter) {
const filterFilterToLowercase = filterFilter.toLowerCase();
return filters.filter(filter =>
filter.value.toLowerCase().includes(filterFilterToLowercase)
);
}
return filters;
}
);
startDeleteFilter = filter => {
this.setState({
filterToDelete: filter,
isDeleteConfirmationModalVisible: true,
});
};
hideDeleteConfirmationModal = () => {
this.setState({
filterToDelete: undefined,
isDeleteConfirmationModalVisible: false,
});
};
deleteFilter = async () => {
const { indexPattern, onAddOrRemoveFilter } = this.props;
const { filterToDelete, filters } = this.state;
indexPattern.sourceFilters = filters.filter(filter => {
return filter.clientId !== filterToDelete.clientId;
});
this.setState({ isSaving: true });
await indexPattern.save();
onAddOrRemoveFilter && onAddOrRemoveFilter();
this.updateFilters();
this.setState({ isSaving: false });
this.hideDeleteConfirmationModal();
};
onAddFilter = async value => {
const { indexPattern, onAddOrRemoveFilter } = this.props;
indexPattern.sourceFilters = [
...(indexPattern.sourceFilters || []),
{ value },
];
this.setState({ isSaving: true });
await indexPattern.save();
onAddOrRemoveFilter && onAddOrRemoveFilter();
this.updateFilters();
this.setState({ isSaving: false });
};
saveFilter = async ({ filterId, newFilterValue }) => {
const { indexPattern } = this.props;
const { filters } = this.state;
indexPattern.sourceFilters = filters.map(filter => {
if (filter.clientId === filterId) {
return {
value: newFilterValue,
clientId: filter.clientId,
};
}
return filter;
});
this.setState({ isSaving: true });
await indexPattern.save();
this.updateFilters();
this.setState({ isSaving: false });
};
renderDeleteConfirmationModal() {
const { filterToDelete } = this.state;
if (!filterToDelete) {
return null;
}
return (
<EuiOverlayMask>
<EuiConfirmModal
title={`Delete source filter '${filterToDelete.value}'?`}
onCancel={this.hideDeleteConfirmationModal}
onConfirm={this.deleteFilter}
cancelButtonText="Cancel"
confirmButtonText="Delete"
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
/>
</EuiOverlayMask>
);
}
render() {
const { indexPattern, fieldWildcardMatcher } = this.props;
const { isSaving } = this.state;
const filteredFilters = this.getFilteredFilters(this.state, this.props);
return (
<div>
<Header />
<AddFilter onAddFilter={this.onAddFilter} />
<EuiSpacer size="l" />
<Table
isSaving={isSaving}
indexPattern={indexPattern}
items={filteredFilters}
fieldWildcardMatcher={fieldWildcardMatcher}
deleteFilter={this.startDeleteFilter}
saveFilter={this.saveFilter}
/>
{this.renderDeleteConfirmationModal()}
</div>
);
}
}

View file

@ -1,34 +0,0 @@
@import (reference) "~ui/styles/variables";
source-filters {
.header {
text-align: center;
}
.source-filters-container {
margin-top: 15px;
&.saving {
pointer-events: none;
opacity: .4;
transition: opacity 0.75s;
}
.source-filter {
display: flex;
align-items: center;
margin: 10px 0;
.value {
text-align: left;
flex: 1 1 auto;
padding-right: 5px;
font-family: @font-family-monospace;
:not(input) {
padding-left: 15px;
}
}
}
}
}