[Management] Scripted fields table in React (#16604)

* First pass at new scripted fields table

* Ensure we destroy when we switch tabs

* PR feedback

* Fix functional tests

* Fix functional tests

* PR feedback and more tests
This commit is contained in:
Chris Roberson 2018-02-12 13:47:46 -05:00 committed by GitHub
parent b5bda1ee32
commit b932467784
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1288 additions and 250 deletions

View file

@ -23,10 +23,7 @@
</p>
<p class="kuiText kuiVerticalRhythm">
This page lists every field in the <strong>{{::indexPattern.title}}</strong>
index and the field's associated core type as recorded by Elasticsearch.
While this list allows you to view the core type of each field, changing
field types must be done using Elasticsearch's
This page lists every field in the <strong>{{::indexPattern.title}}</strong> index and the field's associated core type as recorded by Elasticsearch. To change a field type, use the Elasticsearch
<a target="_window" class="euiLink euiLink--primary" href="http://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html">
Mapping API
<i aria-hidden="true" class="fa-link fa"></i>
@ -145,10 +142,7 @@
class="fields indexed-fields"
></indexed-fields-table>
<scripted-fields-table
ng-show="state.tab == 'scriptedFields'"
class="fields scripted-fields"
></scripted-fields-table>
<div id="reactScriptedFieldsTable"></div>
<source-filters-table
ng-show="state.tab == 'sourceFilters'"

View file

@ -1,7 +1,6 @@
import _ from 'lodash';
import './index_header';
import './indexed_fields_table';
import './scripted_fields_table';
import './scripted_field_editor';
import './source_filters_table';
import { KbnUrlProvider } from 'ui/url';
@ -11,6 +10,50 @@ import uiRoutes from 'ui/routes';
import { uiModules } from 'ui/modules';
import template from './edit_index_pattern.html';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { ScriptedFieldsTable } from './scripted_fields_table';
const REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID = 'reactScriptedFieldsTable';
function updateScriptedFieldsTable($scope, $state) {
if ($state.tab === 'scriptedFields') {
$scope.$$postDigest(() => {
const node = document.getElementById(REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID);
if (!node) {
return;
}
render(
<ScriptedFieldsTable
indexPattern={$scope.indexPattern}
fieldFilter={$scope.fieldFilter}
scriptedFieldLanguageFilter={$scope.scriptedFieldLanguageFilter}
helpers={{
redirectToRoute: (obj, route) => {
$scope.kbnUrl.redirectToRoute(obj, route);
$scope.$apply();
},
getRouteHref: (obj, route) => $scope.kbnUrl.getRouteHref(obj, route),
}}
onRemoveField={() => {
$scope.editSections = $scope.editSectionsProvider($scope.indexPattern);
$scope.refreshFilters();
}}
/>,
node,
);
});
} else {
destroyScriptedFieldsTable();
}
}
function destroyScriptedFieldsTable() {
const node = document.getElementById(REACT_SCRIPTED_FIELDS_DOM_ELEMENT_ID);
node && unmountComponentAtNode(node);
}
uiRoutes
.when('/management/kibana/indices/:indexPatternId', {
template,
@ -45,6 +88,7 @@ uiModules.get('apps/management')
const notify = new Notifier();
const $state = $scope.state = new AppState();
$scope.editSectionsProvider = Private(IndicesEditSectionsProvider);
$scope.kbnUrl = Private(KbnUrlProvider);
$scope.indexPattern = $route.current.locals.indexPattern;
docTitle.change($scope.indexPattern.title);
@ -54,7 +98,7 @@ uiModules.get('apps/management')
});
$scope.$watch('indexPattern.fields', function () {
$scope.editSections = Private(IndicesEditSectionsProvider)($scope.indexPattern);
$scope.editSections = $scope.editSectionsProvider($scope.indexPattern);
$scope.refreshFilters();
});
@ -79,6 +123,7 @@ uiModules.get('apps/management')
$scope.changeTab = function (obj) {
$state.tab = obj.index;
updateScriptedFieldsTable($scope, $state);
$state.save();
};
@ -140,4 +185,22 @@ uiModules.get('apps/management')
$scope.indexPattern.timeFieldName = field.name;
return $scope.indexPattern.save();
};
$scope.$watch('fieldFilter', () => {
if ($scope.fieldFilter !== undefined && $state.tab === 'scriptedFields') {
updateScriptedFieldsTable($scope, $state);
}
});
$scope.$watch('scriptedFieldLanguageFilter', () => {
if ($scope.scriptedFieldLanguageFilter !== undefined && $state.tab === 'scriptedFields') {
updateScriptedFieldsTable($scope, $state);
}
});
$scope.$on('$destory', () => {
destroyScriptedFieldsTable();
});
updateScriptedFieldsTable($scope, $state);
});

View file

@ -15,17 +15,17 @@ export function IndicesEditSectionsProvider() {
return [
{
title: 'fields',
title: 'Fields',
index: 'indexedFields',
count: fieldCount.indexed
},
{
title: 'scripted fields',
title: 'Scripted fields',
index: 'scriptedFields',
count: fieldCount.scripted
},
{
title: 'source filters',
title: 'Source filters',
index: 'sourceFilters',
count: fieldCount.sourceFilters
}

View file

@ -0,0 +1,255 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ScriptedFieldsTable should filter based on the lang filter 1`] = `
<div>
<header />
<call-outs
deprecatedLangsInUse={
Array [
"somethingElse",
]
}
painlessDocLink="painlessDocs"
/>
<eui-button
data-test-subj="addScriptedFieldLink"
>
Add scripted field
</eui-button>
<eui-spacer
size="l"
/>
<Table
deleteField={[Function]}
editField={[Function]}
indexPattern={
Object {
"getScriptedFields": [Function],
}
}
model={
Object {
"criteria": Object {
"page": Object {
"index": 0,
"size": 10,
},
"sort": Object {
"direction": "asc",
"field": "name",
},
},
"data": Object {
"records": Array [
Object {
"lang": "painless",
"name": "ScriptedField",
"script": "x++",
},
Object {
"lang": "painless",
"name": "JustATest",
"script": "z++",
},
],
"totalRecordCount": 2,
},
}
}
onDataCriteriaChange={[Function]}
/>
</div>
`;
exports[`ScriptedFieldsTable should filter based on the query bar 1`] = `
<div>
<header />
<call-outs
deprecatedLangsInUse={Array []}
painlessDocLink="painlessDocs"
/>
<eui-button
data-test-subj="addScriptedFieldLink"
>
Add scripted field
</eui-button>
<eui-spacer
size="l"
/>
<Table
deleteField={[Function]}
editField={[Function]}
indexPattern={
Object {
"getScriptedFields": [Function],
}
}
model={
Object {
"criteria": Object {
"page": Object {
"index": 0,
"size": 10,
},
"sort": Object {
"direction": "asc",
"field": "name",
},
},
"data": Object {
"records": Array [
Object {
"lang": "painless",
"name": "JustATest",
"script": "z++",
},
],
"totalRecordCount": 1,
},
}
}
onDataCriteriaChange={[Function]}
/>
</div>
`;
exports[`ScriptedFieldsTable should hide the table if there are no scripted fields 1`] = `
<div>
<header />
<call-outs
deprecatedLangsInUse={Array []}
painlessDocLink="painlessDocs"
/>
<eui-button
data-test-subj="addScriptedFieldLink"
>
Add scripted field
</eui-button>
<eui-spacer
size="l"
/>
</div>
`;
exports[`ScriptedFieldsTable should render normally 1`] = `
<div>
<header />
<call-outs
deprecatedLangsInUse={Array []}
painlessDocLink="painlessDocs"
/>
<eui-button
data-test-subj="addScriptedFieldLink"
>
Add scripted field
</eui-button>
<eui-spacer
size="l"
/>
<Table
deleteField={[Function]}
editField={[Function]}
indexPattern={
Object {
"getScriptedFields": [Function],
}
}
model={
Object {
"criteria": Object {
"page": Object {
"index": 0,
"size": 10,
},
"sort": Object {
"direction": "asc",
"field": "name",
},
},
"data": Object {
"records": Array [
Object {
"lang": "painless",
"name": "ScriptedField",
"script": "x++",
},
Object {
"lang": "painless",
"name": "JustATest",
"script": "z++",
},
],
"totalRecordCount": 2,
},
}
}
onDataCriteriaChange={[Function]}
/>
</div>
`;
exports[`ScriptedFieldsTable should show a delete modal 1`] = `
<div>
<header />
<call-outs
deprecatedLangsInUse={Array []}
painlessDocLink="painlessDocs"
/>
<eui-button
data-test-subj="addScriptedFieldLink"
>
Add scripted field
</eui-button>
<eui-spacer
size="l"
/>
<Table
deleteField={[Function]}
editField={[Function]}
indexPattern={
Object {
"getScriptedFields": [Function],
}
}
model={
Object {
"criteria": Object {
"page": Object {
"index": 0,
"size": 10,
},
"sort": Object {
"direction": "asc",
"field": "name",
},
},
"data": Object {
"records": Array [
Object {
"lang": "painless",
"name": "ScriptedField",
"script": "x++",
},
Object {
"lang": "painless",
"name": "JustATest",
"script": "z++",
},
],
"totalRecordCount": 2,
},
}
}
onDataCriteriaChange={[Function]}
/>
<eui-overlay-mask>
<eui-confirm-modal
cancelButtonText="Cancel"
confirmButtonText="Delete"
onCancel={[Function]}
onConfirm={[Function]}
title="Delete scripted field 'ScriptedField'?"
/>
</eui-overlay-mask>
</div>
`;

View file

@ -0,0 +1,169 @@
import React from 'react';
import { shallow } from 'enzyme';
import { ScriptedFieldsTable } from '../scripted_fields_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',
Comparators: {
property: () => {},
default: () => {},
},
}));
jest.mock('../components/header', () => ({ Header: 'header' }));
jest.mock('../components/call_outs', () => ({ CallOuts: 'call-outs' }));
jest.mock('../components/table', () => ({
// Note: this seems to fix React complaining about non lowercase attributes
Table: () => {
return 'table';
}
}));
jest.mock('ui/scripting_languages', () => ({
getSupportedScriptingLanguages: () => ['painless'],
getDeprecatedScriptingLanguages: () => [],
}));
jest.mock('ui/documentation_links', () => ({
documentationLinks: {
scriptedFields: {
painless: 'painlessDocs'
}
}
}));
const helpers = {
redirectToRoute: () => {},
getRouteHref: () => {},
};
const indexPattern = {
getScriptedFields: () => ([
{ name: 'ScriptedField', lang: 'painless', script: 'x++' },
{ name: 'JustATest', lang: 'painless', script: 'z++' },
])
};
describe('ScriptedFieldsTable', () => {
it('should render normally', async () => {
const component = shallow(
<ScriptedFieldsTable
indexPattern={indexPattern}
helpers={helpers}
/>
);
// Allow the componentWillMount code to execute
// https://github.com/airbnb/enzyme/issues/450
await component.update(); // Fire `componentWillMount()`
await component.update(); // Force update the component post async actions
expect(component).toMatchSnapshot();
});
it('should filter based on the query bar', async () => {
const component = shallow(
<ScriptedFieldsTable
indexPattern={indexPattern}
helpers={helpers}
/>
);
// Allow the componentWillMount code to execute
// https://github.com/airbnb/enzyme/issues/450
await component.update(); // Fire `componentWillMount()`
await component.update(); // Force update the component post async actions
component.setProps({ fieldFilter: 'Just' });
component.update();
expect(component).toMatchSnapshot();
});
it('should filter based on the lang filter', async () => {
const component = shallow(
<ScriptedFieldsTable
indexPattern={{
getScriptedFields: () => ([
{ name: 'ScriptedField', lang: 'painless', script: 'x++' },
{ name: 'JustATest', lang: 'painless', script: 'z++' },
{ name: 'Bad', lang: 'somethingElse', script: 'z++' },
])
}}
helpers={helpers}
/>
);
// Allow the componentWillMount code to execute
// https://github.com/airbnb/enzyme/issues/450
await component.update(); // Fire `componentWillMount()`
await component.update(); // Force update the component post async actions
component.setProps({ scriptedFieldLanguageFilter: 'painless' });
component.update();
expect(component).toMatchSnapshot();
});
it('should hide the table if there are no scripted fields', async () => {
const component = shallow(
<ScriptedFieldsTable
indexPattern={{
getScriptedFields: () => ([])
}}
helpers={helpers}
/>
);
// Allow the componentWillMount code to execute
// https://github.com/airbnb/enzyme/issues/450
await component.update(); // Fire `componentWillMount()`
await component.update(); // Force update the component post async actions
expect(component).toMatchSnapshot();
});
it('should show a delete modal', async () => {
const component = shallow(
<ScriptedFieldsTable
indexPattern={indexPattern}
helpers={helpers}
/>
);
await component.update(); // Fire `componentWillMount()`
component.instance().startDeleteField({ name: 'ScriptedField' });
await component.update();
// Ensure the modal is visible
expect(component).toMatchSnapshot();
});
it('should delete a field', async () => {
const removeScriptedField = jest.fn();
const component = shallow(
<ScriptedFieldsTable
indexPattern={{
...indexPattern,
removeScriptedField,
}}
helpers={helpers}
/>
);
await component.update(); // Fire `componentWillMount()`
component.instance().startDeleteField({ name: 'ScriptedField' });
await component.update();
await component.instance().deleteField();
await component.update();
expect(removeScriptedField).toBeCalled();
});
});

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallOuts should render normally 1`] = `
<div>
<EuiCallOut
color="danger"
iconType="cross"
size="m"
title="Deprecation languages in use"
>
<p>
The following deprecated languages are in use:
php
. Support for these languages will be removed in the next major version of Kibana and Elasticsearch. Convert you scripted fields to
<EuiLink
color="primary"
href="http://www.elastic.co/painlessDocs"
type="button"
>
Painless
</EuiLink>
to avoid any problems.
</p>
</EuiCallOut>
<EuiSpacer
size="m"
/>
</div>
`;
exports[`CallOuts should render without any call outs 1`] = `""`;

View file

@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import { CallOuts } from '../call_outs';
describe('CallOuts', () => {
it('should render normally', async () => {
const component = shallow(
<CallOuts
deprecatedLangsInUse={['php']}
painlessDocLink="http://www.elastic.co/painlessDocs"
/>
);
expect(component).toMatchSnapshot();
});
it('should render without any call outs', async () => {
const component = shallow(
<CallOuts
deprecatedLangsInUse={[]}
painlessDocLink="http://www.elastic.co/painlessDocs"
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,33 @@
import React from 'react';
import {
EuiCallOut,
EuiLink,
EuiSpacer,
} from '@elastic/eui';
export const CallOuts = ({
deprecatedLangsInUse,
painlessDocLink,
}) => {
if (!deprecatedLangsInUse.length) {
return null;
}
return (
<div>
<EuiCallOut
title="Deprecation languages in use"
color="danger"
iconType="cross"
>
<p>
The following deprecated languages are in use: {deprecatedLangsInUse.join(', ')}.
Support for these languages will be removed in the next major version of Kibana
and Elasticsearch. Convert you scripted fields to <EuiLink href={painlessDocLink}>Painless</EuiLink> to avoid any problems.
</p>
</EuiCallOut>
<EuiSpacer size="m"/>
</div>
);
};

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Header should render normally 1`] = `
<div>
<EuiTitle
size="s"
>
<h3>
Scripted fields
</h3>
</EuiTitle>
<EuiText>
<p>
You can use scripted fields in visualizations and display them in your documents. However, you cannot search scripted fields.
</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,22 @@
import React from 'react';
import {
EuiTitle,
EuiText,
EuiSpacer,
} from '@elastic/eui';
export const Header = () => (
<div>
<EuiTitle size="s">
<h3>Scripted fields</h3>
</EuiTitle>
<EuiText>
<p>
You can use scripted fields in visualizations and display them in your documents.
However, you cannot search scripted fields.
</p>
</EuiText>
<EuiSpacer size="s" />
</div>
);

View file

@ -0,0 +1,99 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Table should render normally 1`] = `
<EuiTableOfRecords
config={
Object {
"columns": Array [
Object {
"dataType": "string",
"description": "Name of the field",
"field": "name",
"name": "Name",
"sortable": true,
},
Object {
"dataType": "string",
"description": "Language used for the field",
"field": "lang",
"name": "Lang",
"render": [Function],
"sortable": true,
},
Object {
"dataType": "string",
"description": "Script for the field",
"field": "script",
"name": "Script",
"sortable": true,
},
Object {
"description": "Format used for the field",
"field": "name",
"name": "Format",
"render": [Function],
"sortable": false,
},
Object {
"actions": Array [
Object {
"description": "Edit this field",
"icon": "pencil",
"name": "Edit",
"onClick": [Function],
},
Object {
"color": "danger",
"description": "Delete this field",
"icon": "trash",
"name": "Delete",
"onClick": [Function],
},
],
"name": "",
},
],
"onDataCriteriaChange": [Function],
"pagination": Object {
"pageSizeOptions": Array [
5,
10,
25,
50,
],
},
"recordId": "id",
"selection": undefined,
}
}
model={
Object {
"criteria": Object {
"page": Object {
"index": 0,
"size": 10,
},
"sort": Object {
"direction": "asc",
"field": "name",
},
},
"data": Object {
"records": Array [
Object {
"id": 1,
"name": "Elastic",
},
],
"totalRecordCount": 1,
},
}
}
/>
`;
exports[`Table should render the format 1`] = `
<span>
string
</span>
`;

View file

@ -0,0 +1,99 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Table } from '../table';
const indexPattern = {
fieldFormatMap: {
Elastic: {
type: {
title: 'string'
}
}
}
};
const model = {
data: {
records: [{ id: 1, name: 'Elastic' }],
totalRecordCount: 1,
},
criteria: {
page: {
index: 0,
size: 10,
},
sort: {
field: 'name',
direction: 'asc'
},
}
};
describe('Table', () => {
it('should render normally', async () => {
const component = shallow(
<Table
indexPattern={indexPattern}
model={model}
editField={() => {}}
deleteField={() => {}}
onDataCriteriaChange={() => {}}
/>
);
expect(component).toMatchSnapshot();
});
it('should render the format', async () => {
const component = shallow(
<Table
indexPattern={indexPattern}
model={model}
editField={() => {}}
deleteField={() => {}}
onDataCriteriaChange={() => {}}
/>
);
const formatTableCell = shallow(component.prop('config').columns[3].render('Elastic'));
expect(formatTableCell).toMatchSnapshot();
});
it('should allow edits', () => {
const editField = jest.fn();
const component = shallow(
<Table
indexPattern={indexPattern}
model={model}
editField={editField}
deleteField={() => {}}
onDataCriteriaChange={() => {}}
/>
);
// Click the delete button
component.prop('config').columns[4].actions[0].onClick();
expect(editField).toBeCalled();
});
it('should allow deletes', () => {
const deleteField = jest.fn();
const component = shallow(
<Table
indexPattern={indexPattern}
model={model}
editField={() => {}}
deleteField={deleteField}
onDataCriteriaChange={() => {}}
/>
);
// Click the delete button
component.prop('config').columns[4].actions[1].onClick();
expect(deleteField).toBeCalled();
});
});

View file

@ -0,0 +1,119 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import {
EuiTableOfRecords
} from '@elastic/eui';
export class Table extends PureComponent {
static propTypes = {
indexPattern: PropTypes.object.isRequired,
model: PropTypes.shape({
data: PropTypes.shape({
records: PropTypes.array.isRequired,
totalRecordCount: PropTypes.number.isRequired,
}).isRequired,
criteria: PropTypes.shape({
page: PropTypes.shape({
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
}).isRequired,
sort: PropTypes.shape({
field: PropTypes.string.isRequired,
direction: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
}),
editField: PropTypes.func.isRequired,
deleteField: PropTypes.func.isRequired,
onDataCriteriaChange: PropTypes.func.isRequired,
}
renderFormatCell = (value) => {
const { indexPattern } = this.props;
const title = indexPattern.fieldFormatMap[value] && indexPattern.fieldFormatMap[value].type
? indexPattern.fieldFormatMap[value].type.title
: '';
return (
<span>{title}</span>
);
}
getTableConfig() {
const { editField, deleteField, onDataCriteriaChange } = this.props;
return {
recordId: 'id',
columns: [
{
field: 'name',
name: 'Name',
description: `Name of the field`,
dataType: 'string',
sortable: true,
},
{
field: 'lang',
name: 'Lang',
description: `Language used for the field`,
dataType: 'string',
sortable: true,
render: value => {
return (
<span data-test-subj="scriptedFieldLang">
{value}
</span>
);
}
},
{
field: 'script',
name: 'Script',
description: `Script for the field`,
dataType: 'string',
sortable: true,
},
{
field: 'name',
name: 'Format',
description: `Format used for the field`,
render: this.renderFormatCell,
sortable: false,
},
{
name: '',
actions: [
{
name: 'Edit',
description: 'Edit this field',
icon: 'pencil',
onClick: editField,
},
{
name: 'Delete',
description: 'Delete this field',
icon: 'trash',
color: 'danger',
onClick: deleteField,
},
]
}
],
pagination: {
pageSizeOptions: [5, 10, 25, 50]
},
selection: undefined,
onDataCriteriaChange,
};
}
render() {
const { model } = this.props;
return (
<EuiTableOfRecords config={this.getTableConfig()} model={model} />
);
}
}

View file

@ -1,26 +0,0 @@
import _ from 'lodash';
export function dateScripts(indexPattern) {
const dateScripts = {};
const scripts = {
__dayOfMonth: 'dayOfMonth',
__dayOfWeek: 'dayOfWeek',
__dayOfYear: 'dayOfYear',
__hourOfDay: 'hourOfDay',
__minuteOfDay: 'minuteOfDay',
__minuteOfHour: 'minuteOfHour',
__monthOfYear: 'monthOfYear',
__weekOfYear: 'weekOfWeekyear',
__year: 'year'
};
_.each(indexPattern.fields.byType.date, function (field) {
if (field.indexed) {
_.each(scripts, function (value, key) {
dateScripts[field.name + '.' + key] = 'doc["' + field.name + '"].date.' + value;
});
}
});
return dateScripts;
}

View file

@ -1 +1 @@
import './scripted_fields_table';
export { ScriptedFieldsTable } from './scripted_fields_table';

View file

@ -0,0 +1,78 @@
import {
getTableOfRecordsState,
} from '../table';
jest.mock('@elastic/eui', () => ({
Comparators: {
property: () => {},
default: () => {},
},
}));
const items = [
{ name: 'Kibana' },
{ name: 'Elasticsearch' },
{ name: 'Logstash' },
];
describe('getTableOfRecordsState', () => {
it('should return a TableOfRecords model', () => {
const model = getTableOfRecordsState(items, {
page: {
index: 0,
size: 10,
},
sort: {
field: 'name',
direction: 'asc',
},
});
expect(model).toEqual({
data: {
records: items,
totalRecordCount: items.length,
},
criteria: {
page: {
index: 0,
size: 10
},
sort: {
field: 'name',
direction: 'asc'
},
}
});
});
it('should paginate', () => {
const model = getTableOfRecordsState(items, {
page: {
index: 1,
size: 1,
},
sort: {
field: 'name',
direction: 'asc',
},
});
expect(model).toEqual({
data: {
records: [{ name: 'Elasticsearch' }],
totalRecordCount: items.length,
},
criteria: {
page: {
index: 1,
size: 1
},
sort: {
field: 'name',
direction: 'asc'
},
}
});
});
});

View file

@ -0,0 +1,61 @@
import {
Comparators
} from '@elastic/eui';
export const getPage = (data, pageIndex, pageSize, sort) => {
let list = data;
if (sort) {
list = data.sort(Comparators.property(sort.field, Comparators.default(sort.direction)));
}
if (!pageIndex && !pageSize) {
return {
index: 0,
size: list.length,
items: list,
totalRecordCount: list.length
};
}
const from = pageIndex * pageSize;
const items = list.slice(from, Math.min(from + pageSize, list.length));
return {
index: pageIndex,
size: pageSize,
items,
totalRecordCount: list.length
};
};
export const getTableOfRecordsState = (items, criteria) => {
const page = getPage(items, criteria.page.index, criteria.page.size, criteria.sort);
return {
data: {
records: page.items,
totalRecordCount: page.totalRecordCount,
},
criteria: {
page: {
index: page.index,
size: page.size
},
sort: criteria.sort,
}
};
};
export const DEFAULT_TABLE_OF_RECORDS_STATE = {
data: {
records: [],
totalRecordCount: 0,
},
criteria: {
page: {
index: 0,
size: 10,
},
sort: {
field: 'name',
direction: 'asc',
}
}
};

View file

@ -1,80 +0,0 @@
<h3 class="kuiTextTitle kuiVerticalRhythm">
Scripted fields
</h3>
<p class="kuiText kuiVerticalRhythm">
These scripted fields are computed on the fly from your data. They can be used in visualizations and displayed in your documents, however they can not be searched. You can manage them here and add new ones as you see fit, but be careful, scripts can be tricky!
</p>
<div class="kuiInfoPanel kuiInfoPanel--warning kuiVerticalRhythm" ng-if="getDeprecatedLanguagesInUse().length !== 0">
<div class="kuiInfoPanelHeader">
<span
class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--warning fa-bolt"
aria-label="Warning"
role="img"
></span>
<span class="kuiInfoPanelHeader__title">
Deprecation Warning
</span>
</div>
<div class="kuiInfoPanelBody">
<div class="kuiInfoPanelBody__message">
We've detected that the following deprecated languages are in use: {{ getDeprecatedLanguagesInUse().join(', ') }}.
Support for these languages will be removed in the next major version of Kibana and Elasticsearch.
We recommend converting your scripted fields to
<a class="kuiLink" ng-href="{{docLinks.painless}}">Painless</a>.
</div>
</div>
</div>
<div class="kuiInfoPanel kuiInfoPanel--error kuiVerticalRhythm" ng-if="getUnsupportedLanguagesInUse().length !== 0">
<div class="kuiInfoPanelHeader">
<span
class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--error fa-warning"
aria-label="Error"
role="img"
></span>
<span class="kuiInfoPanelHeader__title">
Unsupported Languages
</span>
</div>
<div class="kuiInfoPanelBody">
<div class="kuiInfoPanelBody__message">
We've detected that the following unsupported languages are in use: {{ getUnsupportedLanguagesInUse().join(', ') }}.
All scripted fields must be converted to <a class="kuiLink" ng-href="{{docLinks.painless}}">Painless</a>.
</div>
</div>
</div>
<a
data-test-subj="addScriptedFieldLink"
ng-href="{{ kbnUrl.getRouteHref(indexPattern, 'addField') }}"
class="kuiButton kuiButton--primary kuiButton--iconText kuiVerticalRhythm"
>
<span aria-hidden="true" class="kuiButton__icon kuiIcon fa-plus"></span>
<span>Add Scripted Field</span>
</a>
<div class="kuiVerticalRhythm">
<paginated-table
columns="columns"
rows="rows"
per-page="perPage"
link-to-top="true"
show-blank-rows="false"
></paginated-table>
</div>
<div
class="kuiInfoPanel kuiInfoPanel--info kuiVerticalRhythm"
ng-if="rows.length === 0"
>
<div class="kuiInfoPanelHeader">
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--info fa-info"></span>
<span class="kuiInfoPanelHeader__title">
No scripted fields found.
</span>
</div>
</div>

View file

@ -1,138 +1,192 @@
import _ from 'lodash';
import 'ui/paginated_table';
import fieldControlsHtml from '../field_controls.html';
import { dateScripts } from './date_scripts';
import { uiModules } from 'ui/modules';
import { toastNotifications } from 'ui/notify';
import template from './scripted_fields_table.html';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { getSupportedScriptingLanguages, getDeprecatedScriptingLanguages } from 'ui/scripting_languages';
import { documentationLinks } from 'ui/documentation_links/documentation_links';
import { documentationLinks } from 'ui/documentation_links';
uiModules.get('apps/management')
.directive('scriptedFieldsTable', function (kbnUrl, $filter, confirmModal) {
const rowScopes = []; // track row scopes, so they can be destroyed as needed
const filter = $filter('filter');
import {
EuiButton,
EuiSpacer,
EuiOverlayMask,
EuiConfirmModal,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
return {
restrict: 'E',
template,
scope: true,
link: function ($scope) {
import { Table } from './components/table';
import { Header } from './components/header';
import { CallOuts } from './components/call_outs';
import { getTableOfRecordsState, DEFAULT_TABLE_OF_RECORDS_STATE } from './lib';
const fieldCreatorPath = '/management/kibana/indices/{{ indexPattern }}/scriptedField';
const fieldEditorPath = fieldCreatorPath + '/{{ fieldName }}';
$scope.docLinks = documentationLinks.scriptedFields;
$scope.perPage = 25;
$scope.columns = [
{ title: 'name' },
{ title: 'lang' },
{ title: 'script' },
{ title: 'format' },
{ title: 'controls', sortable: false }
];
export class ScriptedFieldsTable extends Component {
static propTypes = {
indexPattern: PropTypes.object.isRequired,
fieldFilter: PropTypes.string,
scriptedFieldLanguageFilter: PropTypes.string,
helpers: PropTypes.shape({
redirectToRoute: PropTypes.func.isRequired,
getRouteHref: PropTypes.func.isRequired,
}),
onRemoveField: PropTypes.func,
}
$scope.$watchMulti(['[]indexPattern.fields', 'fieldFilter', 'scriptedFieldLanguageFilter'], refreshRows);
constructor(props) {
super(props);
function refreshRows() {
_.invoke(rowScopes, '$destroy');
rowScopes.length = 0;
const fields = filter($scope.indexPattern.getScriptedFields(), {
name: $scope.fieldFilter,
lang: $scope.scriptedFieldLanguageFilter
});
_.find($scope.editSections, { index: 'scriptedFields' }).count = fields.length; // Update the tab count
$scope.rows = fields.map(function (field) {
const rowScope = $scope.$new();
rowScope.field = field;
rowScopes.push(rowScope);
return [
_.escape(field.name),
{
markup: field.lang,
attr: {
'data-test-subj': 'scriptedFieldLang'
}
},
_.escape(field.script),
_.get($scope.indexPattern, ['fieldFormatMap', field.name, 'type', 'title']),
{
markup: fieldControlsHtml,
scope: rowScope
}
];
});
}
$scope.addDateScripts = function () {
const conflictFields = [];
let fieldsAdded = 0;
_.each(dateScripts($scope.indexPattern), function (script, field) {
try {
$scope.indexPattern.addScriptedField(field, script, 'number');
fieldsAdded++;
} catch (e) {
conflictFields.push(field);
}
});
if (fieldsAdded > 0) {
toastNotifications.addSuccess({
title: 'Created script fields',
text: `Created ${fieldsAdded}`,
});
}
if (conflictFields.length > 0) {
toastNotifications.addWarning({
title: `Didn't add duplicate fields`,
text: `${conflictFields.length} fields: ${conflictFields.join(', ')}`,
});
}
};
$scope.create = function () {
const params = {
indexPattern: $scope.indexPattern.id
};
kbnUrl.change(fieldCreatorPath, params);
};
$scope.edit = function (field) {
const params = {
indexPattern: $scope.indexPattern.id,
fieldName: field.name
};
kbnUrl.change(fieldEditorPath, params);
};
$scope.remove = function (field) {
const confirmModalOptions = {
confirmButtonText: 'Delete',
onConfirm: () => { $scope.indexPattern.removeScriptedField(field.name); },
title: `Delete scripted field '${field.name}'?`
};
confirmModal(`You can't recover scripted fields.`, confirmModalOptions);
};
function getLanguagesInUse() {
const fields = $scope.indexPattern.getScriptedFields();
return _.uniq(_.map(fields, 'lang'));
}
$scope.getDeprecatedLanguagesInUse = function () {
return _.intersection(getLanguagesInUse(), getDeprecatedScriptingLanguages());
};
$scope.getUnsupportedLanguagesInUse = function () {
return _.difference(getLanguagesInUse(), _.union(getSupportedScriptingLanguages(), getDeprecatedScriptingLanguages()));
};
}
this.state = {
deprecatedLangsInUse: [],
fieldToDelete: undefined,
isDeleteConfirmationModalVisible: false,
fields: [],
...DEFAULT_TABLE_OF_RECORDS_STATE,
};
});
}
componentWillMount() {
this.fetchFields();
}
fetchFields = async () => {
const fields = await this.props.indexPattern.getScriptedFields();
const deprecatedLangsInUse = [];
const deprecatedLangs = getDeprecatedScriptingLanguages();
const supportedLangs = getSupportedScriptingLanguages();
for (const { lang } of fields) {
if (deprecatedLangs.includes(lang) || !supportedLangs.includes(lang)) {
deprecatedLangsInUse.push(lang);
}
}
this.setState({
fields,
deprecatedLangsInUse,
...this.computeTableState(this.state.criteria, this.props, fields)
});
}
onDataCriteriaChange = criteria => {
this.setState(this.computeTableState(criteria));
}
componentWillReceiveProps(nextProps) {
if (this.props.fieldFilter !== nextProps.fieldFilter) {
this.setState(this.computeTableState(this.state.criteria, nextProps));
}
if (this.props.scriptedFieldLanguageFilter !== nextProps.scriptedFieldLanguageFilter) {
this.setState(this.computeTableState(this.state.criteria, nextProps));
}
}
computeTableState(criteria, props = this.props, fields = this.state.fields) {
let items = fields;
if (props.fieldFilter) {
const fieldFilter = props.fieldFilter.toLowerCase();
items = items.filter(field => field.name.toLowerCase().includes(fieldFilter));
}
if (props.scriptedFieldLanguageFilter) {
items = items.filter(field => field.lang === props.scriptedFieldLanguageFilter);
}
return getTableOfRecordsState(items, criteria);
}
renderCallOuts() {
const { deprecatedLangsInUse } = this.state;
return (
<CallOuts
deprecatedLangsInUse={deprecatedLangsInUse}
painlessDocLink={documentationLinks.scriptedFields.painless}
/>
);
}
startDeleteField = field => {
this.setState({ fieldToDelete: field, isDeleteConfirmationModalVisible: true });
}
hideDeleteConfirmationModal = () => {
this.setState({ fieldToDelete: undefined, isDeleteConfirmationModalVisible: false });
}
deleteField = () => {
const { indexPattern, onRemoveField } = this.props;
const { fieldToDelete } = this.state;
indexPattern.removeScriptedField(fieldToDelete.name);
onRemoveField && onRemoveField();
this.fetchFields();
this.hideDeleteConfirmationModal();
}
renderDeleteConfirmationModal() {
const { fieldToDelete } = this.state;
if (!fieldToDelete) {
return null;
}
return (
<EuiOverlayMask>
<EuiConfirmModal
title={`Delete scripted field '${fieldToDelete.name}'?`}
onCancel={this.hideDeleteConfirmationModal}
onConfirm={this.deleteField}
cancelButtonText="Cancel"
confirmButtonText="Delete"
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
/>
</EuiOverlayMask>
);
}
render() {
const {
helpers,
indexPattern,
} = this.props;
const {
data,
criteria: {
page,
sort,
},
fields,
} = this.state;
const model = {
data,
criteria: {
page,
sort,
},
};
return (
<div>
<Header/>
{this.renderCallOuts()}
<EuiButton
data-test-subj="addScriptedFieldLink"
href={helpers.getRouteHref(indexPattern, 'addField')}
>
Add scripted field
</EuiButton>
<EuiSpacer size="l" />
{ fields.length > 0 ?
<Table
indexPattern={indexPattern}
model={model}
editField={field => this.props.helpers.redirectToRoute(field, 'edit')}
deleteField={this.startDeleteField}
onDataCriteriaChange={this.onDataCriteriaChange}
/>
: null
}
{this.renderDeleteConfirmationModal()}
</div>
);
}
}