Advanced Settings to React/EUI (#18878)

This commit is contained in:
Jen Huang 2018-05-07 16:20:56 -07:00 committed by GitHub
parent 4d3b173272
commit 8079bec770
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 4901 additions and 634 deletions

View file

@ -8,7 +8,7 @@ for displayed decimal values.
To set advanced options:
. Go to *Settings > Advanced*.
. Click the *Edit* button for the option you want to modify.
. Scroll or use the search bar to find the option you want to modify.
. Enter a new value for the option.
. Click the *Save* button.

View file

@ -0,0 +1,357 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdvancedSettings should render normally 1`] = `
<div
className="advancedSettings"
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiText
grow={true}
>
<h1>
Settings
</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<Search
categories={
Array [
"general",
"elasticsearch",
]
}
onQueryChange={[Function]}
query={
Query {
"ast": _AST {
"_clauses": Array [],
"_indexedClauses": Object {
"field": Object {},
"is": Object {},
"term": Array [],
},
},
"syntax": Object {
"parse": [Function],
"print": [Function],
},
"text": "",
}
}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<CallOuts />
<EuiSpacer
size="m"
/>
<Form
categories={
Array [
"general",
"elasticsearch",
]
}
categoryCounts={
Object {
"elasticsearch": 2,
"general": 7,
}
}
clear={[Function]}
clearQuery={[Function]}
save={[Function]}
settings={
Object {
"elasticsearch": Array [
Object {
"ariaName": "test array setting",
"category": Array [
"elasticsearch",
],
"defVal": Array [
"default_value",
],
"description": "Description for Test array setting",
"displayName": "Test array setting",
"isCustom": undefined,
"name": "test:array:setting",
"options": undefined,
"readonly": false,
"type": "array",
"value": undefined,
},
Object {
"ariaName": "test boolean setting",
"category": Array [
"elasticsearch",
],
"defVal": true,
"description": "Description for Test boolean setting",
"displayName": "Test boolean setting",
"isCustom": undefined,
"name": "test:boolean:setting",
"options": undefined,
"readonly": false,
"type": "boolean",
"value": undefined,
},
],
"general": Array [
Object {
"ariaName": "test customstring setting",
"category": Array [
"general",
],
"defVal": null,
"description": "Description for Test custom string setting",
"displayName": "Test custom string setting",
"isCustom": undefined,
"name": "test:customstring:setting",
"options": undefined,
"readonly": false,
"type": "string",
"value": undefined,
},
Object {
"ariaName": "test image setting",
"category": Array [
"general",
],
"defVal": null,
"description": "Description for Test image setting",
"displayName": "Test image setting",
"isCustom": undefined,
"name": "test:image:setting",
"options": undefined,
"readonly": false,
"type": "image",
"value": undefined,
},
Object {
"ariaName": "test json setting",
"category": Array [
"general",
],
"defVal": "{\\"foo\\": \\"bar\\"}",
"description": "Description for Test json setting",
"displayName": "Test json setting",
"isCustom": undefined,
"name": "test:json:setting",
"options": undefined,
"readonly": false,
"type": "json",
"value": undefined,
},
Object {
"ariaName": "test markdown setting",
"category": Array [
"general",
],
"defVal": "",
"description": "Description for Test markdown setting",
"displayName": "Test markdown setting",
"isCustom": undefined,
"name": "test:markdown:setting",
"options": undefined,
"readonly": false,
"type": "markdown",
"value": undefined,
},
Object {
"ariaName": "test number setting",
"category": Array [
"general",
],
"defVal": 5,
"description": "Description for Test number setting",
"displayName": "Test number setting",
"isCustom": undefined,
"name": "test:number:setting",
"options": undefined,
"readonly": false,
"type": "number",
"value": undefined,
},
Object {
"ariaName": "test select setting",
"category": Array [
"general",
],
"defVal": "orange",
"description": "Description for Test select setting",
"displayName": "Test select setting",
"isCustom": undefined,
"name": "test:select:setting",
"options": Array [
"apple",
"orange",
"banana",
],
"readonly": false,
"type": "select",
"value": undefined,
},
Object {
"ariaName": "test string setting",
"category": Array [
"general",
],
"defVal": null,
"description": "Description for Test string setting",
"displayName": "Test string setting",
"isCustom": undefined,
"name": "test:string:setting",
"options": undefined,
"readonly": false,
"type": "string",
"value": undefined,
},
],
}
}
/>
</div>
`;
exports[`AdvancedSettings should render specific setting if given setting key 1`] = `
<div
className="advancedSettings"
>
<EuiFlexGroup
alignItems="stretch"
component="div"
direction="row"
gutterSize="none"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={true}
>
<EuiText
grow={true}
>
<h1>
Settings
</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={true}
>
<Search
categories={
Array [
"general",
"elasticsearch",
]
}
onQueryChange={[Function]}
query={
Query {
"ast": _AST {
"_clauses": Array [
Object {
"field": "ariaName",
"match": "must",
"operator": "eq",
"type": "field",
"value": "test string setting",
},
],
"_indexedClauses": Object {
"field": Object {
"ariaName": Array [
Object {
"field": "ariaName",
"match": "must",
"operator": "eq",
"type": "field",
"value": "test string setting",
},
],
},
"is": Object {},
"term": Array [],
},
},
"syntax": Object {
"parse": [Function],
"print": [Function],
},
"text": "ariaName:\\"test string setting\\"",
}
}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer
size="m"
/>
<CallOuts />
<EuiSpacer
size="m"
/>
<Form
categories={
Array [
"general",
"elasticsearch",
]
}
categoryCounts={
Object {
"elasticsearch": 2,
"general": 7,
}
}
clear={[Function]}
clearQuery={[Function]}
save={[Function]}
settings={
Object {
"general": Array [
Object {
"ariaName": "test string setting",
"category": Array [
"general",
],
"defVal": null,
"description": "Description for Test string setting",
"displayName": "Test string setting",
"isCustom": undefined,
"name": "test:string:setting",
"options": undefined,
"readonly": false,
"type": "string",
"value": undefined,
},
],
}
}
/>
</div>
`;

View file

@ -1,225 +0,0 @@
<tr class="kuiTableRow">
<!-- Name -->
<td class="kuiTableRowCell kuiTableRowCell--wrap">
<div class="kuiTableRowCell__liner">
<p class="kuiTextTitle kuiVerticalRhythmSmall">
{{conf.name}}
</p>
<p
class="kuiSubText kuiSubduedText kuiVerticalRhythmSmall"
ng-if="!isDefaultValue(conf)"
>
Default: <em>{{conf.defVal == undefined || conf.defVal === '' ? 'null' : conf.defVal}}</em>
</p>
<p
class="kuiSubText kuiSubduedText kuiVerticalRhythmSmall"
ng-if="conf.isCustom"
>
(Custom setting)
</p>
<p
class="kuiSubText kuiVerticalRhythmSmall"
ng-bind-html="conf.description | trustAsHtml"
></p>
</div>
</td>
<!-- Value -->
<td class="kuiTableRowCell">
<div class="kuiTableRowCell__liner">
<!-- Settings editors -->
<form
name="forms.configEdit"
ng-if="conf.editing"
ng-submit="save(conf)"
role="form"
>
<input
ng-if="conf.normal"
ng-model="conf.unsavedValue"
ng-keyup="maybeCancel($event, conf)"
placeholder="{{conf.value || conf.defVal}}"
type="text"
class="kuiTextInput fullWidth"
>
<textarea
ng-if="conf.markdown"
type="text"
class="kuiTextArea fullWidth"
ng-model="conf.unsavedValue"
ng-keyup="maybeCancel($event, conf)"
elastic-textarea
data-test-subj="unsavedValueMarkdownTextArea"
></textarea>
<textarea
ng-if="conf.json"
type="text"
class="kuiTextArea fullWidth"
ng-model="conf.unsavedValue"
ng-keyup="maybeCancel($event, conf)"
elastic-textarea
validate-json
data-test-subj="unsavedValueJsonTextArea"
></textarea>
<p
class="kuiSubText"
ng-show="forms.configEdit.$error.jsonInput"
>
Invalid JSON syntax
</p>
<input
ng-if="conf.array"
ng-list=","
ng-model="conf.unsavedValue"
ng-keyup="maybeCancel($event, conf)"
placeholder="{{(conf.value || conf.defVal).join(', ')}}"
type="text"
class="kuiTextInput fullWidth"
>
<input
ng-if="conf.bool"
ng-model="conf.unsavedValue"
type="checkbox"
class="kuiCheckBox"
data-test-subj="advancedSetting-{{conf.name}}-checkbox"
>
<select
ng-if="conf.select"
name="conf.name"
ng-model="conf.unsavedValue"
ng-options="option as option for option in conf.options"
class="kuiSelect"
></select>
<input
ng-if="conf.image"
ng-model="conf.unsavedValue"
input-base-sixty-four
max="{{conf.options.maxSize && conf.options.maxSize.length}}"
accept=".jpg,.jpeg,.png"
type="file"
class="fullWidth"
on-change="onImageChange"
>
<p
class="kuiSubText"
ng-show="forms.configEdit.$error.maxSize"
>
Image is too large, maximum size is {{conf.options.maxSize.description}}
</p>
</form>
<!-- Setting display formats -->
<span
ng-if="!conf.editing"
data-test-subj="advancedSetting-{{conf.name}}-currentValue"
>
<span ng-if="(conf.normal || conf.json || conf.select)">
{{conf.value || conf.defVal}}
</span>
<span ng-if="conf.array">
{{(conf.value || conf.defVal).join(', ')}}
</span>
<span ng-if="conf.bool">
{{conf.value === undefined ? conf.defVal : conf.value}}
</span>
<span
ng-if="conf.markdown"
ng-bind-html="conf.value | markdown"
></span>
<img
alt="Image"
ng-if="conf.image && conf.value"
ng-src="{{ conf.value }}"
class="advancedSettingsTableRowImage"
></img>
</span>
</div>
</td>
<!-- Actions -->
<td class="kuiTableRowCell advancedSettingsTableRowActionsCell">
<div class="kuiTableRowCell__liner">
<div class="kuiMenuButtonGroup kuiMenuButtonGroup--alignRight">
<!-- Edit -->
<button
ng-if="!conf.editing"
ng-click="edit(conf)"
class="kuiMenuButton kuiMenuButton--basic kuiMenuButton--iconText"
ng-disabled="conf.tooComplex"
aria-label="Edit {{conf.ariaName}}"
data-test-subj="advancedSetting-{{conf.name}}-editButton"
>
<span
aria-hidden="true"
class="kuiMenuButton__icon kuiIcon fa-pencil"
></span>
<span>Edit</span>
</button>
<!-- Save -->
<button
ng-if="conf.editing"
ng-click="save(conf)"
class="kuiMenuButton kuiMenuButton--primary kuiMenuButton--iconText"
ng-disabled="conf.loading || conf.tooComplex || forms.configEdit.$invalid"
aria-label="Save edit"
data-test-subj="advancedSetting-{{conf.name}}-saveButton"
>
<span
aria-hidden="true"
ng-if="!conf.loading"
class="kuiMenuButton__icon kuiIcon fa-save"
></span>
<span
aria-hidden="true"
ng-if="conf.loading"
class="kuiMenuButton__icon kuiIcon fa-spinner"
></span>
<span>Save</span>
</button>
<!-- Clear -->
<button
ng-if="!conf.editing"
ng-click="clear(conf)"
ng-hide="isDefaultValue(conf)"
aria-label="Clear {{conf.ariaName}}"
class="kuiMenuButton kuiMenuButton--danger kuiMenuButton--iconText"
data-test-subj="advancedSetting-{{conf.name}}-clearButton"
>
<span
aria-hidden="true"
class="kuiMenuButton__icon kuiIcon fa-trash-o"
></span>
<span>Clear</span>
</button>
<!-- Cancel -->
<button
ng-if="conf.editing"
ng-click="cancelEdit(conf)"
class="kuiMenuButton kuiMenuButton--basic kuiMenuButton--iconText"
aria-label="Cancel edit"
>
<span
aria-hidden="true"
class="kuiMenuButton__icon kuiIcon fa-times"
></span>
<span>Cancel</span>
</button>
</div>
</div>
</td>
</tr>

View file

@ -1,81 +0,0 @@
import 'ui/elastic_textarea';
import 'ui/filters/markdown';
import { uiModules } from 'ui/modules';
import { fatalError } from 'ui/notify';
import { keyCodes } from '@elastic/eui';
import advancedRowTemplate from './advanced_row.html';
uiModules.get('apps/management')
.directive('advancedRow', function (config) {
return {
restrict: 'A',
replace: true,
template: advancedRowTemplate,
scope: {
conf: '=advancedRow',
configs: '='
},
link: function ($scope) {
// To allow passing form validation state back
$scope.forms = {};
// setup loading flag, run async op, then clear loading and editing flag (just in case)
const loading = function (conf, fn) {
conf.loading = true;
fn()
.then(function () {
conf.loading = conf.editing = false;
})
.catch(fatalError);
};
$scope.maybeCancel = function ($event, conf) {
if ($event.keyCode === keyCodes.ESCAPE) {
$scope.cancelEdit(conf);
}
};
$scope.edit = function (conf) {
conf.unsavedValue = conf.value == null ? conf.defVal : conf.value;
$scope.configs.forEach(function (c) {
c.editing = (c === conf);
});
};
$scope.save = function (conf) {
// an empty JSON is valid as per the validateJson directive.
// set the value to empty JSON in this case so that its parsing upon retrieving the setting does not fail.
if (conf.type === 'json' && conf.unsavedValue === '') {
conf.unsavedValue = '{}';
}
loading(conf, function () {
if (conf.unsavedValue === conf.defVal) {
return config.remove(conf.name);
}
return config.set(conf.name, conf.unsavedValue);
});
};
$scope.cancelEdit = function (conf) {
conf.editing = false;
};
$scope.clear = function (conf) {
return loading(conf, function () {
return config.remove(conf.name);
});
};
$scope.isDefaultValue = (conf) => {
// conf.isCustom = custom setting, provided by user, so there is no notion of
// having a default or non-default value for it
return conf.isCustom
|| conf.value === undefined
|| conf.value === ''
|| String(conf.value) === String(conf.defVal);
};
}
};
});

View file

@ -0,0 +1,146 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
Comparators,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
Query,
} from '@elastic/eui';
import { CallOuts } from './components/call_outs';
import { Search } from './components/search';
import { Form } from './components/form';
import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib';
import './advanced_settings.less';
export class AdvancedSettings extends Component {
static propTypes = {
config: PropTypes.object.isRequired,
query: PropTypes.string,
}
constructor(props) {
super(props);
const { config, query } = this.props;
const parsedQuery = Query.parse(query ? `ariaName:"${getAriaName(query)}"` : '');
this.init(config);
this.state = {
query: parsedQuery,
filteredSettings: this.mapSettings(Query.execute(parsedQuery, this.settings)),
};
}
init(config) {
this.settings = this.mapConfig(config);
this.groupedSettings = this.mapSettings(this.settings);
this.categories = Object.keys(this.groupedSettings).sort((a, b) => {
if(a === DEFAULT_CATEGORY) return -1;
if(b === DEFAULT_CATEGORY) return 1;
if(a > b) return 1;
return a === b ? 0 : -1;
});
this.categoryCounts = Object.keys(this.groupedSettings).reduce((counts, category) => {
counts[category] = this.groupedSettings[category].length;
return counts;
}, {});
}
componentWillReceiveProps(nextProps) {
const { config } = nextProps;
const { query } = this.state;
this.init(config);
this.setState({
filteredSettings: this.mapSettings(Query.execute(query, this.settings)),
});
}
mapConfig(config) {
const all = config.getAll();
return Object.entries(all)
.map((setting) => {
return toEditableConfig({
def: setting[1],
name: setting[0],
value: setting[1].userValue,
isCustom: config.isCustom(setting[0]),
});
})
.filter((c) => !c.readonly)
.sort(Comparators.property('name', Comparators.default('asc')));
}
mapSettings(settings) {
// Group settings by category
return settings.reduce((groupedSettings, setting) => {
// We will want to change this logic when we put each category on its
// own page aka allowing a setting to be included in multiple categories.
const category = setting.category[0];
(groupedSettings[category] = groupedSettings[category] || []).push(setting);
return groupedSettings;
}, {});
}
saveConfig = (name, value) => {
return this.props.config.set(name, value);
}
clearConfig = (name) => {
return this.props.config.remove(name);
}
onQueryChange = (query) => {
this.setState({
query,
filteredSettings: this.mapSettings(Query.execute(query, this.settings)),
});
}
clearQuery = () => {
this.setState({
query: Query.parse(''),
filteredSettings: this.groupedSettings,
});
}
render() {
const { filteredSettings, query } = this.state;
return (
<div className="advancedSettings">
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<EuiText>
<h1>Settings</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<Search
query={query}
categories={this.categories}
onQueryChange={this.onQueryChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<CallOuts/>
<EuiSpacer size="m" />
<Form
settings={filteredSettings}
categories={this.categories}
categoryCounts={this.categoryCounts}
clearQuery={this.clearQuery}
save={this.saveConfig}
clear={this.clearConfig}
/>
</div>
);
}
}

View file

@ -0,0 +1,30 @@
@import (reference) '~ui/styles/variables/colors';
.advancedSettings {
padding: 20px;
background: @globalColorLightestGray;
min-height: calc(~"100vh - 70px");
> div {
max-width: 1000px;
margin: 0 auto;
}
.advancedSettings__field {
+ * {
margin-top: 24px;
}
&__wrapper {
width: 720px;
}
&__actions {
padding-top: 30px;
}
.euiFormHelpText {
padding-bottom: 0;
}
}
}

View file

@ -0,0 +1,119 @@
import React from 'react';
import { shallow } from 'enzyme';
import { AdvancedSettings } from './advanced_settings';
jest.mock('./components/field', () => ({
Field: () => {
return 'field';
}
}));
jest.mock('./components/call_outs', () => ({
CallOuts: () => {
return 'callOuts';
}
}));
jest.mock('./components/search', () => ({
Search: () => {
return 'search';
}
}));
const config = {
set: () => {},
remove: () => {},
isCustom: (setting) => setting.isCustom,
getAll: () => {
return {
'test:array:setting': {
value: ['default_value'],
name: 'Test array setting',
description: 'Description for Test array setting',
category: ['elasticsearch'],
},
'test:boolean:setting': {
value: true,
name: 'Test boolean setting',
description: 'Description for Test boolean setting',
category: ['elasticsearch'],
},
'test:image:setting': {
value: null,
name: 'Test image setting',
description: 'Description for Test image setting',
type: 'image',
},
'test:json:setting': {
value: '{"foo": "bar"}',
name: 'Test json setting',
description: 'Description for Test json setting',
type: 'json',
},
'test:markdown:setting': {
value: '',
name: 'Test markdown setting',
description: 'Description for Test markdown setting',
type: 'markdown',
},
'test:number:setting': {
value: 5,
name: 'Test number setting',
description: 'Description for Test number setting',
},
'test:select:setting': {
value: 'orange',
name: 'Test select setting',
description: 'Description for Test select setting',
type: 'select',
options: ['apple', 'orange', 'banana'],
},
'test:string:setting': {
value: null,
name: 'Test string setting',
description: 'Description for Test string setting',
type: 'string',
isCustom: true,
},
'test:readonlystring:setting': {
value: null,
name: 'Test readonly string setting',
description: 'Description for Test readonly string setting',
type: 'string',
readonly: true,
},
'test:customstring:setting': {
value: null,
name: 'Test custom string setting',
description: 'Description for Test custom string setting',
type: 'string',
isCustom: true,
},
};
}
};
describe('AdvancedSettings', () => {
it('should render normally', async () => {
const component = shallow(
<AdvancedSettings
config={config}
/>
);
expect(component).toMatchSnapshot();
});
it('should render specific setting if given setting key', async () => {
const component = shallow(
<AdvancedSettings
config={config}
query="test:string:setting"
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallOuts should render normally 1`] = `
<div>
<EuiCallOut
color="warning"
iconType="bolt"
size="m"
title="Caution: You can break stuff here"
>
<p>
Be careful in here, these settings are for very advanced users only. Tweaks you make here can break large portions of Kibana. Some of these settings may be undocumented, unsupported or experimental. If a field has a default value, blanking the field will reset it to its default which may be unacceptable given other configuration directives. Deleting a custom setting will permanently remove it from Kibana's config.
</p>
</EuiCallOut>
</div>
`;

