mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[7.x] Advanced settings UI change to centralize save state (#5… (#58373)
This commit is contained in:
parent
328333d912
commit
c3109b59da
19 changed files with 5161 additions and 4664 deletions
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
@import './management_app/advanced_settings';
|
||||
@import './management_app/index';
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
@import './advanced_settings';
|
||||
|
||||
@import './components/index';
|
|
@ -17,21 +17,27 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
.mgtAdvancedSettings__field {
|
||||
.mgtAdvancedSettings__field {
|
||||
+ * {
|
||||
margin-top: $euiSize;
|
||||
}
|
||||
|
||||
&Wrapper {
|
||||
width: 640px;
|
||||
|
||||
@include internetExplorerOnly() {
|
||||
min-height: 1px;
|
||||
}
|
||||
padding-left: $euiSizeS;
|
||||
margin-left: -$euiSizeS;
|
||||
&--unsaved {
|
||||
// Simulates a left side border without shifting content
|
||||
box-shadow: -$euiSizeXS 0px $euiColorSecondary;
|
||||
}
|
||||
|
||||
&Actions {
|
||||
padding-top: $euiSizeM;
|
||||
&--invalid {
|
||||
// Simulates a left side border without shifting content
|
||||
box-shadow: -$euiSizeXS 0px $euiColorDanger;
|
||||
}
|
||||
@include internetExplorerOnly() {
|
||||
min-height: 1px;
|
||||
}
|
||||
&Row {
|
||||
padding-left: $euiSizeS;
|
||||
}
|
||||
|
||||
@include internetExplorerOnly {
|
||||
|
@ -40,3 +46,19 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mgtAdvancedSettingsForm__unsavedCount {
|
||||
@include euiBreakpoint('xs', 's') {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mgtAdvancedSettingsForm__unsavedCountMessage{
|
||||
// Simulates a left side border without shifting content
|
||||
box-shadow: -$euiSizeXS 0px $euiColorSecondary;
|
||||
padding-left: $euiSizeS;
|
||||
}
|
||||
|
||||
.mgtAdvancedSettingsForm__button {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ import { ComponentRegistry } from '../';
|
|||
|
||||
import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib';
|
||||
|
||||
import { FieldSetting, IQuery } from './types';
|
||||
import { FieldSetting, IQuery, SettingsChanges } from './types';
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
enableSaving: boolean;
|
||||
|
@ -177,6 +177,13 @@ export class AdvancedSettingsComponent extends Component<
|
|||
});
|
||||
};
|
||||
|
||||
saveConfig = async (changes: SettingsChanges) => {
|
||||
const arr = Object.entries(changes).map(([key, value]) =>
|
||||
this.props.uiSettings.set(key, value)
|
||||
);
|
||||
return Promise.all(arr);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { filteredSettings, query, footerQueryMatched } = this.state;
|
||||
const componentRegistry = this.props.componentRegistry;
|
||||
|
@ -205,18 +212,19 @@ export class AdvancedSettingsComponent extends Component<
|
|||
<AdvancedSettingsVoiceAnnouncement queryText={query.text} settings={filteredSettings} />
|
||||
|
||||
<Form
|
||||
settings={filteredSettings}
|
||||
settings={this.groupedSettings}
|
||||
visibleSettings={filteredSettings}
|
||||
categories={this.categories}
|
||||
categoryCounts={this.categoryCounts}
|
||||
clearQuery={this.clearQuery}
|
||||
save={this.props.uiSettings.set.bind(this.props.uiSettings)}
|
||||
clear={this.props.uiSettings.remove.bind(this.props.uiSettings)}
|
||||
save={this.saveConfig}
|
||||
showNoResultsMessage={!footerQueryMatched}
|
||||
enableSaving={this.props.enableSaving}
|
||||
dockLinks={this.props.dockLinks}
|
||||
toasts={this.props.toasts}
|
||||
/>
|
||||
<PageFooter
|
||||
toasts={this.props.toasts}
|
||||
query={query}
|
||||
onQueryMatchChange={this.onFooterQueryMatchChange}
|
||||
enableSaving={this.props.enableSaving}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
@import './form/index';
|
File diff suppressed because it is too large
Load diff
|
@ -20,21 +20,14 @@
|
|||
import React from 'react';
|
||||
import { I18nProvider } from '@kbn/i18n/react';
|
||||
import { shallowWithI18nProvider, mountWithI18nProvider } from 'test_utils/enzyme_helpers';
|
||||
import { mount } from 'enzyme';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { FieldSetting } from '../../types';
|
||||
import { UiSettingsType, StringValidation } from '../../../../../../core/public';
|
||||
import { notificationServiceMock, docLinksServiceMock } from '../../../../../../core/public/mocks';
|
||||
|
||||
// @ts-ignore
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { Field } from './field';
|
||||
|
||||
jest.mock('ui/notify', () => ({
|
||||
toastNotifications: {
|
||||
addDanger: () => {},
|
||||
add: jest.fn(),
|
||||
},
|
||||
}));
|
||||
import { Field, getEditableValue } from './field';
|
||||
|
||||
jest.mock('brace/theme/textmate', () => 'brace/theme/textmate');
|
||||
jest.mock('brace/mode/markdown', () => 'brace/mode/markdown');
|
||||
|
@ -45,6 +38,18 @@ const defaults = {
|
|||
category: ['category'],
|
||||
};
|
||||
|
||||
const exampleValues = {
|
||||
array: ['example_value'],
|
||||
boolean: false,
|
||||
image: 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=',
|
||||
json: { foo: 'bar2' },
|
||||
markdown: 'Hello World',
|
||||
number: 1,
|
||||
select: 'banana',
|
||||
string: 'hello world',
|
||||
stringWithValidation: 'foo',
|
||||
};
|
||||
|
||||
const settings: Record<string, FieldSetting> = {
|
||||
array: {
|
||||
name: 'array:test:setting',
|
||||
|
@ -161,7 +166,7 @@ const settings: Record<string, FieldSetting> = {
|
|||
description: 'Description for String test validation setting',
|
||||
type: 'string',
|
||||
validation: {
|
||||
regex: new RegExp('/^foo'),
|
||||
regex: new RegExp('^foo'),
|
||||
message: 'must start with "foo"',
|
||||
},
|
||||
value: undefined,
|
||||
|
@ -182,11 +187,22 @@ const userValues = {
|
|||
string: 'foo',
|
||||
stringWithValidation: 'fooUserValue',
|
||||
};
|
||||
|
||||
const invalidUserValues = {
|
||||
stringWithValidation: 'invalidUserValue',
|
||||
};
|
||||
const save = jest.fn(() => Promise.resolve(true));
|
||||
const clear = jest.fn(() => Promise.resolve(true));
|
||||
|
||||
const handleChange = jest.fn();
|
||||
const clearChange = jest.fn();
|
||||
|
||||
const getFieldSettingValue = (wrapper: ReactWrapper, name: string, type: string) => {
|
||||
const field = findTestSubject(wrapper, `advancedSetting-editField-${name}`);
|
||||
if (type === 'boolean') {
|
||||
return field.props()['aria-checked'];
|
||||
} else {
|
||||
return field.props().value;
|
||||
}
|
||||
};
|
||||
|
||||
describe('Field', () => {
|
||||
Object.keys(settings).forEach(type => {
|
||||
|
@ -197,8 +213,7 @@ describe('Field', () => {
|
|||
const component = shallowWithI18nProvider(
|
||||
<Field
|
||||
setting={setting}
|
||||
save={save}
|
||||
clear={clear}
|
||||
handleChange={handleChange}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
|
@ -217,8 +232,7 @@ describe('Field', () => {
|
|||
value: userValues[type],
|
||||
isOverridden: true,
|
||||
}}
|
||||
save={save}
|
||||
clear={clear}
|
||||
handleChange={handleChange}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
|
@ -232,14 +246,12 @@ describe('Field', () => {
|
|||
const component = shallowWithI18nProvider(
|
||||
<Field
|
||||
setting={setting}
|
||||
save={save}
|
||||
clear={clear}
|
||||
handleChange={handleChange}
|
||||
enableSaving={false}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
@ -251,8 +263,7 @@ describe('Field', () => {
|
|||
// @ts-ignore
|
||||
value: userValues[type],
|
||||
}}
|
||||
save={save}
|
||||
clear={clear}
|
||||
handleChange={handleChange}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
|
@ -269,28 +280,44 @@ describe('Field', () => {
|
|||
...setting,
|
||||
isCustom: true,
|
||||
}}
|
||||
save={save}
|
||||
clear={clear}
|
||||
handleChange={handleChange}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
it('should render unsaved value if there are unsaved changes', async () => {
|
||||
const component = shallowWithI18nProvider(
|
||||
<Field
|
||||
setting={{
|
||||
...setting,
|
||||
isCustom: true,
|
||||
}}
|
||||
handleChange={handleChange}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
unsavedChanges={{
|
||||
// @ts-ignore
|
||||
value: exampleValues[setting.type],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
if (type === 'select') {
|
||||
it('should use options for rendering values', () => {
|
||||
it('should use options for rendering values and optionsLabels for rendering labels', () => {
|
||||
const component = mountWithI18nProvider(
|
||||
<Field
|
||||
setting={{
|
||||
...setting,
|
||||
isCustom: true,
|
||||
}}
|
||||
save={save}
|
||||
clear={clear}
|
||||
handleChange={handleChange}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
|
@ -298,25 +325,8 @@ describe('Field', () => {
|
|||
);
|
||||
const select = findTestSubject(component, `advancedSetting-editField-${setting.name}`);
|
||||
// @ts-ignore
|
||||
const labels = select.find('option').map(option => option.prop('value'));
|
||||
expect(labels).toEqual(['apple', 'orange', 'banana']);
|
||||
});
|
||||
|
||||
it('should use optionLabels for rendering labels', () => {
|
||||
const component = mountWithI18nProvider(
|
||||
<Field
|
||||
setting={{
|
||||
...setting,
|
||||
isCustom: true,
|
||||
}}
|
||||
save={save}
|
||||
clear={clear}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
/>
|
||||
);
|
||||
const select = findTestSubject(component, `advancedSetting-editField-${setting.name}`);
|
||||
const values = select.find('option').map(option => option.prop('value'));
|
||||
expect(values).toEqual(['apple', 'orange', 'banana']);
|
||||
// @ts-ignore
|
||||
const labels = select.find('option').map(option => option.text());
|
||||
expect(labels).toEqual(['Apple', 'Orange', 'banana']);
|
||||
|
@ -328,8 +338,8 @@ describe('Field', () => {
|
|||
<I18nProvider>
|
||||
<Field
|
||||
setting={setting}
|
||||
save={save}
|
||||
clear={clear}
|
||||
clearChange={clearChange}
|
||||
handleChange={handleChange}
|
||||
enableSaving={true}
|
||||
toasts={notificationServiceMock.createStartContract().toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
|
@ -352,90 +362,52 @@ describe('Field', () => {
|
|||
const userValue = userValues[type];
|
||||
(component.instance() as Field).getImageAsBase64 = ({}: Blob) => Promise.resolve('');
|
||||
|
||||
it('should be able to change value from no value and cancel', async () => {
|
||||
await (component.instance() as Field).onImageChange([userValue]);
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-cancelEditField-${setting.name}`).simulate(
|
||||
'click'
|
||||
);
|
||||
expect(
|
||||
(component.instance() as Field).state.unsavedValue ===
|
||||
(component.instance() as Field).state.savedValue
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to change value and save', async () => {
|
||||
await (component.instance() as Field).onImageChange([userValue]);
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-saveEditField-${setting.name}`).simulate(
|
||||
'click'
|
||||
);
|
||||
expect(save).toBeCalled();
|
||||
component.setState({ savedValue: userValue });
|
||||
it('should be able to change value and cancel', async () => {
|
||||
(component.instance() as Field).onImageChange([userValue]);
|
||||
expect(handleChange).toBeCalled();
|
||||
await wrapper.setProps({
|
||||
unsavedChanges: {
|
||||
value: userValue,
|
||||
changeImage: true,
|
||||
},
|
||||
setting: {
|
||||
...(component.instance() as Field).props.setting,
|
||||
value: userValue,
|
||||
},
|
||||
});
|
||||
|
||||
await (component.instance() as Field).cancelChangeImage();
|
||||
expect(clearChange).toBeCalledWith(setting.name);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
it('should be able to change value from existing value and save', async () => {
|
||||
it('should be able to change value from existing value', async () => {
|
||||
await wrapper.setProps({
|
||||
unsavedChanges: {},
|
||||
});
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-changeImage-${setting.name}`).simulate('click');
|
||||
|
||||
const newUserValue = `${userValue}=`;
|
||||
await (component.instance() as Field).onImageChange([newUserValue]);
|
||||
const updated2 = wrapper.update();
|
||||
findTestSubject(updated2, `advancedSetting-saveEditField-${setting.name}`).simulate(
|
||||
'click'
|
||||
);
|
||||
expect(save).toBeCalled();
|
||||
component.setState({ savedValue: newUserValue });
|
||||
await wrapper.setProps({
|
||||
setting: {
|
||||
...(component.instance() as Field).props.setting,
|
||||
value: newUserValue,
|
||||
},
|
||||
});
|
||||
wrapper.update();
|
||||
expect(handleChange).toBeCalled();
|
||||
});
|
||||
|
||||
it('should be able to reset to default value', async () => {
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
|
||||
expect(clear).toBeCalled();
|
||||
expect(handleChange).toBeCalledWith(setting.name, {
|
||||
value: getEditableValue(setting.type, setting.defVal),
|
||||
changeImage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else if (type === 'markdown' || type === 'json') {
|
||||
describe(`for changing ${type} setting`, () => {
|
||||
const { wrapper, component } = setup();
|
||||
const userValue = userValues[type];
|
||||
const fieldUserValue = userValue;
|
||||
|
||||
it('should be able to change value and cancel', async () => {
|
||||
(component.instance() as Field).onCodeEditorChange(fieldUserValue as UiSettingsType);
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-cancelEditField-${setting.name}`).simulate(
|
||||
'click'
|
||||
);
|
||||
expect(
|
||||
(component.instance() as Field).state.unsavedValue ===
|
||||
(component.instance() as Field).state.savedValue
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to change value and save', async () => {
|
||||
(component.instance() as Field).onCodeEditorChange(fieldUserValue as UiSettingsType);
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-saveEditField-${setting.name}`).simulate(
|
||||
'click'
|
||||
);
|
||||
expect(save).toBeCalled();
|
||||
component.setState({ savedValue: fieldUserValue });
|
||||
it('should be able to change value', async () => {
|
||||
(component.instance() as Field).onCodeEditorChange(userValue as UiSettingsType);
|
||||
expect(handleChange).toBeCalledWith(setting.name, { value: userValue });
|
||||
await wrapper.setProps({
|
||||
setting: {
|
||||
...(component.instance() as Field).props.setting,
|
||||
|
@ -445,19 +417,21 @@ describe('Field', () => {
|
|||
wrapper.update();
|
||||
});
|
||||
|
||||
if (type === 'json') {
|
||||
it('should be able to clear value and have empty object populate', async () => {
|
||||
(component.instance() as Field).onCodeEditorChange('' as UiSettingsType);
|
||||
wrapper.update();
|
||||
expect((component.instance() as Field).state.unsavedValue).toEqual('{}');
|
||||
});
|
||||
}
|
||||
|
||||
it('should be able to reset to default value', async () => {
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
|
||||
expect(clear).toBeCalled();
|
||||
expect(handleChange).toBeCalledWith(setting.name, {
|
||||
value: getEditableValue(setting.type, setting.defVal),
|
||||
});
|
||||
});
|
||||
|
||||
if (type === 'json') {
|
||||
it('should be able to clear value and have empty object populate', async () => {
|
||||
await (component.instance() as Field).onCodeEditorChange('' as UiSettingsType);
|
||||
wrapper.update();
|
||||
expect(handleChange).toBeCalledWith(setting.name, { value: setting.defVal });
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
describe(`for changing ${type} setting`, () => {
|
||||
|
@ -470,76 +444,45 @@ describe('Field', () => {
|
|||
// @ts-ignore
|
||||
const invalidUserValue = invalidUserValues[type];
|
||||
it('should display an error when validation fails', async () => {
|
||||
(component.instance() as Field).onFieldChange(invalidUserValue);
|
||||
await (component.instance() as Field).onFieldChange(invalidUserValue);
|
||||
const expectedUnsavedChanges = {
|
||||
value: invalidUserValue,
|
||||
error: (setting.validation as StringValidation).message,
|
||||
isInvalid: true,
|
||||
};
|
||||
expect(handleChange).toBeCalledWith(setting.name, expectedUnsavedChanges);
|
||||
wrapper.setProps({ unsavedChanges: expectedUnsavedChanges });
|
||||
const updated = wrapper.update();
|
||||
const errorMessage = updated.find('.euiFormErrorText').text();
|
||||
expect(errorMessage).toEqual((setting.validation as StringValidation).message);
|
||||
expect(errorMessage).toEqual(expectedUnsavedChanges.error);
|
||||
});
|
||||
}
|
||||
|
||||
it('should be able to change value and cancel', async () => {
|
||||
(component.instance() as Field).onFieldChange(fieldUserValue);
|
||||
it('should be able to change value', async () => {
|
||||
await (component.instance() as Field).onFieldChange(fieldUserValue);
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-cancelEditField-${setting.name}`).simulate(
|
||||
'click'
|
||||
);
|
||||
expect(
|
||||
(component.instance() as Field).state.unsavedValue ===
|
||||
(component.instance() as Field).state.savedValue
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to change value and save', async () => {
|
||||
(component.instance() as Field).onFieldChange(fieldUserValue);
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-saveEditField-${setting.name}`).simulate(
|
||||
'click'
|
||||
);
|
||||
expect(save).toBeCalled();
|
||||
component.setState({ savedValue: fieldUserValue });
|
||||
await wrapper.setProps({
|
||||
setting: {
|
||||
...(component.instance() as Field).props.setting,
|
||||
value: userValue,
|
||||
},
|
||||
});
|
||||
wrapper.update();
|
||||
expect(handleChange).toBeCalledWith(setting.name, { value: fieldUserValue });
|
||||
updated.setProps({ unsavedChanges: { value: fieldUserValue } });
|
||||
const currentValue = getFieldSettingValue(updated, setting.name, type);
|
||||
expect(currentValue).toEqual(fieldUserValue);
|
||||
});
|
||||
|
||||
it('should be able to reset to default value', async () => {
|
||||
await wrapper.setProps({
|
||||
unsavedChanges: {},
|
||||
setting: { ...setting, value: fieldUserValue },
|
||||
});
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
|
||||
expect(clear).toBeCalled();
|
||||
const expectedEditableValue = getEditableValue(setting.type, setting.defVal);
|
||||
expect(handleChange).toBeCalledWith(setting.name, {
|
||||
value: expectedEditableValue,
|
||||
});
|
||||
updated.setProps({ unsavedChanges: { value: expectedEditableValue } });
|
||||
const currentValue = getFieldSettingValue(updated, setting.name, type);
|
||||
expect(currentValue).toEqual(expectedEditableValue);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should show a reload toast when saving setting requiring a page reload', async () => {
|
||||
const setting = {
|
||||
...settings.string,
|
||||
requiresPageReload: true,
|
||||
};
|
||||
const toasts = notificationServiceMock.createStartContract().toasts;
|
||||
const wrapper = mountWithI18nProvider(
|
||||
<Field
|
||||
setting={setting}
|
||||
save={save}
|
||||
clear={clear}
|
||||
enableSaving={true}
|
||||
toasts={toasts}
|
||||
dockLinks={docLinksServiceMock.createStartContract().links}
|
||||
/>
|
||||
);
|
||||
(wrapper.instance() as Field).onFieldChange({ target: { value: 'a new value' } });
|
||||
const updated = wrapper.update();
|
||||
findTestSubject(updated, `advancedSetting-saveEditField-${setting.name}`).simulate('click');
|
||||
expect(save).toHaveBeenCalled();
|
||||
await save();
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining('Please reload the page'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,17 +18,16 @@
|
|||
*/
|
||||
|
||||
import React, { PureComponent, Fragment } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import 'brace/theme/textmate';
|
||||
import 'brace/mode/markdown';
|
||||
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiCode,
|
||||
EuiCodeBlock,
|
||||
EuiScreenReaderOnly,
|
||||
// @ts-ignore
|
||||
EuiCodeEditor,
|
||||
EuiDescribedFormGroup,
|
||||
|
@ -36,23 +35,20 @@ import {
|
|||
EuiFieldText,
|
||||
// @ts-ignore
|
||||
EuiFilePicker,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiIconTip,
|
||||
EuiImage,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
EuiToolTip,
|
||||
EuiText,
|
||||
EuiSelect,
|
||||
EuiSwitch,
|
||||
EuiSwitchEvent,
|
||||
keyCodes,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { FieldSetting } from '../../types';
|
||||
import { FieldSetting, FieldState } from '../../types';
|
||||
import { isDefaultValue } from '../../lib';
|
||||
import {
|
||||
UiSettingsType,
|
||||
|
@ -64,71 +60,37 @@ import {
|
|||
|
||||
interface FieldProps {
|
||||
setting: FieldSetting;
|
||||
save: (name: string, value: string) => Promise<boolean>;
|
||||
clear: (name: string) => Promise<boolean>;
|
||||
handleChange: (name: string, value: FieldState) => void;
|
||||
enableSaving: boolean;
|
||||
dockLinks: DocLinksStart['links'];
|
||||
toasts: ToastsStart;
|
||||
clearChange?: (name: string) => void;
|
||||
unsavedChanges?: FieldState;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface FieldState {
|
||||
unsavedValue: any;
|
||||
savedValue: any;
|
||||
loading: boolean;
|
||||
isInvalid: boolean;
|
||||
error: string | null;
|
||||
changeImage: boolean;
|
||||
isJsonArray: boolean;
|
||||
}
|
||||
|
||||
export class Field extends PureComponent<FieldProps, FieldState> {
|
||||
private changeImageForm: EuiFilePicker | undefined;
|
||||
constructor(props: FieldProps) {
|
||||
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(String(defVal) || '{}')) : false,
|
||||
};
|
||||
export const getEditableValue = (
|
||||
type: UiSettingsType,
|
||||
value: FieldSetting['value'],
|
||||
defVal?: FieldSetting['defVal']
|
||||
) => {
|
||||
const val = value === null || value === undefined ? defVal : value;
|
||||
switch (type) {
|
||||
case 'array':
|
||||
return (val as string[]).join(', ');
|
||||
case 'boolean':
|
||||
return !!val;
|
||||
case 'number':
|
||||
return Number(val);
|
||||
case 'image':
|
||||
return val;
|
||||
default:
|
||||
return val || '';
|
||||
}
|
||||
};
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: FieldProps) {
|
||||
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: UiSettingsType,
|
||||
value: FieldSetting['value'],
|
||||
defVal: FieldSetting['defVal']
|
||||
) {
|
||||
const val = value === null || value === undefined ? defVal : value;
|
||||
switch (type) {
|
||||
case 'array':
|
||||
return (val as string[]).join(', ');
|
||||
case 'boolean':
|
||||
return !!val;
|
||||
case 'number':
|
||||
return Number(val);
|
||||
case 'image':
|
||||
return val;
|
||||
default:
|
||||
return val || '';
|
||||
}
|
||||
}
|
||||
export class Field extends PureComponent<FieldProps> {
|
||||
private changeImageForm: EuiFilePicker | undefined = React.createRef();
|
||||
|
||||
getDisplayedDefaultValue(
|
||||
type: UiSettingsType,
|
||||
|
@ -150,47 +112,60 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
}
|
||||
}
|
||||
|
||||
setLoading(loading: boolean) {
|
||||
this.setState({
|
||||
loading,
|
||||
});
|
||||
}
|
||||
handleChange = (unsavedChanges: FieldState) => {
|
||||
this.props.handleChange(this.props.setting.name, unsavedChanges);
|
||||
};
|
||||
|
||||
clearError() {
|
||||
this.setState({
|
||||
isInvalid: false,
|
||||
error: null,
|
||||
});
|
||||
resetField = () => {
|
||||
const { type, defVal } = this.props.setting;
|
||||
if (type === 'image') {
|
||||
this.cancelChangeImage();
|
||||
return this.handleChange({
|
||||
value: getEditableValue(type, defVal),
|
||||
changeImage: true,
|
||||
});
|
||||
}
|
||||
return this.handleChange({ value: getEditableValue(type, defVal) });
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: FieldProps) {
|
||||
if (
|
||||
prevProps.setting.type === 'image' &&
|
||||
prevProps.unsavedChanges?.value &&
|
||||
!this.props.unsavedChanges?.value
|
||||
) {
|
||||
this.cancelChangeImage();
|
||||
}
|
||||
}
|
||||
|
||||
onCodeEditorChange = (value: UiSettingsType) => {
|
||||
const { type } = this.props.setting;
|
||||
const { isJsonArray } = this.state;
|
||||
const { defVal, type } = this.props.setting;
|
||||
|
||||
let newUnsavedValue;
|
||||
let isInvalid = false;
|
||||
let error = null;
|
||||
let errorParams = {};
|
||||
|
||||
switch (type) {
|
||||
case 'json':
|
||||
const isJsonArray = Array.isArray(JSON.parse((defVal as string) || '{}'));
|
||||
newUnsavedValue = value.trim() || (isJsonArray ? '[]' : '{}');
|
||||
try {
|
||||
JSON.parse(newUnsavedValue);
|
||||
} catch (e) {
|
||||
isInvalid = true;
|
||||
error = i18n.translate('advancedSettings.field.codeEditorSyntaxErrorMessage', {
|
||||
defaultMessage: 'Invalid JSON syntax',
|
||||
});
|
||||
errorParams = {
|
||||
error: i18n.translate('advancedSettings.field.codeEditorSyntaxErrorMessage', {
|
||||
defaultMessage: 'Invalid JSON syntax',
|
||||
}),
|
||||
isInvalid: true,
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
newUnsavedValue = value;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
isInvalid,
|
||||
unsavedValue: newUnsavedValue,
|
||||
this.handleChange({
|
||||
value: newUnsavedValue,
|
||||
...errorParams,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -201,58 +176,44 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
onFieldChangeEvent = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
this.onFieldChange(e.target.value);
|
||||
|
||||
onFieldChange = (value: any) => {
|
||||
const { type, validation } = this.props.setting;
|
||||
const { unsavedValue } = this.state;
|
||||
|
||||
onFieldChange = (targetValue: any) => {
|
||||
const { type, validation, value, defVal } = this.props.setting;
|
||||
let newUnsavedValue;
|
||||
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
newUnsavedValue = !unsavedValue;
|
||||
const { unsavedChanges } = this.props;
|
||||
const currentValue = unsavedChanges
|
||||
? unsavedChanges.value
|
||||
: getEditableValue(type, value, defVal);
|
||||
newUnsavedValue = !currentValue;
|
||||
break;
|
||||
case 'number':
|
||||
newUnsavedValue = Number(value);
|
||||
newUnsavedValue = Number(targetValue);
|
||||
break;
|
||||
default:
|
||||
newUnsavedValue = value;
|
||||
newUnsavedValue = targetValue;
|
||||
}
|
||||
|
||||
let isInvalid = false;
|
||||
let error = null;
|
||||
let errorParams = {};
|
||||
|
||||
if (validation && (validation as StringValidationRegex).regex) {
|
||||
if ((validation as StringValidationRegex)?.regex) {
|
||||
if (!(validation as StringValidationRegex).regex!.test(newUnsavedValue.toString())) {
|
||||
error = (validation as StringValidationRegex).message;
|
||||
isInvalid = true;
|
||||
errorParams = {
|
||||
error: (validation as StringValidationRegex).message,
|
||||
isInvalid: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
unsavedValue: newUnsavedValue,
|
||||
isInvalid,
|
||||
error,
|
||||
this.handleChange({
|
||||
value: newUnsavedValue,
|
||||
...errorParams,
|
||||
});
|
||||
};
|
||||
|
||||
onFieldKeyDown = ({ keyCode }: { keyCode: number }) => {
|
||||
if (keyCode === keyCodes.ENTER) {
|
||||
this.saveEdit();
|
||||
}
|
||||
if (keyCode === keyCodes.ESCAPE) {
|
||||
this.cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
onFieldEscape = ({ keyCode }: { keyCode: number }) => {
|
||||
if (keyCode === keyCodes.ESCAPE) {
|
||||
this.cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
onImageChange = async (files: any[]) => {
|
||||
if (!files.length) {
|
||||
this.clearError();
|
||||
this.setState({
|
||||
unsavedValue: null,
|
||||
});
|
||||
|
@ -266,19 +227,24 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
if (file instanceof File) {
|
||||
base64Image = (await this.getImageAsBase64(file)) as string;
|
||||
}
|
||||
const isInvalid = !!(maxSize && maxSize.length && base64Image.length > maxSize.length);
|
||||
this.setState({
|
||||
isInvalid,
|
||||
error: isInvalid
|
||||
? i18n.translate('advancedSettings.field.imageTooLargeErrorMessage', {
|
||||
defaultMessage: 'Image is too large, maximum size is {maxSizeDescription}',
|
||||
values: {
|
||||
maxSizeDescription: maxSize.description,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
|
||||
let errorParams = {};
|
||||
const isInvalid = !!(maxSize?.length && base64Image.length > maxSize.length);
|
||||
if (isInvalid) {
|
||||
errorParams = {
|
||||
isInvalid,
|
||||
error: i18n.translate('advancedSettings.field.imageTooLargeErrorMessage', {
|
||||
defaultMessage: 'Image is too large, maximum size is {maxSizeDescription}',
|
||||
values: {
|
||||
maxSizeDescription: maxSize.description,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
this.handleChange({
|
||||
changeImage: true,
|
||||
unsavedValue: base64Image,
|
||||
value: base64Image,
|
||||
...errorParams,
|
||||
});
|
||||
} catch (err) {
|
||||
this.props.toasts.addDanger(
|
||||
|
@ -305,152 +271,62 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
}
|
||||
|
||||
changeImage = () => {
|
||||
this.setState({
|
||||
this.handleChange({
|
||||
value: null,
|
||||
changeImage: true,
|
||||
});
|
||||
};
|
||||
|
||||
cancelChangeImage = () => {
|
||||
const { savedValue } = this.state;
|
||||
|
||||
if (this.changeImageForm) {
|
||||
this.changeImageForm.fileInput.value = null;
|
||||
this.changeImageForm.handleChange();
|
||||
if (this.changeImageForm.current) {
|
||||
this.changeImageForm.current.fileInput.value = null;
|
||||
this.changeImageForm.current.handleChange({});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
changeImage: false,
|
||||
unsavedValue: savedValue,
|
||||
});
|
||||
};
|
||||
|
||||
cancelEdit = () => {
|
||||
const { savedValue } = this.state;
|
||||
this.clearError();
|
||||
this.setState({
|
||||
unsavedValue: savedValue,
|
||||
});
|
||||
};
|
||||
|
||||
showPageReloadToast = () => {
|
||||
if (this.props.setting.requiresPageReload) {
|
||||
this.props.toasts.add({
|
||||
title: i18n.translate('advancedSettings.field.requiresPageReloadToastDescription', {
|
||||
defaultMessage: 'Please reload the page for the "{settingName}" setting to take effect.',
|
||||
values: {
|
||||
settingName: this.props.setting.displayName || this.props.setting.name,
|
||||
},
|
||||
}),
|
||||
text: element => {
|
||||
const content = (
|
||||
<>
|
||||
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton size="s" onClick={() => window.location.reload()}>
|
||||
{i18n.translate('advancedSettings.field.requiresPageReloadToastButtonLabel', {
|
||||
defaultMessage: 'Reload page',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
||||
ReactDOM.render(content, element);
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
},
|
||||
color: 'success',
|
||||
});
|
||||
if (this.props.clearChange) {
|
||||
this.props.clearChange(this.props.setting.name);
|
||||
}
|
||||
};
|
||||
|
||||
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: string) => val.trim());
|
||||
isSameValue = valueToSave.join(',') === (defVal as string[]).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);
|
||||
}
|
||||
|
||||
this.showPageReloadToast();
|
||||
|
||||
if (changeImage) {
|
||||
this.cancelChangeImage();
|
||||
}
|
||||
} catch (e) {
|
||||
this.props.toasts.addDanger(
|
||||
i18n.translate('advancedSettings.field.saveFieldErrorMessage', {
|
||||
defaultMessage: 'Unable to save {name}',
|
||||
values: { name },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.setLoading(false);
|
||||
};
|
||||
|
||||
resetField = async () => {
|
||||
const { name } = this.props.setting;
|
||||
this.setLoading(true);
|
||||
try {
|
||||
await this.props.clear(name);
|
||||
this.showPageReloadToast();
|
||||
this.cancelChangeImage();
|
||||
this.clearError();
|
||||
} catch (e) {
|
||||
this.props.toasts.addDanger(
|
||||
i18n.translate('advancedSettings.field.resetFieldErrorMessage', {
|
||||
defaultMessage: 'Unable to reset {name}',
|
||||
values: { name },
|
||||
})
|
||||
);
|
||||
}
|
||||
this.setLoading(false);
|
||||
};
|
||||
|
||||
renderField(setting: FieldSetting) {
|
||||
const { enableSaving } = this.props;
|
||||
const { loading, changeImage, unsavedValue } = this.state;
|
||||
const { name, value, type, options, optionLabels = {}, isOverridden, ariaName } = setting;
|
||||
renderField(id: string, setting: FieldSetting) {
|
||||
const { enableSaving, unsavedChanges, loading } = this.props;
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
type,
|
||||
options,
|
||||
optionLabels = {},
|
||||
isOverridden,
|
||||
defVal,
|
||||
ariaName,
|
||||
} = setting;
|
||||
const a11yProps: { [key: string]: string } = unsavedChanges
|
||||
? {
|
||||
'aria-label': ariaName,
|
||||
'aria-describedby': id,
|
||||
}
|
||||
: {
|
||||
'aria-label': ariaName,
|
||||
};
|
||||
const currentValue = unsavedChanges
|
||||
? unsavedChanges.value
|
||||
: getEditableValue(type, value, defVal);
|
||||
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<EuiSwitch
|
||||
label={
|
||||
!!unsavedValue ? (
|
||||
!!currentValue ? (
|
||||
<FormattedMessage id="advancedSettings.field.onLabel" defaultMessage="On" />
|
||||
) : (
|
||||
<FormattedMessage id="advancedSettings.field.offLabel" defaultMessage="Off" />
|
||||
)
|
||||
}
|
||||
checked={!!unsavedValue}
|
||||
checked={!!currentValue}
|
||||
onChange={this.onFieldChangeSwitch}
|
||||
disabled={loading || isOverridden || !enableSaving}
|
||||
onKeyDown={this.onFieldKeyDown}
|
||||
data-test-subj={`advancedSetting-editField-${name}`}
|
||||
aria-label={ariaName}
|
||||
{...a11yProps}
|
||||
/>
|
||||
);
|
||||
case 'markdown':
|
||||
|
@ -458,10 +334,10 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
return (
|
||||
<div data-test-subj={`advancedSetting-editField-${name}`}>
|
||||
<EuiCodeEditor
|
||||
aria-label={ariaName}
|
||||
{...a11yProps}
|
||||
mode={type}
|
||||
theme="textmate"
|
||||
value={unsavedValue}
|
||||
value={currentValue}
|
||||
onChange={this.onCodeEditorChange}
|
||||
width="100%"
|
||||
height="auto"
|
||||
|
@ -476,24 +352,22 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
$blockScrolling: Infinity,
|
||||
}}
|
||||
showGutter={false}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'image':
|
||||
const changeImage = unsavedChanges?.changeImage;
|
||||
if (!isDefaultValue(setting) && !changeImage) {
|
||||
return (
|
||||
<EuiImage aria-label={ariaName} allowFullScreen url={value as string} alt={name} />
|
||||
);
|
||||
return <EuiImage {...a11yProps} allowFullScreen url={value as string} alt={name} />;
|
||||
} else {
|
||||
return (
|
||||
<EuiFilePicker
|
||||
disabled={loading || isOverridden || !enableSaving}
|
||||
onChange={this.onImageChange}
|
||||
accept=".jpg,.jpeg,.png"
|
||||
ref={(input: HTMLInputElement) => {
|
||||
this.changeImageForm = input;
|
||||
}}
|
||||
onKeyDown={this.onFieldEscape}
|
||||
ref={this.changeImageForm}
|
||||
fullWidth
|
||||
data-test-subj={`advancedSetting-editField-${name}`}
|
||||
/>
|
||||
);
|
||||
|
@ -501,8 +375,8 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
case 'select':
|
||||
return (
|
||||
<EuiSelect
|
||||
aria-label={ariaName}
|
||||
value={unsavedValue}
|
||||
{...a11yProps}
|
||||
value={currentValue}
|
||||
options={(options as string[]).map(option => {
|
||||
return {
|
||||
text: optionLabels.hasOwnProperty(option) ? optionLabels[option] : option,
|
||||
|
@ -512,31 +386,31 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
onChange={this.onFieldChangeEvent}
|
||||
isLoading={loading}
|
||||
disabled={loading || isOverridden || !enableSaving}
|
||||
onKeyDown={this.onFieldKeyDown}
|
||||
fullWidth
|
||||
data-test-subj={`advancedSetting-editField-${name}`}
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
return (
|
||||
<EuiFieldNumber
|
||||
aria-label={ariaName}
|
||||
value={unsavedValue}
|
||||
{...a11yProps}
|
||||
value={currentValue}
|
||||
onChange={this.onFieldChangeEvent}
|
||||
isLoading={loading}
|
||||
disabled={loading || isOverridden || !enableSaving}
|
||||
onKeyDown={this.onFieldKeyDown}
|
||||
fullWidth
|
||||
data-test-subj={`advancedSetting-editField-${name}`}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<EuiFieldText
|
||||
aria-label={ariaName}
|
||||
value={unsavedValue}
|
||||
{...a11yProps}
|
||||
value={currentValue}
|
||||
onChange={this.onFieldChangeEvent}
|
||||
isLoading={loading}
|
||||
disabled={loading || isOverridden || !enableSaving}
|
||||
onKeyDown={this.onFieldKeyDown}
|
||||
fullWidth
|
||||
data-test-subj={`advancedSetting-editField-${name}`}
|
||||
/>
|
||||
);
|
||||
|
@ -699,8 +573,12 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
}
|
||||
|
||||
renderResetToDefaultLink(setting: FieldSetting) {
|
||||
const { ariaName, name } = setting;
|
||||
if (isDefaultValue(setting)) {
|
||||
const { defVal, ariaName, name } = setting;
|
||||
if (
|
||||
defVal === this.props.unsavedChanges?.value ||
|
||||
isDefaultValue(setting) ||
|
||||
this.props.loading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
|
@ -726,7 +604,7 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
}
|
||||
|
||||
renderChangeImageLink(setting: FieldSetting) {
|
||||
const { changeImage } = this.state;
|
||||
const changeImage = this.props.unsavedChanges?.changeImage;
|
||||
const { type, value, ariaName, name } = setting;
|
||||
if (type !== 'image' || !value || changeImage) {
|
||||
return;
|
||||
|
@ -752,84 +630,49 @@ export class Field extends PureComponent<FieldProps, FieldState> {
|
|||
);
|
||||
}
|
||||
|
||||
renderActions(setting: FieldSetting) {
|
||||
const { ariaName, name } = setting;
|
||||
const { loading, isInvalid, changeImage, savedValue, unsavedValue } = this.state;
|
||||
const isDisabled = loading || setting.isOverridden;
|
||||
|
||||
if (savedValue === unsavedValue && !changeImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFormRow className="mgtAdvancedSettings__fieldActions" hasEmptyLabelSpace>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
aria-label={i18n.translate('advancedSettings.field.saveButtonAriaLabel', {
|
||||
defaultMessage: 'Save {ariaName}',
|
||||
values: {
|
||||
ariaName,
|
||||
},
|
||||
})}
|
||||
onClick={this.saveEdit}
|
||||
disabled={isDisabled || isInvalid}
|
||||
data-test-subj={`advancedSetting-saveEditField-${name}`}
|
||||
>
|
||||
<FormattedMessage id="advancedSettings.field.saveButtonLabel" defaultMessage="Save" />
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
aria-label={i18n.translate('advancedSettings.field.cancelEditingButtonAriaLabel', {
|
||||
defaultMessage: 'Cancel editing {ariaName}',
|
||||
values: {
|
||||
ariaName,
|
||||
},
|
||||
})}
|
||||
onClick={() => (changeImage ? this.cancelChangeImage() : this.cancelEdit())}
|
||||
disabled={isDisabled}
|
||||
data-test-subj={`advancedSetting-cancelEditField-${name}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="advancedSettings.field.cancelEditingButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { setting } = this.props;
|
||||
const { error, isInvalid } = this.state;
|
||||
const { setting, unsavedChanges } = this.props;
|
||||
const error = unsavedChanges?.error;
|
||||
const isInvalid = unsavedChanges?.isInvalid;
|
||||
|
||||
const className = classNames('mgtAdvancedSettings__field', {
|
||||
'mgtAdvancedSettings__field--unsaved': unsavedChanges,
|
||||
'mgtAdvancedSettings__field--invalid': isInvalid,
|
||||
});
|
||||
const id = setting.name;
|
||||
|
||||
return (
|
||||
<EuiFlexGroup className="mgtAdvancedSettings__field">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiDescribedFormGroup
|
||||
className="mgtAdvancedSettings__fieldWrapper"
|
||||
title={this.renderTitle(setting)}
|
||||
description={this.renderDescription(setting)}
|
||||
>
|
||||
<EuiFormRow
|
||||
isInvalid={isInvalid}
|
||||
error={error}
|
||||
label={this.renderLabel(setting)}
|
||||
helpText={this.renderHelpText(setting)}
|
||||
describedByIds={[`${setting.name}-aria`]}
|
||||
className="mgtAdvancedSettings__fieldRow"
|
||||
hasChildLabel={setting.type !== 'boolean'}
|
||||
>
|
||||
{this.renderField(setting)}
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{this.renderActions(setting)}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiDescribedFormGroup
|
||||
className={className}
|
||||
title={this.renderTitle(setting)}
|
||||
description={this.renderDescription(setting)}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFormRow
|
||||
isInvalid={isInvalid}
|
||||
error={error}
|
||||
label={this.renderLabel(setting)}
|
||||
helpText={this.renderHelpText(setting)}
|
||||
className="mgtAdvancedSettings__fieldRow"
|
||||
hasChildLabel={setting.type !== 'boolean'}
|
||||
fullWidth
|
||||
>
|
||||
<>
|
||||
{this.renderField(id, setting)}
|
||||
{unsavedChanges && (
|
||||
<EuiScreenReaderOnly>
|
||||
<p id={id}>
|
||||
{unsavedChanges.error
|
||||
? unsavedChanges.error
|
||||
: i18n.translate('advancedSettings.field.settingIsUnsaved', {
|
||||
defaultMessage: 'Setting is currently not saved.',
|
||||
})}
|
||||
</p>
|
||||
</EuiScreenReaderOnly>
|
||||
)}
|
||||
</>
|
||||
</EuiFormRow>
|
||||
</EuiDescribedFormGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,4 +17,4 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
export { Field } from './field';
|
||||
export { Field, getEditableValue } from './field';
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,13 @@
|
|||
@import '@elastic/eui/src/components/header/variables';
|
||||
@import '@elastic/eui/src/components/nav_drawer/variables';
|
||||
|
||||
.mgtAdvancedSettingsForm__bottomBar {
|
||||
margin-left: $euiNavDrawerWidthCollapsed;
|
||||
z-index: 9; // Puts it inuder the nav drawer when expanded
|
||||
&--pushForNav {
|
||||
margin-left: $euiNavDrawerWidthExpanded;
|
||||
}
|
||||
@include euiBreakpoint('xs', 's') {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
@import './form';
|
|
@ -18,9 +18,14 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers';
|
||||
import { shallowWithI18nProvider, mountWithI18nProvider } from 'test_utils/enzyme_helpers';
|
||||
import { UiSettingsType } from '../../../../../../core/public';
|
||||
|
||||
// @ts-ignore
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
|
||||
import { notificationServiceMock } from '../../../../../../core/public/mocks';
|
||||
import { SettingsChanges } from '../../types';
|
||||
import { Form } from './form';
|
||||
|
||||
jest.mock('../field', () => ({
|
||||
|
@ -29,6 +34,25 @@ jest.mock('../field', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
const localStorage: Record<string, any> = {
|
||||
'core.chrome.isLocked': true,
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key: string) => {
|
||||
return localStorage[key] || null;
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete (window as any).localStorage;
|
||||
});
|
||||
|
||||
const defaults = {
|
||||
requiresPageReload: false,
|
||||
readOnly: false,
|
||||
|
@ -43,50 +67,52 @@ const defaults = {
|
|||
const settings = {
|
||||
dashboard: [
|
||||
{
|
||||
...defaults,
|
||||
name: 'dashboard:test:setting',
|
||||
ariaName: 'dashboard test setting',
|
||||
displayName: 'Dashboard test setting',
|
||||
category: ['dashboard'],
|
||||
...defaults,
|
||||
requiresPageReload: true,
|
||||
},
|
||||
],
|
||||
general: [
|
||||
{
|
||||
...defaults,
|
||||
name: 'general:test:date',
|
||||
ariaName: 'general test date',
|
||||
displayName: 'Test date',
|
||||
description: 'bar',
|
||||
category: ['general'],
|
||||
...defaults,
|
||||
},
|
||||
{
|
||||
...defaults,
|
||||
name: 'setting:test',
|
||||
ariaName: 'setting test',
|
||||
displayName: 'Test setting',
|
||||
description: 'foo',
|
||||
category: ['general'],
|
||||
...defaults,
|
||||
},
|
||||
],
|
||||
'x-pack': [
|
||||
{
|
||||
...defaults,
|
||||
name: 'xpack:test:setting',
|
||||
ariaName: 'xpack test setting',
|
||||
displayName: 'X-Pack test setting',
|
||||
category: ['x-pack'],
|
||||
description: 'bar',
|
||||
...defaults,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const categories = ['general', 'dashboard', 'hiddenCategory', 'x-pack'];
|
||||
const categoryCounts = {
|
||||
general: 2,
|
||||
dashboard: 1,
|
||||
'x-pack': 10,
|
||||
};
|
||||
const save = (key: string, value: any) => Promise.resolve(true);
|
||||
const clear = (key: string) => Promise.resolve(true);
|
||||
const save = jest.fn((changes: SettingsChanges) => Promise.resolve([true]));
|
||||
|
||||
const clearQuery = () => {};
|
||||
|
||||
describe('Form', () => {
|
||||
|
@ -94,10 +120,10 @@ describe('Form', () => {
|
|||
const component = shallowWithI18nProvider(
|
||||
<Form
|
||||
settings={settings}
|
||||
visibleSettings={settings}
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
save={save}
|
||||
clear={clear}
|
||||
clearQuery={clearQuery}
|
||||
showNoResultsMessage={true}
|
||||
enableSaving={true}
|
||||
|
@ -113,10 +139,10 @@ describe('Form', () => {
|
|||
const component = shallowWithI18nProvider(
|
||||
<Form
|
||||
settings={settings}
|
||||
visibleSettings={settings}
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
save={save}
|
||||
clear={clear}
|
||||
clearQuery={clearQuery}
|
||||
showNoResultsMessage={true}
|
||||
enableSaving={false}
|
||||
|
@ -132,10 +158,10 @@ describe('Form', () => {
|
|||
const component = shallowWithI18nProvider(
|
||||
<Form
|
||||
settings={{}}
|
||||
visibleSettings={settings}
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
save={save}
|
||||
clear={clear}
|
||||
clearQuery={clearQuery}
|
||||
showNoResultsMessage={true}
|
||||
enableSaving={true}
|
||||
|
@ -151,10 +177,10 @@ describe('Form', () => {
|
|||
const component = shallowWithI18nProvider(
|
||||
<Form
|
||||
settings={{}}
|
||||
visibleSettings={settings}
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
save={save}
|
||||
clear={clear}
|
||||
clearQuery={clearQuery}
|
||||
showNoResultsMessage={false}
|
||||
enableSaving={true}
|
||||
|
@ -165,4 +191,70 @@ describe('Form', () => {
|
|||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should hide bottom bar when clicking on the cancel changes button', async () => {
|
||||
const wrapper = mountWithI18nProvider(
|
||||
<Form
|
||||
settings={settings}
|
||||
visibleSettings={settings}
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
save={save}
|
||||
clearQuery={clearQuery}
|
||||
showNoResultsMessage={true}
|
||||
enableSaving={false}
|
||||
toasts={{} as any}
|
||||
dockLinks={{} as any}
|
||||
/>
|
||||
);
|
||||
(wrapper.instance() as Form).setState({
|
||||
unsavedChanges: {
|
||||
'dashboard:test:setting': {
|
||||
value: 'changedValue',
|
||||
},
|
||||
},
|
||||
});
|
||||
const updated = wrapper.update();
|
||||
expect(updated.exists('[data-test-subj="advancedSetting-bottomBar"]')).toEqual(true);
|
||||
await findTestSubject(updated, `advancedSetting-cancelButton`).simulate('click');
|
||||
updated.update();
|
||||
expect(updated.exists('[data-test-subj="advancedSetting-bottomBar"]')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should show a reload toast when saving setting requiring a page reload', async () => {
|
||||
const toasts = notificationServiceMock.createStartContract().toasts;
|
||||
const wrapper = mountWithI18nProvider(
|
||||
<Form
|
||||
settings={settings}
|
||||
visibleSettings={settings}
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
save={save}
|
||||
clearQuery={clearQuery}
|
||||
showNoResultsMessage={true}
|
||||
enableSaving={false}
|
||||
toasts={toasts}
|
||||
dockLinks={{} as any}
|
||||
/>
|
||||
);
|
||||
(wrapper.instance() as Form).setState({
|
||||
unsavedChanges: {
|
||||
'dashboard:test:setting': {
|
||||
value: 'changedValue',
|
||||
},
|
||||
},
|
||||
});
|
||||
const updated = wrapper.update();
|
||||
|
||||
findTestSubject(updated, `advancedSetting-saveButton`).simulate('click');
|
||||
expect(save).toHaveBeenCalled();
|
||||
await save({ 'dashboard:test:setting': 'changedValue' });
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining(
|
||||
'One or more settings require you to reload the page to take effect.'
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import React, { PureComponent, Fragment } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -27,30 +27,188 @@ import {
|
|||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTextColor,
|
||||
EuiBottomBar,
|
||||
EuiButton,
|
||||
EuiToolTip,
|
||||
EuiButtonEmpty,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { toMountPoint } from '../../../../../kibana_react/public';
|
||||
import { DocLinksStart, ToastsStart } from '../../../../../../core/public';
|
||||
|
||||
import { getCategoryName } from '../../lib';
|
||||
import { Field } from '../field';
|
||||
import { FieldSetting } from '../../types';
|
||||
import { Field, getEditableValue } from '../field';
|
||||
import { FieldSetting, SettingsChanges, FieldState } from '../../types';
|
||||
|
||||
type Category = string;
|
||||
const NAV_IS_LOCKED_KEY = 'core.chrome.isLocked';
|
||||
|
||||
interface FormProps {
|
||||
settings: Record<string, FieldSetting[]>;
|
||||
visibleSettings: Record<string, FieldSetting[]>;
|
||||
categories: Category[];
|
||||
categoryCounts: Record<string, number>;
|
||||
clearQuery: () => void;
|
||||
save: (key: string, value: any) => Promise<boolean>;
|
||||
clear: (key: string) => Promise<boolean>;
|
||||
save: (changes: SettingsChanges) => Promise<boolean[]>;
|
||||
showNoResultsMessage: boolean;
|
||||
enableSaving: boolean;
|
||||
dockLinks: DocLinksStart['links'];
|
||||
toasts: ToastsStart;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
unsavedChanges: {
|
||||
[key: string]: FieldState;
|
||||
};
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export class Form extends PureComponent<FormProps> {
|
||||
state: FormState = {
|
||||
unsavedChanges: {},
|
||||
loading: false,
|
||||
};
|
||||
|
||||
setLoading(loading: boolean) {
|
||||
this.setState({
|
||||
loading,
|
||||
});
|
||||
}
|
||||
|
||||
getSettingByKey = (key: string): FieldSetting | undefined => {
|
||||
return Object.values(this.props.settings)
|
||||
.flat()
|
||||
.find(el => el.name === key);
|
||||
};
|
||||
|
||||
getCountOfUnsavedChanges = (): number => {
|
||||
return Object.keys(this.state.unsavedChanges).length;
|
||||
};
|
||||
|
||||
getCountOfHiddenUnsavedChanges = (): number => {
|
||||
const shownSettings = Object.values(this.props.visibleSettings)
|
||||
.flat()
|
||||
.map(setting => setting.name);
|
||||
return Object.keys(this.state.unsavedChanges).filter(key => !shownSettings.includes(key))
|
||||
.length;
|
||||
};
|
||||
|
||||
areChangesInvalid = (): boolean => {
|
||||
const { unsavedChanges } = this.state;
|
||||
return Object.values(unsavedChanges).some(({ isInvalid }) => isInvalid);
|
||||
};
|
||||
|
||||
handleChange = (key: string, change: FieldState) => {
|
||||
const setting = this.getSettingByKey(key);
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
const { type, defVal, value } = setting;
|
||||
const savedValue = getEditableValue(type, value, defVal);
|
||||
if (change.value === savedValue) {
|
||||
return this.clearChange(key);
|
||||
}
|
||||
this.setState({
|
||||
unsavedChanges: {
|
||||
...this.state.unsavedChanges,
|
||||
[key]: change,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
clearChange = (key: string) => {
|
||||
if (!this.state.unsavedChanges[key]) {
|
||||
return;
|
||||
}
|
||||
const unsavedChanges = { ...this.state.unsavedChanges };
|
||||
delete unsavedChanges[key];
|
||||
|
||||
this.setState({
|
||||
unsavedChanges,
|
||||
});
|
||||
};
|
||||
|
||||
clearAllUnsaved = () => {
|
||||
this.setState({ unsavedChanges: {} });
|
||||
};
|
||||
|
||||
saveAll = async () => {
|
||||
this.setLoading(true);
|
||||
const { unsavedChanges } = this.state;
|
||||
|
||||
if (isEmpty(unsavedChanges)) {
|
||||
return;
|
||||
}
|
||||
const configToSave: SettingsChanges = {};
|
||||
let requiresReload = false;
|
||||
|
||||
Object.entries(unsavedChanges).forEach(([name, { value }]) => {
|
||||
const setting = this.getSettingByKey(name);
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
const { defVal, type, requiresPageReload } = setting;
|
||||
let valueToSave = value;
|
||||
let equalsToDefault = false;
|
||||
switch (type) {
|
||||
case 'array':
|
||||
valueToSave = valueToSave.split(',').map((val: string) => val.trim());
|
||||
equalsToDefault = valueToSave.join(',') === (defVal as string[]).join(',');
|
||||
break;
|
||||
case 'json':
|
||||
const isArray = Array.isArray(JSON.parse((defVal as string) || '{}'));
|
||||
valueToSave = valueToSave.trim();
|
||||
valueToSave = valueToSave || (isArray ? '[]' : '{}');
|
||||
default:
|
||||
equalsToDefault = valueToSave === defVal;
|
||||
}
|
||||
if (requiresPageReload) {
|
||||
requiresReload = true;
|
||||
}
|
||||
configToSave[name] = equalsToDefault ? null : valueToSave;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.props.save(configToSave);
|
||||
this.clearAllUnsaved();
|
||||
if (requiresReload) {
|
||||
this.renderPageReloadToast();
|
||||
}
|
||||
} catch (e) {
|
||||
this.props.toasts.addDanger(
|
||||
i18n.translate('advancedSettings.form.saveErrorMessage', {
|
||||
defaultMessage: 'Unable to save',
|
||||
})
|
||||
);
|
||||
}
|
||||
this.setLoading(false);
|
||||
};
|
||||
|
||||
renderPageReloadToast = () => {
|
||||
this.props.toasts.add({
|
||||
title: i18n.translate('advancedSettings.form.requiresPageReloadToastDescription', {
|
||||
defaultMessage: 'One or more settings require you to reload the page to take effect.',
|
||||
}),
|
||||
text: toMountPoint(
|
||||
<>
|
||||
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton size="s" onClick={() => window.location.reload()}>
|
||||
{i18n.translate('advancedSettings.form.requiresPageReloadToastButtonLabel', {
|
||||
defaultMessage: 'Reload page',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
),
|
||||
color: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
renderClearQueryLink(totalSettings: number, currentSettings: number) {
|
||||
const { clearQuery } = this.props;
|
||||
|
||||
|
@ -102,8 +260,9 @@ export class Form extends PureComponent<FormProps> {
|
|||
<Field
|
||||
key={setting.name}
|
||||
setting={setting}
|
||||
save={this.props.save}
|
||||
clear={this.props.clear}
|
||||
handleChange={this.handleChange}
|
||||
unsavedChanges={this.state.unsavedChanges[setting.name]}
|
||||
clearChange={this.clearChange}
|
||||
enableSaving={this.props.enableSaving}
|
||||
dockLinks={this.props.dockLinks}
|
||||
toasts={this.props.toasts}
|
||||
|
@ -141,23 +300,116 @@ export class Form extends PureComponent<FormProps> {
|
|||
return null;
|
||||
}
|
||||
|
||||
renderCountOfUnsaved = () => {
|
||||
const unsavedCount = this.getCountOfUnsavedChanges();
|
||||
const hiddenUnsavedCount = this.getCountOfHiddenUnsavedChanges();
|
||||
return (
|
||||
<EuiTextColor className="mgtAdvancedSettingsForm__unsavedCountMessage" color="ghost">
|
||||
<FormattedMessage
|
||||
id="advancedSettings.form.countOfSettingsChanged"
|
||||
defaultMessage="{unsavedCount} unsaved {unsavedCount, plural,
|
||||
one {setting}
|
||||
other {settings}
|
||||
}{hiddenCount, plural,
|
||||
=0 {}
|
||||
other {, # hidden}
|
||||
}"
|
||||
values={{
|
||||
unsavedCount,
|
||||
hiddenCount: hiddenUnsavedCount,
|
||||
}}
|
||||
/>
|
||||
</EuiTextColor>
|
||||
);
|
||||
};
|
||||
|
||||
renderBottomBar = () => {
|
||||
const areChangesInvalid = this.areChangesInvalid();
|
||||
const bottomBarClasses = classNames('mgtAdvancedSettingsForm__bottomBar', {
|
||||
'mgtAdvancedSettingsForm__bottomBar--pushForNav':
|
||||
localStorage.getItem(NAV_IS_LOCKED_KEY) === 'true',
|
||||
});
|
||||
return (
|
||||
<EuiBottomBar className={bottomBarClasses} data-test-subj="advancedSetting-bottomBar">
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false} className="mgtAdvancedSettingsForm__unsavedCount">
|
||||
<p id="aria-describedby.countOfUnsavedSettings">{this.renderCountOfUnsaved()}</p>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
color="ghost"
|
||||
size="s"
|
||||
iconType="cross"
|
||||
onClick={this.clearAllUnsaved}
|
||||
aria-describedby="aria-describedby.countOfUnsavedSettings"
|
||||
data-test-subj="advancedSetting-cancelButton"
|
||||
>
|
||||
{i18n.translate('advancedSettings.form.cancelButtonLabel', {
|
||||
defaultMessage: 'Cancel changes',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
content={
|
||||
areChangesInvalid &&
|
||||
i18n.translate('advancedSettings.form.saveButtonTooltipWithInvalidChanges', {
|
||||
defaultMessage: 'Fix invalid settings before saving.',
|
||||
})
|
||||
}
|
||||
>
|
||||
<EuiButton
|
||||
className="mgtAdvancedSettingsForm__button"
|
||||
disabled={areChangesInvalid}
|
||||
color="secondary"
|
||||
fill
|
||||
size="s"
|
||||
iconType="check"
|
||||
onClick={this.saveAll}
|
||||
aria-describedby="aria-describedby.countOfUnsavedSettings"
|
||||
isLoading={this.state.loading}
|
||||
data-test-subj="advancedSetting-saveButton"
|
||||
>
|
||||
{i18n.translate('advancedSettings.form.saveButtonLabel', {
|
||||
defaultMessage: 'Save changes',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiBottomBar>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { settings, categories, categoryCounts, clearQuery } = this.props;
|
||||
const { unsavedChanges } = this.state;
|
||||
const { visibleSettings, categories, categoryCounts, clearQuery } = this.props;
|
||||
const currentCategories: Category[] = [];
|
||||
|
||||
categories.forEach(category => {
|
||||
if (settings[category] && settings[category].length) {
|
||||
if (visibleSettings[category] && visibleSettings[category].length) {
|
||||
currentCategories.push(category);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{currentCategories.length
|
||||
? currentCategories.map(category => {
|
||||
return this.renderCategory(category, settings[category], categoryCounts[category]);
|
||||
})
|
||||
: this.maybeRenderNoSettings(clearQuery)}
|
||||
<div>
|
||||
{currentCategories.length
|
||||
? currentCategories.map(category => {
|
||||
return this.renderCategory(
|
||||
category,
|
||||
visibleSettings[category],
|
||||
categoryCounts[category]
|
||||
);
|
||||
})
|
||||
: this.maybeRenderNoSettings(clearQuery)}
|
||||
</div>
|
||||
{!isEmpty(unsavedChanges) && this.renderBottomBar()}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,6 +47,19 @@ export interface FieldSetting {
|
|||
}
|
||||
|
||||
// until eui searchbar and query are typed
|
||||
|
||||
export interface SettingsChanges {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface FieldState {
|
||||
value?: any;
|
||||
changeImage?: boolean;
|
||||
loading?: boolean;
|
||||
isInvalid?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface IQuery {
|
||||
ast: any; // incomplete
|
||||
text: string;
|
||||
|
|
|
@ -33,8 +33,8 @@ import { FormattedMessage } from '@kbn/i18n/react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { PRIVACY_STATEMENT_URL } from '../../common/constants';
|
||||
import { OptInExampleFlyout } from './opt_in_example_flyout';
|
||||
// @ts-ignore
|
||||
import { Field } from '../../../advanced_settings/public';
|
||||
import { ToastsStart } from '../../../../core/public/';
|
||||
import { TelemetryService } from '../services/telemetry_service';
|
||||
const SEARCH_TERMS = ['telemetry', 'usage', 'data', 'usage data'];
|
||||
|
||||
|
@ -44,12 +44,14 @@ interface Props {
|
|||
showAppliesSettingMessage: boolean;
|
||||
enableSaving: boolean;
|
||||
query?: any;
|
||||
toasts: ToastsStart;
|
||||
}
|
||||
|
||||
interface State {
|
||||
processing: boolean;
|
||||
showExample: boolean;
|
||||
queryMatches: boolean | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class TelemetryManagementSection extends Component<Props, State> {
|
||||
|
@ -57,6 +59,7 @@ export class TelemetryManagementSection extends Component<Props, State> {
|
|||
processing: false,
|
||||
showExample: false,
|
||||
queryMatches: null,
|
||||
enabled: this.props.telemetryService.getIsOptedIn() || false,
|
||||
};
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: Props) {
|
||||
|
@ -79,7 +82,7 @@ export class TelemetryManagementSection extends Component<Props, State> {
|
|||
|
||||
render() {
|
||||
const { telemetryService } = this.props;
|
||||
const { showExample, queryMatches } = this.state;
|
||||
const { showExample, queryMatches, enabled, processing } = this.state;
|
||||
|
||||
if (!telemetryService.getCanChangeOptInStatus()) {
|
||||
return null;
|
||||
|
@ -119,7 +122,7 @@ export class TelemetryManagementSection extends Component<Props, State> {
|
|||
displayName: i18n.translate('telemetry.provideUsageStatisticsTitle', {
|
||||
defaultMessage: 'Provide usage statistics',
|
||||
}),
|
||||
value: telemetryService.getIsOptedIn(),
|
||||
value: enabled,
|
||||
description: this.renderDescription(),
|
||||
defVal: true,
|
||||
ariaName: i18n.translate('telemetry.provideUsageStatisticsAriaName', {
|
||||
|
@ -127,10 +130,10 @@ export class TelemetryManagementSection extends Component<Props, State> {
|
|||
}),
|
||||
} as any
|
||||
}
|
||||
loading={processing}
|
||||
dockLinks={null as any}
|
||||
toasts={null as any}
|
||||
save={this.toggleOptIn}
|
||||
clear={this.toggleOptIn}
|
||||
handleChange={this.toggleOptIn}
|
||||
enableSaving={this.props.enableSaving}
|
||||
/>
|
||||
</EuiForm>
|
||||
|
@ -151,13 +154,13 @@ export class TelemetryManagementSection extends Component<Props, State> {
|
|||
<p>
|
||||
<FormattedMessage
|
||||
id="telemetry.callout.appliesSettingTitle"
|
||||
defaultMessage="This setting applies to {allOfKibanaText}"
|
||||
defaultMessage="Changes to this setting apply to {allOfKibanaText} and are saved automatically."
|
||||
values={{
|
||||
allOfKibanaText: (
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="telemetry.callout.appliesSettingTitle.allOfKibanaText"
|
||||
defaultMessage="all of Kibana."
|
||||
defaultMessage="all of Kibana"
|
||||
/>
|
||||
</strong>
|
||||
),
|
||||
|
@ -200,20 +203,35 @@ export class TelemetryManagementSection extends Component<Props, State> {
|
|||
);
|
||||
|
||||
toggleOptIn = async (): Promise<boolean> => {
|
||||
const { telemetryService } = this.props;
|
||||
const newOptInValue = !telemetryService.getIsOptedIn();
|
||||
const { telemetryService, toasts } = this.props;
|
||||
const newOptInValue = !this.state.enabled;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.setState({ processing: true }, async () => {
|
||||
try {
|
||||
await telemetryService.setOptIn(newOptInValue);
|
||||
this.setState({ processing: false });
|
||||
resolve(true);
|
||||
} catch (err) {
|
||||
this.setState({ processing: false });
|
||||
reject(err);
|
||||
this.setState(
|
||||
{
|
||||
processing: true,
|
||||
enabled: newOptInValue,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
await telemetryService.setOptIn(newOptInValue);
|
||||
this.setState({ processing: false });
|
||||
toasts.addSuccess(
|
||||
newOptInValue
|
||||
? i18n.translate('telemetry.optInSuccessOn', {
|
||||
defaultMessage: 'Usage data collection turned on.',
|
||||
})
|
||||
: i18n.translate('telemetry.optInSuccessOff', {
|
||||
defaultMessage: 'Usage data collection turned off.',
|
||||
})
|
||||
);
|
||||
resolve(true);
|
||||
} catch (err) {
|
||||
this.setState({ processing: false });
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -94,7 +94,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
`[data-test-subj="advancedSetting-editField-${propertyName}"] option[value="${propertyValue}"]`
|
||||
);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await testSubjects.click(`advancedSetting-saveEditField-${propertyName}`);
|
||||
await testSubjects.click(`advancedSetting-saveButton`);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
|
@ -102,14 +102,14 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider
|
|||
const input = await testSubjects.find(`advancedSetting-editField-${propertyName}`);
|
||||
await input.clearValue();
|
||||
await input.type(propertyValue);
|
||||
await testSubjects.click(`advancedSetting-saveEditField-${propertyName}`);
|
||||
await testSubjects.click(`advancedSetting-saveButton`);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
async toggleAdvancedSettingCheckbox(propertyName: string) {
|
||||
testSubjects.click(`advancedSetting-editField-${propertyName}`);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await testSubjects.click(`advancedSetting-saveEditField-${propertyName}`);
|
||||
await testSubjects.click(`advancedSetting-saveButton`);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
}
|
||||
|
||||
|
|
|
@ -1616,8 +1616,6 @@
|
|||
"advancedSettings.categoryNames.timelionLabel": "Timelion",
|
||||
"advancedSettings.categoryNames.visualizationsLabel": "ビジュアライゼーション",
|
||||
"advancedSettings.categorySearchLabel": "カテゴリー",
|
||||
"advancedSettings.field.cancelEditingButtonAriaLabel": "{ariaName} の編集をキャンセル",
|
||||
"advancedSettings.field.cancelEditingButtonLabel": "キャンセル",
|
||||
"advancedSettings.field.changeImageLinkAriaLabel": "{ariaName} を変更",
|
||||
"advancedSettings.field.changeImageLinkText": "画像を変更",
|
||||
"advancedSettings.field.codeEditorSyntaxErrorMessage": "無効な JSON 構文",
|
||||
|
@ -1630,17 +1628,10 @@
|
|||
"advancedSettings.field.imageTooLargeErrorMessage": "画像が大きすぎます。最大サイズは {maxSizeDescription} です",
|
||||
"advancedSettings.field.offLabel": "オフ",
|
||||
"advancedSettings.field.onLabel": "オン",
|
||||
"advancedSettings.field.requiresPageReloadToastButtonLabel": "ページを再読み込み",
|
||||
"advancedSettings.field.requiresPageReloadToastDescription": "「{settingName}」設定を有効にするには、ページを再読み込みしてください。",
|
||||
"advancedSettings.field.resetFieldErrorMessage": "{name} をリセットできませんでした",
|
||||
"advancedSettings.field.resetToDefaultLinkAriaLabel": "{ariaName} をデフォルトにリセット",
|
||||
"advancedSettings.field.resetToDefaultLinkText": "デフォルトにリセット",
|
||||
"advancedSettings.field.saveButtonAriaLabel": "{ariaName} を保存",
|
||||
"advancedSettings.field.saveButtonLabel": "保存",
|
||||
"advancedSettings.field.saveFieldErrorMessage": "{name} を保存できませんでした",
|
||||
"advancedSettings.form.clearNoSearchResultText": "(検索結果を消去)",
|
||||
"advancedSettings.form.clearSearchResultText": "(検索結果を消去)",
|
||||
"advancedSettings.form.noSearchResultText": "設定が見つかりませんでした {clearSearch}",
|
||||
"advancedSettings.form.searchResultText": "検索用語により {settingsCount} 件の設定が非表示になっています {clearSearch}",
|
||||
"advancedSettings.pageTitle": "設定",
|
||||
"advancedSettings.searchBar.unableToParseQueryErrorMessage": "クエリをパースできません",
|
||||
|
@ -2472,8 +2463,6 @@
|
|||
"statusPage.statusApp.statusTitle": "プラグインステータス",
|
||||
"statusPage.statusTable.columns.idHeader": "ID",
|
||||
"statusPage.statusTable.columns.statusHeader": "ステータス",
|
||||
"telemetry.callout.appliesSettingTitle": "この設定は {allOfKibanaText} に適用されます",
|
||||
"telemetry.callout.appliesSettingTitle.allOfKibanaText": "Kibana のすべて",
|
||||
"telemetry.callout.clusterStatisticsDescription": "これは収集される基本的なクラスター統計の例です。インデックス、シャード、ノードの数が含まれます。監視がオンになっているかどうかなどのハイレベルの使用統計も含まれます。",
|
||||
"telemetry.callout.clusterStatisticsTitle": "クラスター統計",
|
||||
"telemetry.callout.errorLoadingClusterStatisticsDescription": "クラスター統計の取得中に予期せぬエラーが発生しました。Elasticsearch、Kibana、またはネットワークのエラーが原因の可能性があります。Kibana を確認し、ページを再読み込みして再試行してください。",
|
||||
|
|
|
@ -1616,8 +1616,6 @@
|
|||
"advancedSettings.categoryNames.timelionLabel": "Timelion",
|
||||
"advancedSettings.categoryNames.visualizationsLabel": "可视化",
|
||||
"advancedSettings.categorySearchLabel": "类别",
|
||||
"advancedSettings.field.cancelEditingButtonAriaLabel": "取消编辑 {ariaName}",
|
||||
"advancedSettings.field.cancelEditingButtonLabel": "取消",
|
||||
"advancedSettings.field.changeImageLinkAriaLabel": "更改 {ariaName}",
|
||||
"advancedSettings.field.changeImageLinkText": "更改图片",
|
||||
"advancedSettings.field.codeEditorSyntaxErrorMessage": "JSON 语法无效",
|
||||
|
@ -1630,14 +1628,8 @@
|
|||
"advancedSettings.field.imageTooLargeErrorMessage": "图像过大,最大大小为 {maxSizeDescription}",
|
||||
"advancedSettings.field.offLabel": "关闭",
|
||||
"advancedSettings.field.onLabel": "开启",
|
||||
"advancedSettings.field.requiresPageReloadToastButtonLabel": "重新加载页面",
|
||||
"advancedSettings.field.requiresPageReloadToastDescription": "请重新加载页面,以使“{settingName}”设置生效。",
|
||||
"advancedSettings.field.resetFieldErrorMessage": "无法重置 {name}",
|
||||
"advancedSettings.field.resetToDefaultLinkAriaLabel": "将 {ariaName} 重置为默认值",
|
||||
"advancedSettings.field.resetToDefaultLinkText": "重置为默认值",
|
||||
"advancedSettings.field.saveButtonAriaLabel": "保存 {ariaName}",
|
||||
"advancedSettings.field.saveButtonLabel": "保存",
|
||||
"advancedSettings.field.saveFieldErrorMessage": "无法保存 {name}",
|
||||
"advancedSettings.form.clearNoSearchResultText": "(清除搜索)",
|
||||
"advancedSettings.form.clearSearchResultText": "(清除搜索)",
|
||||
"advancedSettings.form.noSearchResultText": "未找到设置{clearSearch}",
|
||||
|
@ -2472,8 +2464,6 @@
|
|||
"statusPage.statusApp.statusTitle": "插件状态",
|
||||
"statusPage.statusTable.columns.idHeader": "ID",
|
||||
"statusPage.statusTable.columns.statusHeader": "状态",
|
||||
"telemetry.callout.appliesSettingTitle": "此设置适用于{allOfKibanaText}",
|
||||
"telemetry.callout.appliesSettingTitle.allOfKibanaText": "所有 Kibana。",
|
||||
"telemetry.callout.clusterStatisticsDescription": "这是我们将收集的基本集群统计信息的示例。其包括索引、分片和节点的数目。还包括概括性的使用情况统计信息,例如监测是否打开。",
|
||||
"telemetry.callout.clusterStatisticsTitle": "集群统计信息",
|
||||
"telemetry.callout.errorLoadingClusterStatisticsDescription": "尝试提取集群统计信息时发生意外错误。发生此问题的原因可能是 Elasticsearch 出故障、Kibana 出故障或者有网络错误。检查 Kibana,然后重新加载页面并重试。",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue