[7.x] Advanced settings UI change to centralize save state (#5… (#58373)

This commit is contained in:
Marta Bondyra 2020-02-25 13:22:35 +01:00 committed by GitHub
parent 328333d912
commit c3109b59da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 5161 additions and 4664 deletions

View file

@ -17,4 +17,4 @@
* under the License.
*/
@import './management_app/advanced_settings';
@import './management_app/index';

View file

@ -0,0 +1,3 @@
@import './advanced_settings';
@import './components/index';

View file

@ -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%;
}

View file

@ -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}

View file

@ -0,0 +1 @@
@import './form/index';

View file

@ -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'),
})
);
});
});

View file

@ -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>
);
}
}

View file

@ -17,4 +17,4 @@
* under the License.
*/
export { Field } from './field';
export { Field, getEditableValue } from './field';

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
@import './form';

View file

@ -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.'
),
})
);
});
});

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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);
}
}
});
);
});
};

View file

@ -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();
}

View file

@ -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 を確認し、ページを再読み込みして再試行してください。",

View file

@ -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然后重新加载页面并重试。",