View file

@ -0,0 +1,26 @@
import React from 'react';
import {
EuiCallOut,
} from '@elastic/eui';
export const CallOuts = () => {
return (
<div>
<EuiCallOut
title="Caution: You can break stuff here"
color="warning"
iconType="bolt"
>
<p>
Be careful in here, these settings are for very advanced users only.
Tweaks you make here can break large portions of Kibana.
Some of these settings may be undocumented, unsupported or experimental.
If a field has a default value, blanking the field will reset it to its default which may be
unacceptable given other configuration directives.
Deleting a custom setting will permanently remove it from Kibana&apos;s config.
</p>
</EuiCallOut>
</div>
);
};

View file

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

View file

@ -0,0 +1 @@
export { CallOuts } from './call_outs';

View file

@ -0,0 +1,568 @@
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import 'brace/theme/textmate';
import 'brace/mode/markdown';
import { toastNotifications } from 'ui/notify';
import {
EuiButton,
EuiButtonEmpty,
EuiCode,
EuiCodeEditor,
EuiDescribedFormGroup,
EuiFieldNumber,
EuiFieldText,
EuiFilePicker,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiImage,
EuiLink,
EuiSpacer,
EuiText,
EuiSelect,
EuiSwitch,
keyCodes,
} from '@elastic/eui';
import { isDefaultValue } from '../../lib';
export class Field extends PureComponent {
static propTypes = {
setting: PropTypes.object.isRequired,
save: PropTypes.func.isRequired,
clear: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
const { type, value, defVal } = this.props.setting;
const editableValue = this.getEditableValue(type, value, defVal);
this.state = {
isInvalid: false,
error: null,
loading: false,
changeImage: false,
savedValue: editableValue,
unsavedValue: editableValue,
isJsonArray: type === 'json' ? Array.isArray(JSON.parse(defVal || '{}')) : false,
};
this.changeImageForm = null;
}
componentWillReceiveProps(nextProps) {
const { unsavedValue } = this.state;
const { type, value, defVal } = nextProps.setting;
const editableValue = this.getEditableValue(type, value, defVal);
this.setState({
savedValue: editableValue,
unsavedValue: (value === null || value === undefined) ? editableValue : unsavedValue,
});
}
getEditableValue(type, value, defVal) {
const val = (value === null || value === undefined) ? defVal : value;
switch(type) {
case 'array':
return val.join(', ');
case 'boolean':
return !!val;
case 'number':
return Number(val);
case 'image':
return val;
default:
return val || '';
}
}
getDisplayedDefaultValue(type, defVal) {
if(defVal === undefined || defVal === null || defVal === '') {
return 'null';
}
switch(type) {
case 'array':
return defVal.join(', ');
default:
return String(defVal);
}
}
setLoading(loading) {
this.setState({
loading
});
}
clearError() {
this.setState({
isInvalid: false,
error: null,
});
}
onCodeEditorChange = (value) => {
const { type } = this.props.setting;
const { isJsonArray } = this.state;
let newUnsavedValue = undefined;
let isInvalid = false;
let error = null;
switch (type) {
case 'json':
newUnsavedValue = value.trim() || (isJsonArray ? '[]' : '{}');
try {
JSON.parse(newUnsavedValue);
} catch (e) {
isInvalid = true;
error = 'Invalid JSON syntax';
}
break;
default:
newUnsavedValue = value;
}
this.setState({
error,
isInvalid,
unsavedValue: newUnsavedValue,
});
}
onFieldChange = (e) => {
const value = e.target.value;
const { type } = this.props.setting;
const { unsavedValue } = this.state;
let newUnsavedValue = undefined;
switch (type) {
case 'boolean':
newUnsavedValue = !unsavedValue;
break;
case 'number':
newUnsavedValue = Number(value);
break;
default:
newUnsavedValue = value;
}
this.setState({
unsavedValue: newUnsavedValue,
});
}
onFieldKeyDown = ({ keyCode }) => {
if (keyCode === keyCodes.ENTER) {
this.saveEdit();
}
if (keyCode === keyCodes.ESCAPE) {
this.cancelEdit();
}
}
onFieldEscape = ({ keyCode }) => {
if (keyCode === keyCodes.ESCAPE) {
this.cancelEdit();
}
}
onImageChange = async (files) => {
if(!files.length) {
this.clearError();
this.setState({
unsavedValue: null,
});
return;
}
const file = files[0];
const { maxSize } = this.props.setting.options;
try {
const base64Image = await this.getImageAsBase64(file);
const isInvalid = !!(maxSize && maxSize.length && base64Image.length > maxSize.length);
this.setState({
isInvalid,
error: isInvalid ? `Image is too large, maximum size is ${maxSize.description}` : null,
changeImage: true,
unsavedValue: base64Image,
});
} catch(err) {
toastNotifications.addDanger('Image could not be saved');
this.cancelChangeImage();
}
}
getImageAsBase64(file) {
if(!file instanceof File) {
return null;
}
const reader = new FileReader();
reader.readAsDataURL(file);
return new Promise((resolve, reject) => {
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = (err) => {
reject(err);
};
});
}
changeImage = () => {
this.setState({
changeImage: true,
});
}
cancelChangeImage = () => {
const { savedValue } = this.state;
if(this.changeImageForm) {
this.changeImageForm.fileInput.value = null;
this.changeImageForm.handleChange();
}
this.setState({
changeImage: false,
unsavedValue: savedValue,
});
}
cancelEdit = () => {
const { savedValue } = this.state;
this.clearError();
this.setState({
unsavedValue: savedValue,
});
}
saveEdit = async () => {
const { name, defVal, type } = this.props.setting;
const { changeImage, savedValue, unsavedValue, isJsonArray } = this.state;
if(savedValue === unsavedValue) {
return;
}
let valueToSave = unsavedValue;
let isSameValue = false;
switch(type) {
case 'array':
valueToSave = valueToSave.split(',').map(val => val.trim());
isSameValue = valueToSave.join(',') === defVal.join(',');
break;
case 'json':
valueToSave = valueToSave.trim();
valueToSave = valueToSave || (isJsonArray ? '[]' : '{}');
default:
isSameValue = valueToSave === defVal;
}
this.setLoading(true);
try {
if (isSameValue) {
await this.props.clear(name);
} else {
await this.props.save(name, valueToSave);
}
if(changeImage) {
this.cancelChangeImage();
}
} catch(e) {
toastNotifications.addDanger(`Unable to save ${name}`);
}
this.setLoading(false);
}
resetField = async () => {
const { name } = this.props.setting;
this.setLoading(true);
try {
await this.props.clear(name);
this.cancelChangeImage();
this.clearError();
} catch(e) {
toastNotifications.addDanger(`Unable to reset ${name}`);
}
this.setLoading(false);
}
renderField(setting) {
const { loading, changeImage, unsavedValue } = this.state;
const { name, value, type, options } = setting;
switch(type) {
case 'boolean':
return (
<EuiSwitch
label={!!unsavedValue ? 'On' : 'Off'}
checked={!!unsavedValue}
onChange={this.onFieldChange}
disabled={loading}
onKeyDown={this.onFieldKeyDown}
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
case 'markdown':
case 'json':
return (
<div data-test-subj={`advancedSetting-editField-${name}`}>
<EuiCodeEditor
mode={type}
theme="textmate"
value={unsavedValue}
onChange={this.onCodeEditorChange}
width="100%"
height="auto"
minLines={6}
maxLines={30}
setOptions={{
showLineNumbers: false,
tabSize: 2,
}}
editorProps={{
$blockScrolling: Infinity
}}
/>
</div>
);
case 'image':
if(!isDefaultValue(setting) && !changeImage) {
return (
<EuiImage
allowFullScreen
url={value}
alt={name}
/>
);
} else {
return (
<EuiFilePicker
disabled={loading}
onChange={this.onImageChange}
accept=".jpg,.jpeg,.png"
ref={(input) => { this.changeImageForm = input; }}
onKeyDown={this.onFieldEscape}
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
}
case 'select':
return (
<EuiSelect
value={unsavedValue}
options={options.map((text) => {
return { text, value: text };
})}
onChange={this.onFieldChange}
isLoading={loading}
disabled={loading}
onKeyDown={this.onFieldKeyDown}
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
case 'number':
return (
<EuiFieldNumber
value={unsavedValue}
onChange={this.onFieldChange}
isLoading={loading}
disabled={loading}
onKeyDown={this.onFieldKeyDown}
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
default:
return (
<EuiFieldText
value={unsavedValue}
onChange={this.onFieldChange}
isLoading={loading}
disabled={loading}
onKeyDown={this.onFieldKeyDown}
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
}
}
renderLabel(setting) {
return(
<span aria-label={setting.ariaName}>
{setting.name}
</span>
);
}
renderHelpText(setting) {
const defaultLink = this.renderResetToDefaultLink(setting);
const imageLink = this.renderChangeImageLink(setting);
if(defaultLink || imageLink) {
return (
<span>
{defaultLink}
{imageLink}
</span>
);
}
return null;
}
renderTitle(setting) {
return (
<h3>
{setting.displayName || setting.name}
{setting.isCustom ?
<EuiIconTip type="asterisk" color="primary" aria-label="Custom setting" content="Custom setting" />
: ''}
</h3>
);
}
renderDescription(setting) {
return (
<Fragment>
<div
/*
* Justification for dangerouslySetInnerHTML:
* Setting description may contain formatting and links to documentation.
*/
dangerouslySetInnerHTML={{ __html: setting.description }} //eslint-disable-line react/no-danger
/>
{this.renderDefaultValue(setting)}
</Fragment>
);
}
renderDefaultValue(setting) {
const { type, defVal } = setting;
if(isDefaultValue(setting)) {
return;
}
return (
<Fragment>
<EuiSpacer size="s" />
<EuiText size="xs">
Default: <EuiCode>{this.getDisplayedDefaultValue(type, defVal)}</EuiCode>
</EuiText>
</Fragment>
);
}
renderResetToDefaultLink(setting) {
const { ariaName, name } = setting;
if(isDefaultValue(setting)) {
return;
}
return (
<span>
<EuiLink
aria-label={`Reset ${ariaName} to default`}
onClick={this.resetField}
data-test-subj={`advancedSetting-resetField-${name}`}
>
Reset to default
</EuiLink>
&nbsp;&nbsp;&nbsp;
</span>
);
}
renderChangeImageLink(setting) {
const { changeImage } = this.state;
const { type, value, ariaName, name } = setting;
if(type !== 'image' || !value || changeImage) {
return;
}
return (
<span>
<EuiLink
aria-label={`Change ${ariaName}`}
onClick={this.changeImage}
data-test-subj={`advancedSetting-changeImage-${name}`}
>
Change image
</EuiLink>
</span>
);
}
renderActions(setting) {
const { ariaName, name } = setting;
const { loading, isInvalid, changeImage, savedValue, unsavedValue } = this.state;
if(savedValue === unsavedValue && !changeImage) {
return;
}
return (
<EuiFormRow className="advancedSettings__field__actions" hasEmptyLabelSpace>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
fill
aria-label={`Save ${ariaName}`}
onClick={this.saveEdit}
disabled={loading || isInvalid}
data-test-subj={`advancedSetting-saveEditField-${name}`}
>
Save
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-label={`Cancel editing ${ariaName}`}
onClick={() => changeImage ? this.cancelChangeImage() : this.cancelEdit()}
disabled={loading}
data-test-subj={`advancedSetting-cancelEditField-${name}`}
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
}
render() {
const { setting } = this.props;
const { error, isInvalid } = this.state;
return (
<EuiFlexGroup className="advancedSettings__field">
<EuiFlexItem grow={false}>
<EuiDescribedFormGroup
className="advancedSettings__field__wrapper"
title={this.renderTitle(setting)}
description={this.renderDescription(setting)}
idAria={`${setting.name}-aria`}
>
<EuiFormRow
isInvalid={isInvalid}
error={error}
label={this.renderLabel(setting)}
helpText={this.renderHelpText(setting)}
describedByIds={[`${setting.name}-aria`]}
>
{this.renderField(setting)}
</EuiFormRow>
</EuiDescribedFormGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{this.renderActions(setting)}
</EuiFlexItem>
</EuiFlexGroup>
);
}
}

View file

@ -0,0 +1,312 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test';
import { Field } from './field';
jest.mock('ui/notify', () => ({
toastNotifications: {
addDanger: () => {}
}
}));
jest.mock('brace/theme/textmate', () => 'brace/theme/textmate');
jest.mock('brace/mode/markdown', () => 'brace/mode/markdown');
const settings = {
array: {
name: 'array:test:setting',
ariaName: 'array test setting',
displayName: 'Array test setting',
description: 'Description for Array test setting',
type: 'array',
value: undefined,
defVal: ['default_value'],
isCustom: false,
options: null,
},
boolean: {
name: 'boolean:test:setting',
ariaName: 'boolean test setting',
displayName: 'Boolean test setting',
description: 'Description for Boolean test setting',
type: 'boolean',
value: undefined,
defVal: true,
isCustom: false,
options: null,
},
image: {
name: 'image:test:setting',
ariaName: 'image test setting',
displayName: 'Image test setting',
description: 'Description for Image test setting',
type: 'image',
value: undefined,
defVal: null,
isCustom: false,
options: {
maxSize: {
length: 1000,
displayName: '1 kB',
description: 'Description for 1 kB',
}
},
},
json: {
name: 'json:test:setting',
ariaName: 'json test setting',
displayName: 'Json test setting',
description: 'Description for Json test setting',
type: 'json',
value: '{"foo": "bar"}',
defVal: '{}',
isCustom: false,
options: null,
},
markdown: {
name: 'markdown:test:setting',
ariaName: 'markdown test setting',
displayName: 'Markdown test setting',
description: 'Description for Markdown test setting',
type: 'markdown',
value: undefined,
defVal: '',
isCustom: false,
options: null,
},
number: {
name: 'number:test:setting',
ariaName: 'number test setting',
displayName: 'Number test setting',
description: 'Description for Number test setting',
type: 'number',
value: undefined,
defVal: 5,
isCustom: false,
options: null,
},
select: {
name: 'select:test:setting',
ariaName: 'select test setting',
displayName: 'Select test setting',
description: 'Description for Select test setting',
type: 'select',
value: undefined,
defVal: 'orange',
isCustom: false,
options: ['apple', 'orange', 'banana'],
},
string: {
name: 'string:test:setting',
ariaName: 'string test setting',
displayName: 'String test setting',
description: 'Description for String test setting',
type: 'string',
value: undefined,
defVal: null,
isCustom: false,
options: null,
},
};
const userValues = {
array: ['user', 'value'],
boolean: false,
image: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
json: '{"hello": "world"}',
markdown: '**bold**',
number: 10,
select: 'banana',
string: 'foo',
};
const save = jest.fn(() => Promise.resolve());
const clear = jest.fn(() => Promise.resolve());
describe('Field', () => {
Object.keys(settings).forEach(type => {
const setting = settings[type];
describe(`for ${type} setting`, () => {
it('should render default value if there is no user value set', async () => {
const component = shallow(
<Field
setting={setting}
save={save}
clear={clear}
/>
);
expect(component).toMatchSnapshot();
});
it('should render user value if there is user value is set', async () => {
const component = shallow(
<Field
setting={{
...setting,
value: userValues[type],
}}
save={save}
clear={clear}
/>
);
expect(component).toMatchSnapshot();
});
it('should render custom setting icon if it is custom', async () => {
const component = shallow(
<Field
setting={{
...setting,
isCustom: true,
}}
save={save}
clear={clear}
/>
);
expect(component).toMatchSnapshot();
});
});
if(type === 'image') {
describe(`for changing ${type} setting`, () => {
const component = mount(
<Field
setting={setting}
save={save}
clear={clear}
/>
);
const userValue = userValues[type];
component.instance().getImageAsBase64 = (file) => Promise.resolve(file);
it('should be able to change value from no value and cancel', async () => {
await component.instance().onImageChange([userValue]);
component.update();
findTestSubject(component, `advancedSetting-cancelEditField-${setting.name}`).simulate('click');
expect(component.instance().state.unsavedValue === component.instance().state.savedValue).toBe(true);
});
it('should be able to change value and save', async () => {
await component.instance().onImageChange([userValue]);
component.update();
findTestSubject(component, `advancedSetting-saveEditField-${setting.name}`).simulate('click');
expect(save).toBeCalled();
component.setState({ savedValue: userValue });
await component.setProps({ setting: {
...component.instance().props.setting,
value: userValue,
} });
component.update();
});
it('should be able to change value from existing value and save', async () => {
const newUserValue = `${userValue}=`;
findTestSubject(component, `advancedSetting-changeImage-${setting.name}`).simulate('click');
await component.instance().onImageChange([newUserValue]);
component.update();
findTestSubject(component, `advancedSetting-saveEditField-${setting.name}`).simulate('click');
expect(save).toBeCalled();
component.setState({ savedValue: newUserValue });
await component.setProps({ setting: {
...component.instance().props.setting,
value: newUserValue,
} });
component.update();
});
it('should be able to reset to default value', async () => {
findTestSubject(component, `advancedSetting-resetField-${setting.name}`).simulate('click');
expect(clear).toBeCalled();
});
});
} else if(type === 'markdown' || type === 'json') {
describe(`for changing ${type} setting`, () => {
const component = mount(
<Field
setting={setting}
save={save}
clear={clear}
/>
);
const userValue = userValues[type];
const fieldUserValue = userValue;
it('should be able to change value and cancel', async () => {
component.instance().onCodeEditorChange(fieldUserValue);
component.update();
findTestSubject(component, `advancedSetting-cancelEditField-${setting.name}`).simulate('click');
expect(component.instance().state.unsavedValue === component.instance().state.savedValue).toBe(true);
});
it('should be able to change value and save', async () => {
component.instance().onCodeEditorChange(fieldUserValue);
component.update();
findTestSubject(component, `advancedSetting-saveEditField-${setting.name}`).simulate('click');
expect(save).toBeCalled();
component.setState({ savedValue: fieldUserValue });
await component.setProps({ setting: {
...component.instance().props.setting,
value: userValue,
} });
component.update();
});
if(type === 'json') {
it('should be able to clear value and have empty object populate', async () => {
component.instance().onCodeEditorChange('');
component.update();
expect(component.instance().state.unsavedValue).toEqual('{}');
});
}
it('should be able to reset to default value', async () => {
findTestSubject(component, `advancedSetting-resetField-${setting.name}`).simulate('click');
expect(clear).toBeCalled();
});
});
} else {
describe(`for changing ${type} setting`, () => {
const component = mount(
<Field
setting={setting}
save={save}
clear={clear}
/>
);
const userValue = userValues[type];
const fieldUserValue = type === 'array' ? userValue.join(', ') : userValue;
it('should be able to change value and cancel', async () => {
component.instance().onFieldChange({ target: { value: fieldUserValue } });
component.update();
findTestSubject(component, `advancedSetting-cancelEditField-${setting.name}`).simulate('click');
expect(component.instance().state.unsavedValue === component.instance().state.savedValue).toBe(true);
});
it('should be able to change value and save', async () => {
component.instance().onFieldChange({ target: { value: fieldUserValue } });
component.update();
findTestSubject(component, `advancedSetting-saveEditField-${setting.name}`).simulate('click');
expect(save).toBeCalled();
component.setState({ savedValue: fieldUserValue });
await component.setProps({ setting: {
...component.instance().props.setting,
value: userValue,
} });
component.update();
});
it('should be able to reset to default value', async () => {
findTestSubject(component, `advancedSetting-resetField-${setting.name}`).simulate('click');
expect(clear).toBeCalled();
});
});
}
});
});

View file

@ -0,0 +1 @@
export { Field } from './field';

View file

@ -0,0 +1,227 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Form should render no settings message when there are no settings 1`] = `
<React.Fragment>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
No settings found
<EuiLink
color="primary"
onClick={[Function]}
type="button"
>
(Clear search)
</EuiLink>
</EuiPanel>
</React.Fragment>
`;
exports[`Form should render normally 1`] = `
<React.Fragment>
<React.Fragment
key="general"
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiForm>
<EuiText
grow={true}
>
<EuiFlexGroup
alignItems="baseline"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<h2>
General
</h2>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
<EuiSpacer
size="m"
/>
<Field
clear={[Function]}
key="general:test:date"
save={[Function]}
setting={
Object {
"ariaName": "general test date",
"category": Array [
"general",
],
"description": "bar",
"displayName": "Test date",
"name": "general:test:date",
}
}
/>
<Field
clear={[Function]}
key="setting:test"
save={[Function]}
setting={
Object {
"ariaName": "setting test",
"category": Array [
"general",
],
"description": "foo",
"displayName": "Test setting",
"name": "setting:test",
}
}
/>
</EuiForm>
</EuiPanel>
<EuiSpacer
size="l"
/>
</React.Fragment>
<React.Fragment
key="dashboard"
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiForm>
<EuiText
grow={true}
>
<EuiFlexGroup
alignItems="baseline"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<h2>
Dashboard
</h2>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
<EuiSpacer
size="m"
/>
<Field
clear={[Function]}
key="dashboard:test:setting"
save={[Function]}
setting={
Object {
"ariaName": "dashboard test setting",
"category": Array [
"dashboard",
],
"displayName": "Dashboard test setting",
"name": "dashboard:test:setting",
}
}
/>
</EuiForm>
</EuiPanel>
<EuiSpacer
size="l"
/>
</React.Fragment>
<React.Fragment
key="x-pack"
>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="l"
>
<EuiForm>
<EuiText
grow={true}
>
<EuiFlexGroup
alignItems="baseline"
component="div"
direction="row"
gutterSize="l"
justifyContent="flexStart"
responsive={true}
wrap={false}
>
<EuiFlexItem
component="div"
grow={false}
>
<h2>
X-pack
</h2>
</EuiFlexItem>
<EuiFlexItem
component="div"
grow={false}
>
<em>
Search terms are hiding
9
settings
<EuiLink
color="primary"
onClick={[Function]}
type="button"
>
<em>
(clear search)
</em>
</EuiLink>
</em>
</EuiFlexItem>
</EuiFlexGroup>
</EuiText>
<EuiSpacer
size="m"
/>
<Field
clear={[Function]}
key="xpack:test:setting"
save={[Function]}
setting={
Object {
"ariaName": "xpack test setting",
"category": Array [
"x-pack",
],
"description": "bar",
"displayName": "X-Pack test setting",
"name": "xpack:test:setting",
}
}
/>
</EuiForm>
</EuiPanel>
<EuiSpacer
size="l"
/>
</React.Fragment>
</React.Fragment>
`;

View file

@ -0,0 +1,105 @@
import React, { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import {
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiLink,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { getCategoryName } from '../../lib';
import { Field } from '../field';
export class Form extends PureComponent {
static propTypes = {
settings: PropTypes.object.isRequired,
categories: PropTypes.array.isRequired,
categoryCounts: PropTypes.object.isRequired,
clearQuery: PropTypes.func.isRequired,
save: PropTypes.func.isRequired,
clear: PropTypes.func.isRequired,
}
renderClearQueryLink(totalSettings, currentSettings) {
const { clearQuery } = this.props;
if(totalSettings !== currentSettings) {
return (
<EuiFlexItem grow={false}>
<em>
Search terms are hiding {totalSettings - currentSettings} settings {(
<EuiLink onClick={clearQuery}>
<em>(clear search)</em>
</EuiLink>
)}
</em>
</EuiFlexItem>
);
}
return null;
}
renderCategory(category, settings, totalSettings) {
return (
<Fragment key={category}>
<EuiPanel paddingSize="l">
<EuiForm>
<EuiText>
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem grow={false}>
<h2>{getCategoryName(category)}</h2>
</EuiFlexItem>
{this.renderClearQueryLink(totalSettings, settings.length)}
</EuiFlexGroup>
</EuiText>
<EuiSpacer size="m" />
{settings.map(setting => {
return (
<Field
key={setting.name}
setting={setting}
save={this.props.save}
clear={this.props.clear}
/>
);
})}
</EuiForm>
</EuiPanel>
<EuiSpacer size="l" />
</Fragment>
);
}
render() {
const { settings, categories, categoryCounts, clearQuery } = this.props;
const currentCategories = [];
categories.forEach(category => {
if(settings[category] && settings[category].length) {
currentCategories.push(category);
}
});
return (
<Fragment>
{
currentCategories.length ? currentCategories.map((category) => {
return (
this.renderCategory(category, settings[category], categoryCounts[category]) // fix this
);
}) : (
<EuiPanel paddingSize="l">
No settings found <EuiLink onClick={clearQuery}>(Clear search)</EuiLink>
</EuiPanel>
)
}
</Fragment>
);
}
}

View file

@ -0,0 +1,87 @@
import React from 'react';
import { shallow } from 'enzyme';
import { Form } from './form';
jest.mock('../field', () => ({
Field: () => {
return 'field';
}
}));
const settings = {
'dashboard': [
{
name: 'dashboard:test:setting',
ariaName: 'dashboard test setting',
displayName: 'Dashboard test setting',
category: ['dashboard'],
},
],
'general': [
{
name: 'general:test:date',
ariaName: 'general test date',
displayName: 'Test date',
description: 'bar',
category: ['general'],
},
{
name: 'setting:test',
ariaName: 'setting test',
displayName: 'Test setting',
description: 'foo',
category: ['general'],
},
],
'x-pack': [
{
name: 'xpack:test:setting',
ariaName: 'xpack test setting',
displayName: 'X-Pack test setting',
category: ['x-pack'],
description: 'bar',
},
],
};
const categories = ['general', 'dashboard', 'hiddenCategory', 'x-pack'];
const categoryCounts = {
general: 2,
dashboard: 1,
'x-pack': 10,
};
const save = () => {};
const clear = () => {};
const clearQuery = () => {};
describe('Form', () => {
it('should render normally', async () => {
const component = shallow(
<Form
settings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clear={clear}
clearQuery={clearQuery}
/>
);
expect(component).toMatchSnapshot();
});
it('should render no settings message when there are no settings', async () => {
const component = shallow(
<Form
settings={{}}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clear={clear}
clearQuery={clearQuery}
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -0,0 +1 @@
export { Form } from './form';

View file

@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Search should render normally 1`] = `
<EuiSearchBar
box={
Object {
"incremental": true,
}
}
filters={
Array [
Object {
"field": "category",
"multiSelect": "or",
"name": "Category",
"options": Array [
Object {
"name": "General",
"value": "general",
},
Object {
"name": "Dashboard",
"value": "dashboard",
},
Object {
"name": "HiddenCategory",
"value": "hiddenCategory",
},
Object {
"name": "X-pack",
"value": "x-pack",
},
],
"type": "field_value_selection",
},
]
}
onChange={[Function]}
query={
Query {
"ast": _AST {
"_clauses": Array [],
"_indexedClauses": Object {
"field": Object {},
"is": Object {},
"term": Array [],
},
},
"syntax": Object {
"parse": [Function],
"print": [Function],
},
"text": "",
}
}
/>
`;

View file

@ -0,0 +1 @@
export { Search } from './search';

View file

@ -0,0 +1,55 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import {
EuiSearchBar,
} from '@elastic/eui';
import { getCategoryName } from '../../lib';
export class Search extends PureComponent {
static propTypes = {
categories: PropTypes.array.isRequired,
query: PropTypes.object.isRequired,
onQueryChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
const { categories } = props;
this.categories = categories.map(category => {
return {
value: category,
name: getCategoryName(category),
};
});
}
render() {
const { query, onQueryChange } = this.props;
const box = {
incremental: true,
};
const filters = [
{
type: 'field_value_selection',
field: 'category',
name: 'Category',
multiSelect: 'or',
options: this.categories,
}
];
return (
<EuiSearchBar
box={box}
filters={filters}
onChange={onQueryChange}
query={query}
/>
);
}
}

View file

@ -0,0 +1,37 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import { Query } from '@elastic/eui';
import { Search } from './search';
const query = Query.parse('');
const categories = ['general', 'dashboard', 'hiddenCategory', 'x-pack'];
describe('Search', () => {
it('should render normally', async () => {
const onQueryChange = () => {};
const component = shallow(
<Search
query={query}
categories={categories}
onQueryChange={onQueryChange}
/>
);
expect(component).toMatchSnapshot();
});
it('should call parent function when query is changed', async () => {
const onQueryChange = jest.fn();
const component = mount(
<Search
query={query}
categories={categories}
onQueryChange={onQueryChange}
/>
);
component.find('input').simulate('keyUp');
component.find('EuiSearchFilters').prop('onChange')(query);
expect(onQueryChange).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,77 +1,5 @@
<kbn-management-app section="kibana">
<kbn-management-advanced class="kuiViewContent kuiViewContent--constrainedWidth kuiViewContentItem">
<!-- Warning -->
<div class="kuiInfoPanel kuiInfoPanel--warning kuiVerticalRhythm">
<div class="kuiInfoPanelHeader">
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--warning fa-bolt"></span>
<span class="kuiInfoPanelHeader__title" id="kbnManagementAdvancedWarning">
Caution: You can break stuff here
</span>
</div>
<div class="kuiInfoPanelBody">
<div class="kuiInfoPanelBody__message">
Be careful in here, these settings are for very advanced users only.
Tweaks you make here can break large portions of Kibana. Some of these
settings may be undocumented, unsupported or experimental. If a field has a default value,
blanking the field will reset it to its default which
may be unacceptable given other configuration directives. Deleting a
custom setting will permanently remove it from Kibana's config.
</div>
</div>
</div>
<!-- Search -->
<form
role="form"
class="kuiVerticalRhythm"
>
<div class="kuiSearchInput fullWidth">
<div class="kuiSearchInput__icon kuiIcon fa-search"></div>
<input
class="kuiSearchInput__input"
aria-label="Search"
ng-model="advancedFilter"
type="text"
placeholder="Search..."
aria-describedby="kbnManagementAdvancedWarning"
>
</div>
</form>
<!-- Settings table -->
<table class="kuiTable kuiVerticalRhythm">
<thead>
<tr>
<th scope="col" class="kuiTableHeaderCell">
<div class="kuiTableRowCell__liner">
Name
</div>
</th>
<th scope="col" class="kuiTableHeaderCell">
<div class="kuiTableRowCell__liner">
Value
</div>
</th>
<th
scope="col"
class="kuiTableHeaderCell kuiTableHeaderCell--alignRight"
style="width: 180px"
>
<div class="kuiTableRowCell__liner">
<!-- Actions -->
</div>
</th>
</tr>
</thead>
<tbody>
<tr
ng-repeat="conf in configs | filter:advancedFilter"
advanced-row="conf"
configs="configs"
></tr>
</tbody>
</table>
<kbn-management-advanced>
<div id="reactAdvancedSettings"></div>
</kbn-management-advanced>
</kbn-management-app>

View file

@ -1,41 +1,56 @@
import _ from 'lodash';
import { toEditableConfig } from './lib/to_editable_config';
import './advanced_row';
import { management } from 'ui/management';
import uiRoutes from 'ui/routes';
import { uiModules } from 'ui/modules';
import indexTemplate from './index.html';
import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory } from 'ui/registry/feature_catalogue';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { AdvancedSettings } from './advanced_settings';
const REACT_ADVANCED_SETTINGS_DOM_ELEMENT_ID = 'reactAdvancedSettings';
function updateAdvancedSettings($scope, config, query) {
$scope.$$postDigest(() => {
const node = document.getElementById(REACT_ADVANCED_SETTINGS_DOM_ELEMENT_ID);
if (!node) {
return;
}
render(
<AdvancedSettings
config={config}
query={query}
/>,
node,
);
});
}
function destroyAdvancedSettings() {
const node = document.getElementById(REACT_ADVANCED_SETTINGS_DOM_ELEMENT_ID);
node && unmountComponentAtNode(node);
}
uiRoutes
.when('/management/kibana/settings', {
.when('/management/kibana/settings/:setting?', {
template: indexTemplate
});
uiModules.get('apps/management')
.directive('kbnManagementAdvanced', function (config) {
.directive('kbnManagementAdvanced', function (config, $route) {
return {
restrict: 'E',
link: function ($scope) {
// react to changes of the config values
config.watchAll(changed, $scope);
config.watchAll(() => {
updateAdvancedSettings($scope, config, $route.current.params.setting || '');
}, $scope);
// initial config setup
changed();
$scope.$on('$destory', () => {
destroyAdvancedSettings();
});
function changed() {
const all = config.getAll();
const editable = _(all)
.map((def, name) => toEditableConfig({
def,
name,
value: def.userValue,
isCustom: config.isCustom(name)
}))
.value();
const writable = _.reject(editable, 'readonly');
$scope.configs = writable;
}
$route.updateParams({ setting: null });
}
};
});

View file

@ -0,0 +1,12 @@
import expect from 'expect.js';
import { DEFAULT_CATEGORY } from '../default_category';
describe('Settings', function () {
describe('Advanced', function () {
describe('DEFAULT_CATEGORY', function () {
it('should be general', function () {
expect(DEFAULT_CATEGORY).to.be('general');
});
});
});
});

View file

@ -1,6 +1,5 @@
import { getAriaName } from '../get_aria_name';
import expect from 'expect.js';
import { getAriaName } from '../get_aria_name';
describe('Settings', function () {
describe('Advanced', function () {

View file

@ -0,0 +1,33 @@
import expect from 'expect.js';
import { getCategoryName } from '../get_category_name';
describe('Settings', function () {
describe('Advanced', function () {
describe('getCategoryName(category)', function () {
it('should be a function', function () {
expect(getCategoryName).to.be.a(Function);
});
it('should return correct name for known categories', function () {
expect(getCategoryName('general')).to.be('General');
expect(getCategoryName('timelion')).to.be('Timelion');
expect(getCategoryName('notifications')).to.be('Notifications');
expect(getCategoryName('visualizations')).to.be('Visualizations');
expect(getCategoryName('discover')).to.be('Discover');
expect(getCategoryName('dashboard')).to.be('Dashboard');
expect(getCategoryName('reporting')).to.be('Reporting');
expect(getCategoryName('search')).to.be('Search');
});
it('should capitalize unknown category', function () {
expect(getCategoryName('elasticsearch')).to.be('Elasticsearch');
});
it('should return empty string for no category', function () {
expect(getCategoryName()).to.be('');
expect(getCategoryName('')).to.be('');
expect(getCategoryName(false)).to.be('');
});
});
});
});

View file

@ -1,27 +0,0 @@
import { getEditorType } from '../get_editor_type';
import expect from 'expect.js';
describe('Settings', function () {
describe('Advanced', function () {
describe('getEditorType(conf)', function () {
describe('when given type has a named editor', function () {
it('returns that named editor', function () {
expect(getEditorType({ type: 'json' })).to.equal('json');
expect(getEditorType({ type: 'array' })).to.equal('array');
expect(getEditorType({ type: 'boolean' })).to.equal('boolean');
expect(getEditorType({ type: 'select' })).to.equal('select');
});
});
describe('when given a type of number, string, null, or undefined', function () {
it('returns "normal"', function () {
expect(getEditorType({ type: 'number' })).to.equal('normal');
expect(getEditorType({ type: 'string' })).to.equal('normal');
expect(getEditorType({ type: 'null' })).to.equal('normal');
expect(getEditorType({ type: 'undefined' })).to.equal('normal');
});
});
});
});
});

View file

@ -1,6 +1,5 @@
import { getValType } from '../get_val_type';
import expect from 'expect.js';
import { getValType } from '../get_val_type';
describe('Settings', function () {
describe('Advanced', function () {

View file

@ -0,0 +1,55 @@
import expect from 'expect.js';
import { isDefaultValue } from '../is_default_value';
describe('Settings', function () {
describe('Advanced', function () {
describe('getCategoryName(category)', function () {
it('should be a function', function () {
expect(isDefaultValue).to.be.a(Function);
});
describe('when given a setting definition object', function () {
const setting = {
isCustom: false,
value: 'value',
defVal: 'defaultValue',
};
describe('that is custom', function () {
it('should return true', function () {
expect(isDefaultValue({ ...setting, isCustom: true })).to.be(true);
});
});
describe('without a value', function () {
it('should return true', function () {
expect(isDefaultValue({ ...setting, value: undefined })).to.be(true);
expect(isDefaultValue({ ...setting, value: '' })).to.be(true);
});
});
describe('with a value that is the same as the default value', function () {
it('should return true', function () {
expect(isDefaultValue({ ...setting, value: 'defaultValue' })).to.be(true);
expect(isDefaultValue({ ...setting, value: [], defVal: [] })).to.be(true);
expect(isDefaultValue({ ...setting, value: '{"foo":"bar"}', defVal: '{"foo":"bar"}' })).to.be(true);
expect(isDefaultValue({ ...setting, value: 123, defVal: 123 })).to.be(true);
expect(isDefaultValue({ ...setting, value: 456, defVal: '456' })).to.be(true);
expect(isDefaultValue({ ...setting, value: false, defVal: false })).to.be(true);
});
});
describe('with a value that is different than the default value', function () {
it('should return false', function () {
expect(isDefaultValue({ ...setting })).to.be(false);
expect(isDefaultValue({ ...setting, value: [1], defVal: [2] })).to.be(false);
expect(isDefaultValue({ ...setting, value: '{"foo":"bar"}', defVal: '{"foo2":"bar2"}' })).to.be(false);
expect(isDefaultValue({ ...setting, value: 123, defVal: 1234 })).to.be(false);
expect(isDefaultValue({ ...setting, value: 456, defVal: '4567' })).to.be(false);
expect(isDefaultValue({ ...setting, value: true, defVal: false })).to.be(false);
});
});
});
});
});
});

View file

@ -1,6 +1,5 @@
import { toEditableConfig } from '../to_editable_config';
import expect from 'expect.js';
import { toEditableConfig } from '../to_editable_config';
describe('Settings', function () {
describe('Advanced', function () {

View file

@ -0,0 +1 @@
export const DEFAULT_CATEGORY = 'general';

View file

@ -0,0 +1,16 @@
import { StringUtils } from 'ui/utils/string_utils';
const names = {
'general': 'General',
'timelion': 'Timelion',
'notifications': 'Notifications',
'visualizations': 'Visualizations',
'discover': 'Discover',
'dashboard': 'Dashboard',
'reporting': 'Reporting',
'search': 'Search',
};
export function getCategoryName(category) {
return category ? names[category] || StringUtils.upperFirst(category) : '';
}

View file

@ -1,13 +0,0 @@
import _ from 'lodash';
const NAMED_EDITORS = ['json', 'array', 'boolean', 'select', 'markdown', 'image'];
const NORMAL_EDITOR = ['number', 'string', 'null', 'undefined'];
/**
* @param {object} advanced setting configuration object
* @returns {string} the editor type to use when editing value
*/
export function getEditorType(conf) {
if (_.contains(NAMED_EDITORS, conf.type)) return conf.type;
if (_.contains(NORMAL_EDITOR, conf.type)) return 'normal';
}

View file

@ -0,0 +1,5 @@
export { isDefaultValue } from './is_default_value';
export { toEditableConfig } from './to_editable_config';
export { getCategoryName } from './get_category_name';
export { DEFAULT_CATEGORY } from './default_category';
export { getAriaName } from './get_aria_name';

View file

@ -0,0 +1,3 @@
export function isDefaultValue(setting) {
return (setting.isCustom || setting.value === undefined || setting.value === '' || String(setting.value) === String(setting.defVal));
}

View file

@ -1,6 +1,6 @@
import { getValType } from './get_val_type';
import { getEditorType } from './get_editor_type';
import { getAriaName } from './get_aria_name';
import { DEFAULT_CATEGORY } from './default_category';
/**
* @param {object} advanced setting definition object
@ -14,26 +14,18 @@ export function toEditableConfig({ def, name, value, isCustom }) {
}
const conf = {
name,
displayName: def.name || name,
ariaName: getAriaName(name),
value,
category: def.category && def.category.length ? def.category : [DEFAULT_CATEGORY],
isCustom,
readonly: !!def.readonly,
defVal: def.value,
type: getValType(def, value),
description: def.description,
options: def.options
options: def.options,
};
const editor = getEditorType(conf);
conf.json = editor === 'json';
conf.select = editor === 'select';
conf.bool = editor === 'boolean';
conf.array = editor === 'array';
conf.markdown = editor === 'markdown';
conf.image = editor === 'image';
conf.normal = editor === 'normal';
conf.tooComplex = !editor;
return conf;
}

View file

@ -14,37 +14,46 @@ export function getUiSettingDefaults() {
readonly: true
},
'query:queryString:options': {
name: 'Query string options',
value: '{ "analyze_wildcard": true, "default_field": "*" }',
description: '<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html" target="_blank" rel="noopener noreferrer">Options</a> for the lucene query string parser',
type: 'json'
},
'query:allowLeadingWildcards': {
name: 'Allow leading wildcards in query',
value: true,
description: `When set, * is allowed as the first character in a query clause. Currently only applies when experimental query
features are enabled in the query bar. To disallow leading wildcards in basic lucene queries, use query:queryString:options`,
},
'search:queryLanguage': {
name: 'Query language',
value: 'lucene',
description: 'Query language used by the query bar. Kuery is an experimental new language built specifically for Kibana.',
description: `Query language used by the query bar. Kuery is an experimental new language built specifically for Kibana.`,
type: 'select',
options: ['lucene', 'kuery']
},
'sort:options': {
name: 'Sort options',
value: '{ "unmapped_type": "boolean" }',
description: '<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html" target="_blank" rel="noopener noreferrer">Options</a> for the Elasticsearch sort parameter',
description: `<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html"
target="_blank" rel="noopener noreferrer">Options</a> for the Elasticsearch sort parameter`,
type: 'json'
},
'dateFormat': {
name: 'Date format',
value: 'MMMM Do YYYY, HH:mm:ss.SSS',
description: 'When displaying a pretty formatted date, use this <a href="http://momentjs.com/docs/#/displaying/format/" target="_blank" rel="noopener noreferrer">format</a>',
description: `When displaying a pretty formatted date, use this <a href="http://momentjs.com/docs/#/displaying/format/"
target="_blank" rel="noopener noreferrer">format</a>`,
},
'dateFormat:tz': {
name: 'Timezone for date formatting',
value: 'Browser',
description: 'Which timezone should be used. "Browser" will use the timezone detected by your browser.',
description: `Which timezone should be used. "Browser" will use the timezone detected by your browser.`,
type: 'select',
options: ['Browser', ...moment.tz.names()]
},
'dateFormat:scaled': {
name: 'Scaled date format',
type: 'json',
value:
`[
@ -56,112 +65,139 @@ export function getUiSettingDefaults() {
["P1YT", "YYYY"]
]`,
description: (
'Values that define the format used in situations where timebased' +
' data is rendered in order, and formatted timestamps should adapt to the' +
' interval between measurements. Keys are' +
' <a href="http://en.wikipedia.org/wiki/ISO_8601#Time_intervals" target="_blank" rel="noopener noreferrer">' +
'ISO8601 intervals.</a>'
`Values that define the format used in situations where time-based
data is rendered in order, and formatted timestamps should adapt to the
interval between measurements. Keys are
<a href="http://en.wikipedia.org/wiki/ISO_8601#Time_intervals" target="_blank" rel="noopener noreferrer">
ISO8601 intervals.</a>`
)
},
'dateFormat:dow': {
name: 'Day of week',
value: defaultWeekday,
description: 'What day should weeks start on?',
description: `What day should weeks start on?`,
type: 'select',
options: weekdays
},
'defaultIndex': {
name: 'Default index',
value: null,
description: 'The index to access if no index is set',
description: `The index to access if no index is set`,
},
'defaultColumns': {
name: 'Default columns',
value: ['_source'],
description: 'Columns displayed by default in the Discovery tab',
description: `Columns displayed by default in the Discovery tab`,
category: ['discover'],
},
'metaFields': {
name: 'Meta fields',
value: ['_source', '_id', '_type', '_index', '_score'],
description: 'Fields that exist outside of _source to merge into our document when displaying it',
description: `Fields that exist outside of _source to merge into our document when displaying it`,
},
'discover:sampleSize': {
name: 'Number of rows',
value: 500,
description: 'The number of rows to show in the table',
description: `The number of rows to show in the table`,
category: ['discover'],
},
'discover:aggs:terms:size': {
name: 'Number of terms',
value: 20,
type: 'number',
description: 'Determines how many terms will be visualized when clicking the "visualize" ' +
'button, in the field drop downs, in the discover sidebar.'
description: `Determines how many terms will be visualized when clicking the "visualize"
button, in the field drop downs, in the discover sidebar.`,
category: ['discover'],
},
'discover:sort:defaultOrder': {
name: 'Default sort direction',
value: 'desc',
options: ['desc', 'asc'],
type: 'select',
description: 'Controls the default sort direction for time based index patterns in the Discover app.',
description: `Controls the default sort direction for time based index patterns in the Discover app.`,
category: ['discover'],
},
'doc_table:highlight': {
name: 'Highlight results',
value: true,
description: 'Highlight results in Discover and Saved Searches Dashboard.' +
'Highlighting makes requests slow when working on big documents.',
description: `Highlight results in Discover and Saved Searches Dashboard.
Highlighting makes requests slow when working on big documents.`,
category: ['discover'],
},
'courier:maxSegmentCount': {
name: 'Maximum segment count',
value: 30,
description: 'Requests in discover are split into segments to prevent massive requests from being sent to ' +
'elasticsearch. This setting attempts to prevent the list of segments from getting too long, which might ' +
'cause requests to take much longer to process'
description: `Requests in discover are split into segments to prevent massive requests from being sent to
elasticsearch. This setting attempts to prevent the list of segments from getting too long, which might
cause requests to take much longer to process.`,
category: ['search'],
},
'courier:ignoreFilterIfFieldNotInIndex': {
name: 'Ignore filter(s)',
value: false,
description: 'This configuration enhances support for dashboards containing visualizations accessing dissimilar indexes. ' +
'When set to false, all filters are applied to all visualizations. ' +
'When set to true, filter(s) will be ignored for a visualization ' +
'when the visualization\'s index does not contain the filtering field.'
description: `This configuration enhances support for dashboards containing visualizations accessing dissimilar indexes.
When set to false, all filters are applied to all visualizations.
When set to true, filter(s) will be ignored for a visualization
when the visualization's index does not contain the filtering field.`,
category: ['search'],
},
'courier:setRequestPreference': {
name: 'Request preference',
value: 'sessionId',
options: ['sessionId', 'custom', 'none'],
type: 'select',
description: 'Allows you to set which shards handle your search requests. ' +
'<ul>' +
'<li><strong>sessionId:</strong> restricts operations to execute all search requests on the same shards. ' +
'This has the benefit of reusing shard caches across requests. ' +
'<li><strong>custom:</strong> allows you to define a your own preference. ' +
'Use <strong>courier:customRequestPreference</strong> to customize your preference value. ' +
'<li><strong>none:</strong> means do not set a preference. ' +
'This might provide better performance because requests can be spread across all shard copies. ' +
'However, results might be inconsistent because different shards might be in different refresh states.' +
'</ul>'
description: `Allows you to set which shards handle your search requests.
<ul>
<li><strong>sessionId:</strong> restricts operations to execute all search requests on the same shards.
This has the benefit of reusing shard caches across requests.</li>
<li><strong>custom:</strong> allows you to define a your own preference.
Use <strong>courier:customRequestPreference</strong> to customize your preference value.</li>
<li><strong>none:</strong> means do not set a preference.
This might provide better performance because requests can be spread across all shard copies.
However, results might be inconsistent because different shards might be in different refresh states.</li>
</ul>`,
category: ['search'],
},
'courier:customRequestPreference': {
name: 'Custom request preference',
value: '_local',
type: 'string',
description: '<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-preference.html" target="_blank" rel="noopener noreferrer">Request Preference</a> ' +
' used when <strong>courier:setRequestPreference</strong> is set to "custom".'
description: `<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-preference.html"
target="_blank" rel="noopener noreferrer">Request Preference</a>
used when <strong>courier:setRequestPreference</strong> is set to "custom".`,
category: ['search'],
},
'fields:popularLimit': {
name: 'Popular fields limit',
value: 10,
description: 'The top N most popular fields to show',
description: `The top N most popular fields to show`,
},
'histogram:barTarget': {
name: 'Target bars',
value: 50,
description: 'Attempt to generate around this many bars when using "auto" interval in date histograms',
description: `Attempt to generate around this many bars when using "auto" interval in date histograms`,
},
'histogram:maxBars': {
name: 'Maximum bars',
value: 100,
description: 'Never show more than this many bars in date histograms, scale values if needed',
description: `Never show more than this many bars in date histograms, scale values if needed`,
},
'visualize:enableLabs': {
name: 'Enable labs',
value: true,
description: 'Enable lab visualizations in Visualize.'
description: `Enable lab visualizations in Visualize.`,
category: ['visualization'],
},
'visualization:tileMap:maxPrecision': {
name: 'Maximum tile map precision',
value: 7,
description: 'The maximum geoHash precision displayed on tile maps: 7 is high, 10 is very high, ' +
'12 is the max. ' +
'<a href="http://www.elastic.co/guide/en/elasticsearch/reference/current/' +
'search-aggregations-bucket-geohashgrid-aggregation.html#_cell_dimensions_at_the_equator" ' +
'target="_blank" rel="noopener noreferrer">' +
'Explanation of cell dimensions</a>',
description: `The maximum geoHash precision displayed on tile maps: 7 is high, 10 is very high, 12 is the max.
<a href="http://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html#_cell_dimensions_at_the_equator"
target="_blank" rel="noopener noreferrer">Explanation of cell dimensions</a>`,
category: ['visualization'],
},
'visualization:tileMap:WMSdefaults': {
name: 'Default WMS properties',
value: JSON.stringify({
enabled: false,
url: undefined,
@ -175,62 +211,79 @@ export function getUiSettingDefaults() {
}
}, null, 2),
type: 'json',
description: 'Default <a href="http://leafletjs.com/reference.html#tilelayer-wms" target="_blank" rel="noopener noreferrer">properties</a> for the WMS map server support in the coordinate map'
description: `Default <a href="http://leafletjs.com/reference.html#tilelayer-wms"
target="_blank" rel="noopener noreferrer">properties</a> for the WMS map server support in the coordinate map`,
category: ['visualization'],
},
'visualization:regionmap:showWarnings': {
name: 'Show region map warning',
value: true,
description: 'Whether the region map show a warning when terms cannot be joined to a shape on the map.'
description: `Whether the region map shows a warning when terms cannot be joined to a shape on the map.`,
category: ['visualization'],
},
'visualization:colorMapping': {
type: 'json',
name: 'Color mapping',
value: JSON.stringify({
Count: '#00A69B'
}),
description: 'Maps values to specified colors within visualizations'
type: 'json',
description: `Maps values to specified colors within visualizations`,
category: ['visualization'],
},
'visualization:loadingDelay': {
name: 'Loading delay',
value: '2s',
description: 'Time to wait before dimming visualizations during query'
description: `Time to wait before dimming visualizations during query`,
category: ['visualization'],
},
'visualization:dimmingOpacity': {
type: 'number',
name: 'Dimming opacity',
value: 0.5,
description: 'The opacity of the chart items that are dimmed when highlighting another element of the chart. ' +
'The lower this number, the more the highlighted element will stand out.' +
'This must be a number between 0 and 1.'
type: 'number',
description: `The opacity of the chart items that are dimmed when highlighting another element of the chart.
The lower this number, the more the highlighted element will stand out.
This must be a number between 0 and 1.`,
category: ['visualization'],
},
'csv:separator': {
name: 'CSV separator',
value: ',',
description: 'Separate exported values with this string',
description: `Separate exported values with this string`,
},
'csv:quoteValues': {
name: 'Quote CSV values',
value: true,
description: 'Should values be quoted in csv exports?',
description: `Should values be quoted in csv exports?`,
},
'history:limit': {
name: 'History limit',
value: 10,
description: 'In fields that have history (e.g. query inputs), show this many recent values',
description: `In fields that have history (e.g. query inputs), show this many recent values`,
},
'shortDots:enable': {
name: 'Shorten fields',
value: false,
description: 'Shorten long fields, for example, instead of foo.bar.baz, show f.b.baz',
description: `Shorten long fields, for example, instead of foo.bar.baz, show f.b.baz`,
},
'truncate:maxHeight': {
name: 'Maximum table cell height',
value: 115,
description: 'The maximum height that a cell in a table should occupy. Set to 0 to disable truncation'
description: `The maximum height that a cell in a table should occupy. Set to 0 to disable truncation`,
},
'indexPattern:fieldMapping:lookBack': {
name: 'Recent matching patterns',
value: 5,
description: 'For index patterns containing timestamps in their names, look for this many recent matching ' +
'patterns from which to query the field mapping'
description: `For index patterns containing timestamps in their names, look for this many recent matching
patterns from which to query the field mapping`
},
'indexPatterns:warnAboutUnsupportedTimePatterns': {
name: 'Time pattern warning',
value: false,
description: 'When an index pattern is using the now unsupported "time pattern" format, a warning will ' +
'be displayed once per session that is using this pattern. Set this to false to disable that warning.'
description: `When an index pattern is using the now unsupported "time pattern" format, a warning will
be displayed once per session that is using this pattern. Set this to false to disable that warning.`
},
'format:defaultTypeMap': {
type: 'json',
name: 'Field type format name',
value:
`{
"ip": { "id": "ip", "params": {} },
@ -240,67 +293,77 @@ export function getUiSettingDefaults() {
"_source": { "id": "_source", "params": {} },
"_default_": { "id": "string", "params": {} }
}`,
description: 'Map of the format name to use by default for each field type. ' +
'"_default_" is used if the field type is not mentioned explicitly'
type: 'json',
description: `Map of the format name to use by default for each field type.
"_default_" is used if the field type is not mentioned explicitly`
},
'format:number:defaultPattern': {
type: 'string',
name: 'Number format',
value: '0,0.[000]',
description: 'Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "number" format'
type: 'string',
description: `Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "number" format`
},
'format:bytes:defaultPattern': {
type: 'string',
name: 'Bytes format',
value: '0,0.[000]b',
description: 'Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "bytes" format'
type: 'string',
description: `Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "bytes" format`
},
'format:percent:defaultPattern': {
type: 'string',
name: 'Percent format',
value: '0,0.[000]%',
description: 'Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "percent" format'
type: 'string',
description: `Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "percent" format`
},
'format:currency:defaultPattern': {
type: 'string',
name: 'Currency format',
value: '($0,0.[00])',
description: 'Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "currency" format'
type: 'string',
description: `Default <a href="http://numeraljs.com/" target="_blank" rel="noopener noreferrer">numeral format</a> for the "currency" format`
},
'format:number:defaultLocale': {
name: 'Formatting locale',
value: 'en',
type: 'select',
options: numeralLanguageIds,
description: '<a href="http://numeraljs.com/" target="_blank" rel="noopener">numeral language</a>'
description: `<a href="http://numeraljs.com/" target="_blank" rel="noopener">numeral language</a>`
},
'savedObjects:perPage': {
type: 'number',
name: 'Objects per page',
value: 5,
description: 'Number of objects to show per page in the load dialog'
type: 'number',
description: `Number of objects to show per page in the load dialog`
},
'savedObjects:listingLimit': {
name: 'Objects listing limit',
type: 'number',
value: 1000,
description: 'Number of objects to fetch for the listing pages'
description: `Number of objects to fetch for the listing pages`
},
'timepicker:timeDefaults': {
type: 'json',
name: 'Time picker defaults',
value:
`{
"from": "now-15m",
"to": "now",
"mode": "quick"
}`,
description: 'The timefilter selection to use when Kibana is started without one'
type: 'json',
description: `The timefilter selection to use when Kibana is started without one`
},
'timepicker:refreshIntervalDefaults': {
type: 'json',
name: 'Time picker refresh interval',
value:
`{
"display": "Off",
"pause": false,
"value": 0
}`,
description: 'The timefilter\'s default refresh interval'
type: 'json',
description: `The timefilter's default refresh interval`
},
'timepicker:quickRanges': {
type: 'json',
name: 'Time picker quick ranges',
value: JSON.stringify([
{ from: 'now/d', to: 'now/d', display: 'Today', section: 0 },
{ from: 'now/w', to: 'now/w', display: 'This week', section: 0 },
@ -328,79 +391,106 @@ export function getUiSettingDefaults() {
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 2 },
], null, 2),
description: 'The list of ranges to show in the Quick section of the time picker. ' +
'This should be an array of objects, with each object containing "from", "to" (see ' +
'<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math" target="_blank" rel="noopener noreferrer">accepted formats</a>' +
'), "display" (the title to be displayed), and "section" (which column to put the option in).'
type: 'json',
description: `The list of ranges to show in the Quick section of the time picker.
This should be an array of objects, with each object containing "from", "to" (see
<a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math"
target="_blank" rel="noopener noreferrer">accepted formats</a>),
"display" (the title to be displayed), and "section" (which column to put the option in).`
},
'dashboard:defaultDarkTheme': {
name: 'Dark theme',
value: false,
description: 'New dashboards use dark theme by default'
description: `New dashboards use dark theme by default`,
category: ['dashboard'],
},
'filters:pinnedByDefault': {
name: 'Pin filters by default',
value: false,
description: 'Whether the filters should have a global state (be pinned) by default'
description: `Whether the filters should have a global state (be pinned) by default`
},
'filterEditor:suggestValues': {
name: 'Filter editor suggest values',
value: true,
description: 'Set this property to false to prevent the filter editor from suggesting values for fields.'
description: `Set this property to false to prevent the filter editor from suggesting values for fields.`
},
'notifications:banner': {
name: 'Custom banner notification',
value: '',
type: 'markdown',
description: 'A custom banner intended for temporary notices to all users. <a href="https://help.github.com/articles/basic-writing-and-formatting-syntax/" target="_blank" rel="noopener noreferrer">Markdown supported</a>.',
value: ''
description: `A custom banner intended for temporary notices to all users.
<a href="https://help.github.com/articles/basic-writing-and-formatting-syntax/"
target="_blank" rel="noopener noreferrer">Markdown supported</a>.`,
category: ['notifications'],
},
'notifications:lifetime:banner': {
name: 'Banner notification lifetime',
value: 3000000,
description: 'The time in milliseconds which a banner notification ' +
'will be displayed on-screen for. Setting to Infinity will disable the countdown.',
description: `The time in milliseconds which a banner notification
will be displayed on-screen for. Setting to Infinity will disable the countdown.`,
type: 'number',
category: ['notifications'],
},
'notifications:lifetime:error': {
name: 'Error notification lifetime',
value: 300000,
description: 'The time in milliseconds which an error notification ' +
'will be displayed on-screen for. Setting to Infinity will disable.',
description: `The time in milliseconds which an error notification
'will be displayed on-screen for. Setting to Infinity will disable.`,
type: 'number',
category: ['notifications'],
},
'notifications:lifetime:warning': {
name: 'Warning notification lifetime',
value: 10000,
description: 'The time in milliseconds which a warning notification ' +
'will be displayed on-screen for. Setting to Infinity will disable.',
description: `The time in milliseconds which a warning notification
'will be displayed on-screen for. Setting to Infinity will disable.`,
type: 'number',
category: ['notifications'],
},
'notifications:lifetime:info': {
name: 'Info notification lifetime',
value: 5000,
description: 'The time in milliseconds which an information notification ' +
'will be displayed on-screen for. Setting to Infinity will disable.',
description: `The time in milliseconds which an information notification
will be displayed on-screen for. Setting to Infinity will disable.`,
type: 'number',
category: ['notifications'],
},
'metrics:max_buckets': {
name: 'Maximum buckets',
value: 2000,
description: 'The maximum number of buckets a single datasource can return'
description: `The maximum number of buckets a single datasource can return`
},
'state:storeInSessionStorage': {
name: 'Store URLs in session storage',
value: false,
description: 'The URL can sometimes grow to be too large for some browsers to ' +
'handle. To counter-act this we are testing if storing parts of the URL in ' +
'sessions storage could help. Please let us know how it goes!'
description: `The URL can sometimes grow to be too large for some browsers to
handle. To counter-act this we are testing if storing parts of the URL in
session storage could help. Please let us know how it goes!`
},
'indexPattern:placeholder': {
name: 'Index pattern placeholder',
value: 'logstash-*',
description: 'The placeholder for the field "Index name or pattern" in the "Settings > Indices" tab.',
description: `The placeholder for the field "Index name or pattern" in the "Settings > Indices" tab.`,
},
'context:defaultSize': {
name: 'Context size',
value: 5,
description: 'The number of surrounding entries to show in the context view',
description: `The number of surrounding entries to show in the context view`,
category: ['discover'],
},
'context:step': {
name: 'Context size step',
value: 5,
description: 'The step size to increment or decrement the context size by',
description: `The step size to increment or decrement the context size by`,
category: ['discover'],
},
'context:tieBreakerFields': {
name: 'Tie breaker fields',
value: ['_doc'],
description: 'A comma-separated list of fields to use for tiebreaking between documents ' +
'that have the same timestamp value. From this list the first field that ' +
'is present and sortable in the current index pattern is used.',
description: `A comma-separated list of fields to use for tie-breaking between documents
that have the same timestamp value. From this list the first field that
is present and sortable in the current index pattern is used.`,
category: ['discover'],
},
};
}

View file

@ -23,44 +23,64 @@ export default function (kibana) {
uiSettingDefaults: {
'timelion:showTutorial': {
name: 'Show tutorial',
value: false,
description: 'Should I show the tutorial by default when entering the timelion app?'
description: `Should I show the tutorial by default when entering the timelion app?`,
category: ['timelion'],
},
'timelion:es.timefield': {
name: 'Time field',
value: '@timestamp',
description: 'Default field containing a timestamp when using .es()'
description: `Default field containing a timestamp when using .es()`,
category: ['timelion'],
},
'timelion:es.default_index': {
name: 'Default index',
value: '_all',
description: 'Default elasticsearch index to search with .es()'
description: `Default elasticsearch index to search with .es()`,
category: ['timelion'],
},
'timelion:target_buckets': {
name: 'Target buckets',
value: 200,
description: 'The number of buckets to shoot for when using auto intervals'
description: `The number of buckets to shoot for when using auto intervals`,
category: ['timelion'],
},
'timelion:max_buckets': {
name: 'Maximum buckets',
value: 2000,
description: 'The maximum number of buckets a single datasource can return'
description: `The maximum number of buckets a single datasource can return`,
category: ['timelion'],
},
'timelion:default_columns': {
name: 'Default columns',
value: 2,
description: 'Number of columns on a timelion sheet by default'
description: `Number of columns on a timelion sheet by default`,
category: ['timelion'],
},
'timelion:default_rows': {
name: 'Default rows',
value: 2,
description: 'Number of rows on a timelion sheet by default'
description: `Number of rows on a timelion sheet by default`,
category: ['timelion'],
},
'timelion:min_interval': {
name: 'Minimum interval',
value: '1ms',
description: 'The smallest interval that will be calculated when using "auto"'
description: `The smallest interval that will be calculated when using "auto"`,
category: ['timelion'],
},
'timelion:graphite.url': {
name: 'Graphite URL',
value: 'https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite',
description: '<em>[experimental]</em> The URL of your graphite host'
description: `<em>[experimental]</em> The URL of your graphite host`,
category: ['timelion'],
},
'timelion:quandl.key': {
name: 'Quandl key',
value: 'someKeyHere',
description: '<em>[experimental]</em> Your API key from www.quandl.com'
description: `<em>[experimental]</em> Your API key from www.quandl.com`,
category: ['timelion'],
}
}
},

View file

@ -326,12 +326,12 @@ Notifier.prototype.warning = function (msg, opts, cb) {
/**
* Display a banner message
* @param {String} msg
* @param {Function} cb
* @param {String} content
* @param {String} name
*/
let bannerId;
let bannerTimeoutId;
Notifier.prototype.banner = function (content = '') {
Notifier.prototype.banner = function (content = '', name = '') {
const BANNER_PRIORITY = 100;
const dismissBanner = () => {
@ -355,6 +355,7 @@ Notifier.prototype.banner = function (content = '') {
* The notifier relies on `markdown-it` to produce safe and correct HTML.
*/
dangerouslySetInnerHTML={{ __html: markdownIt.render(content) }} //eslint-disable-line react/no-danger
data-test-subj={name ? `banner-${name}` : null}
/>
<EuiButton type="primary" size="s" onClick={dismissBanner}>

View file

@ -62,7 +62,7 @@ function applyConfig(config) {
const banner = config.get('notifications:banner');
if (typeof banner === 'string' && banner.trim()) {
notify.banner(banner);
notify.banner(banner, 'notifications:banner');
}
}

View file

@ -28,18 +28,11 @@ export default function ({ getService, getPageObjects }) {
expect(advancedSetting).to.be('America/Phoenix');
});
it('should coerce an empty setting of type JSON into an empty object', async function () {
await PageObjects.settings.clickKibanaSettings();
await PageObjects.settings.setAdvancedSettingsInput('query:queryString:options', '', 'unsavedValueJsonTextArea');
const advancedSetting = await PageObjects.settings.getAdvancedSettings('query:queryString:options');
expect(advancedSetting).to.be.eql('{}');
});
describe('state:storeInSessionStorage', () => {
it ('defaults to false', async () => {
await PageObjects.settings.clickKibanaSettings();
const storeInSessionStorage = await PageObjects.settings.getAdvancedSettings('state:storeInSessionStorage');
expect(storeInSessionStorage).to.be('false');
const storeInSessionStorage = await PageObjects.settings.getAdvancedSettingCheckbox('state:storeInSessionStorage');
expect(storeInSessionStorage).to.be(false);
});
it('when false, dashboard state is unhashed', async function () {
@ -61,8 +54,8 @@ export default function ({ getService, getPageObjects }) {
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSettings();
await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage');
const storeInSessionStorage = await PageObjects.settings.getAdvancedSettings('state:storeInSessionStorage');
expect(storeInSessionStorage).to.be('true');
const storeInSessionStorage = await PageObjects.settings.getAdvancedSettingCheckbox('state:storeInSessionStorage');
expect(storeInSessionStorage).to.be(true);
});
it('when true, dashboard state is hashed', async function () {
@ -87,21 +80,6 @@ export default function ({ getService, getPageObjects }) {
});
});
describe('notifications:banner', () => {
it('Should convert notification banner markdown into HTML', async function () {
await PageObjects.settings.clickKibanaSettings();
await PageObjects.settings.setAdvancedSettingsInput('notifications:banner', '# Welcome to Kibana', 'unsavedValueMarkdownTextArea');
const bannerValue = await PageObjects.settings.getAdvancedSettings('notifications:banner');
expect(bannerValue).to.equal('Welcome to Kibana');
});
after('navigate to settings page and clear notifications:banner', async () => {
await PageObjects.settings.navigateTo();
await PageObjects.settings.clickKibanaSettings();
await PageObjects.settings.clearAdvancedSettings('notifications:banner');
});
});
after(async function () {
await PageObjects.settings.clickKibanaSettings();
await PageObjects.settings.setAdvancedSettingsSelect('dateFormat:tz', 'UTC');

View file

@ -38,42 +38,42 @@ export function SettingsPageProvider({ getService, getPageObjects }) {
async getAdvancedSettings(propertyName) {
log.debug('in getAdvancedSettings');
return await testSubjects.getVisibleText(`advancedSetting-${propertyName}-currentValue`);
const setting = await testSubjects.find(`advancedSetting-editField-${propertyName}`);
return await setting.getProperty('value');
}
async getAdvancedSettingCheckbox(propertyName) {
log.debug('in getAdvancedSettingCheckbox');
const setting = await testSubjects.find(`advancedSetting-editField-${propertyName}`);
return await setting.getProperty('checked');
}
async clearAdvancedSettings(propertyName) {
await testSubjects.click(`advancedSetting-${propertyName}-clearButton`);
await testSubjects.click(`advancedSetting-resetField-${propertyName}`);
await PageObjects.header.waitUntilLoadingHasFinished();
}
async setAdvancedSettingsSelect(propertyName, propertyValue) {
await testSubjects.click(`advancedSetting-${propertyName}-editButton`);
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.common.sleep(1000);
await remote.setFindTimeout(defaultFindTimeout)
.findByCssSelector(`option[label="${propertyValue}"]`).click();
.findByCssSelector(`[data-test-subj="advancedSetting-editField-${propertyName}"] option[value="${propertyValue}"]`).click();
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click(`advancedSetting-${propertyName}-saveButton`);
await testSubjects.click(`advancedSetting-saveEditField-${propertyName}`);
await PageObjects.header.waitUntilLoadingHasFinished();
}
async setAdvancedSettingsInput(propertyName, propertyValue, inputSelector) {
await testSubjects.click(`advancedSetting-${propertyName}-editButton`);
await PageObjects.header.waitUntilLoadingHasFinished();
const input = await testSubjects.find(inputSelector);
async setAdvancedSettingsInput(propertyName, propertyValue) {
const input = await testSubjects.find(`advancedSetting-editField-${propertyName}`);
await input.clearValue();
await input.type(propertyValue);
await testSubjects.click(`advancedSetting-${propertyName}-saveButton`);
await testSubjects.click(`advancedSetting-saveEditField-${propertyName}`);
await PageObjects.header.waitUntilLoadingHasFinished();
}
async toggleAdvancedSettingCheckbox(propertyName) {
await testSubjects.click(`advancedSetting-${propertyName}-editButton`);
await PageObjects.header.waitUntilLoadingHasFinished();
const checkbox = await testSubjects.find(`advancedSetting-${propertyName}-checkbox`);
const checkbox = await testSubjects.find(`advancedSetting-editField-${propertyName}`);
await checkbox.click();
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click(`advancedSetting-${propertyName}-saveButton`);
await testSubjects.click(`advancedSetting-saveEditField-${propertyName}`);
await PageObjects.header.waitUntilLoadingHasFinished();
}

View file

@ -27,8 +27,10 @@ export function dashboardMode(kibana) {
uiExports: {
uiSettingDefaults: {
[CONFIG_DASHBOARD_ONLY_MODE_ROLES]: {
description: 'Roles that belong to View Dashboards Only mode',
name: 'Dashboards only roles',
description: `Roles that belong to View Dashboards Only mode`,
value: ['kibana_dashboard_only_user'],
category: ['dashboard'],
}
},
app: {

View file

@ -47,15 +47,17 @@ export const reporting = (kibana) => {
},
uiSettingDefaults: {
[UI_SETTINGS_CUSTOM_PDF_LOGO]: {
description: `Custom image to use in the PDF's footer`,
name: 'PDF footer image',
value: null,
description: `Custom image to use in the PDF's footer`,
type: 'image',
options: {
maxSize: {
length: kbToBase64Length(200),
description: '200 kB',
}
}
},
category: ['reporting'],
}
}
},

View file

@ -67,12 +67,14 @@ export const xpackMain = (kibana) => {
uiExports: {
uiSettingDefaults: {
[CONFIG_TELEMETRY]: {
name: 'Telemetry opt-in',
description: CONFIG_TELEMETRY_DESC,
value: false
},
[XPACK_DEFAULT_ADMIN_EMAIL_UI_SETTING]: {
name: 'Admin email',
// TODO: change the description when email address is used for more things?
description: 'Recipient email address for X-Pack admin operations, such as Cluster Alert email notifications from Monitoring.',
description: `Recipient email address for X-Pack admin operations, such as Cluster Alert email notifications from Monitoring.`,
type: 'string', // TODO: Any way of ensuring this is a valid email address?
value: null
}