From 9314942d68a2ffa253bb890eb63130a156e9ce23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Tue, 8 Jan 2019 19:25:12 +0100 Subject: [PATCH 01/21] [CCR] Advanced settings component --- .../app/components/advanced_settings_form.js | 254 ++++++++++++++++++ .../app/components/follower_index_form.js | 62 +++++ .../public/app/components/index.js | 1 + 3 files changed, 317 insertions(+) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js new file mode 100644 index 0000000000000..7663a82104e66 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiTitle, + EuiSpacer, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiPanel, + EuiDescribedFormGroup, + EuiFormRow, + EuiButtonIcon, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const validateField = (/* field */) => null; + +/** + * State transitions: fields update + */ +export const updateFields = (newValues) => ({ fields }) => ({ + fields: { + ...fields, + ...newValues, + }, +}); + +/** + * State transitions: add setting field to form and errors + */ +export const addSetting = (setting) => ({ fields, fieldsErrors }) => ({ + fields: { + ...fields, + [setting]: '', + }, + fieldsErrors: { + ...fieldsErrors, + [setting]: validateField(setting) + }, + previewSettingActive: null +}); + +/** + * State transitions: remove setting from fields and errors + */ +export const removeSetting = (setting) => ({ fields, fieldsErrors }) => { + const { [setting]: value, ...fieldsWithoutSetting } = fields; // eslint-disable-line no-unused-vars + const { [setting]: value2, ...fieldsErrorsWithoutSetting } = fieldsErrors; // eslint-disable-line no-unused-vars + return { + fields: fieldsWithoutSetting, + fieldsErrors: fieldsErrorsWithoutSetting, + }; +}; + +export class AdvancedSettingsForm extends PureComponent { + static propTypes = { + areErrorsVisible: PropTypes.bool.isRequired, + schema: PropTypes.object.isRequired + } + + state = { + isOpened: false, + fields: {}, + fieldsErrors: {}, + previewSettingActive: null, + }; + + toggle = () => { + this.setState(({ isOpened }) => ({ isOpened: !isOpened })); + } + + selectSetting = (setting) => { + this.setState(addSetting(setting)); + } + + unSelectSetting = (setting) => { + this.setState(removeSetting(setting)); + } + + getSettingSelection = (checkIsSelected = true) => (setting) => checkIsSelected + ? typeof this.state.fields[setting] !== 'undefined' + : typeof this.state.fields[setting] === 'undefined' + + setPreviewSettingActive = (previewSettingActive) => { + this.setState({ previewSettingActive }); + } + + onFieldChange = (fields) => { + this.setState(updateFields(fields)); + } + + renderRowSelectedSetting = (field, value, fieldSchema, areErrorsVisible, fieldErrors) => { + const hasError = !!fieldErrors; + const isInvalid = hasError && (fieldErrors.alwaysVisible || areErrorsVisible); + + return ( + + + +

{fieldSchema.label}

+
+
+ + this.unSelectSetting(field)} + iconType="minusInCircle" + aria-label="Remove setting" + /> + + + )} + description={fieldSchema.description} + fullWidth + key={field} + > + + this.onFieldChange({ [field]: e.target.value })} + fullWidth + /> + +
+ ); + } + + renderSelectedSettings = () => { + const { fields, fieldsErrors } = this.state; + const { areErrorsVisible, schema } = this.props; + return Object.keys(fields).map((field) => ( + this.renderRowSelectedSetting(field, fields[field], schema[field], areErrorsVisible, fieldsErrors[field]) + )); + } + + renderSettings = () => { + const { schema } = this.props; + const { previewSettingActive } = this.state; + + return ( + + + + + { Object.keys(schema) + .filter(this.getSettingSelection(false)) + .map((field, i, arr) => { + const fieldSchema = schema[field]; + // const isSelected = this.isSettingSelected(field); + + return ( + + + + this.selectSetting(field)} + iconType="plusInCircle" + aria-label="Add setting" + /> + {/* isSelected ? this.unSelectSetting(field) : this.selectSetting(field)} + iconType={isSelected ? 'minusInCircle' : 'plusInCircle'} + aria-label={isSelected ? 'Remove setting' : 'Add setting'} + /> */} + + + this.setPreviewSettingActive(field)} + onMouseEnter={() => this.setPreviewSettingActive(field)} + > + {fieldSchema.label} + + + + {i < arr.length - 1 && } + + ); + }) } + + + {previewSettingActive && ( + + +

{schema[previewSettingActive].label}

+
+ + {schema[previewSettingActive].description} + +
+ )} +
+
+
+
+ ); + } + + render() { + const { isOpened } = this.state; + return ( + + {this.renderSelectedSettings()} + + + {!isOpened && ( + + + + )} + {isOpened && ( + + + + )} + + + {isOpened && this.renderSettings()} + + ); + } +} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js index c06856f730695..a2269e28c2da8 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js @@ -33,6 +33,7 @@ import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import routing from '../services/routing'; import { API_STATUS } from '../constants'; import { SectionError } from './section_error'; +import { AdvancedSettingsForm } from './advanced_settings_form'; import { validateFollowerIndex } from '../services/follower_index_validators'; import { loadIndices } from '../services/api'; @@ -73,6 +74,61 @@ export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ } }); +/* eslint-disable */ +const schemaAdvancedFields = { + maxReadRequestOperationCount: { + label: 'Max read request operation count', + description: 'The maximum number of operations to pull per read from the remote cluster.', + validate: {} + }, + maxOutstandingReadRequests: { + label: 'Max outstanding read requests', + description: 'The maximum number of outstanding reads requests from the remote cluster.', + validate: {} + }, + maxReadRequestSize: { + label: 'Max read request size', + description: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster (bye value).', + validate: {} + }, + maxWriteRequestOperationCount: { + label: 'Max write request operation count', + description: 'The maximum number of operations per bulk write request executed on the follower.', + validate: {} + }, + maxWriteRequestSize: { + label: 'Max write request size', + description: 'The maximum total bytes of operations per bulk write request executed on the follower.', + validate: {} + }, + maxOutstandingWriteRequests: { + label: 'Max outstanding write requests', + description: 'The maximum number of outstanding write requests on the follower.', + validate: {} + }, + maxWriteBufferCount: { + label: 'Max write buffer count', + description: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.', + validate: {} + }, + maxWriteBufferSize: { + label: 'Max write buffer size', + description: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.', + validate: {} + }, + maxRetryDelay: { + label: 'Max retry delay', + description: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.', + validate: {} + }, + readPollTimeout: { + label: 'Read poll timeout', + description: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.', + validate: {} + }, +}; +/* eslint-enable */ + export const FollowerIndexForm = injectI18n( class extends PureComponent { static propTypes = { @@ -485,6 +541,12 @@ export const FollowerIndexForm = injectI18n( {renderRemoteClusterField()} {renderLeaderIndex()} + + + {renderFormErrorWarning()} {renderActions()} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js index 1a5d4a9d1eb3f..0c78710387bdd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js @@ -17,3 +17,4 @@ export { FollowerIndexResumeProvider } from './follower_index_resume_provider'; export { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provider'; export { FollowerIndexForm } from './follower_index_form'; export { FollowerIndexPageTitle } from './follower_index_page_title'; +export { AdvancedSettingsForm } from './advanced_settings_form'; From ce8a00fce6fe13f706de833fa54a6ed353ade9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Tue, 8 Jan 2019 19:36:26 +0100 Subject: [PATCH 02/21] Remove preview active on toggle settings --- .../public/app/components/advanced_settings_form.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js index 7663a82104e66..8b62ea26039ad 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js @@ -75,7 +75,7 @@ export class AdvancedSettingsForm extends PureComponent { }; toggle = () => { - this.setState(({ isOpened }) => ({ isOpened: !isOpened })); + this.setState(({ isOpened }) => ({ isOpened: !isOpened, previewSettingActive: null })); } selectSetting = (setting) => { @@ -163,7 +163,6 @@ export class AdvancedSettingsForm extends PureComponent { .filter(this.getSettingSelection(false)) .map((field, i, arr) => { const fieldSchema = schema[field]; - // const isSelected = this.isSettingSelected(field); return ( @@ -175,12 +174,6 @@ export class AdvancedSettingsForm extends PureComponent { iconType="plusInCircle" aria-label="Add setting" /> - {/* isSelected ? this.unSelectSetting(field) : this.selectSetting(field)} - iconType={isSelected ? 'minusInCircle' : 'plusInCircle'} - aria-label={isSelected ? 'Remove setting' : 'Add setting'} - /> */} Date: Wed, 9 Jan 2019 14:35:48 +0100 Subject: [PATCH 03/21] Add client side validation of advanced settings form --- .../app/components/advanced_settings_form.js | 109 ++++++++++++++---- .../app/components/follower_index_form.js | 28 +++-- .../public/app/services/input_validation.js | 24 ++++ 3 files changed, 127 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js index 8b62ea26039ad..6cccf862ecf4d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js @@ -21,8 +21,20 @@ import { EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { debounce } from 'lodash'; -const validateField = (/* field */) => null; +import { getValidator, i18nValidationErrorMessages } from '../services/input_validation'; + +const parseError = (err) => { + if (!err) { + return null; + } + + const [error] = err.details; // Use the first error in the details array (error.details[0]) + const { type, context: { label } } = error; + const message = i18nValidationErrorMessages[type](label); + return { message }; +}; /** * State transitions: fields update @@ -35,26 +47,48 @@ export const updateFields = (newValues) => ({ fields }) => ({ }); /** - * State transitions: add setting field to form and errors + * State transitions: errors update */ -export const addSetting = (setting) => ({ fields, fieldsErrors }) => ({ - fields: { - ...fields, - [setting]: '', - }, - fieldsErrors: { +export const updateFormErrors = (errors, onFormValidityUpdate = () => undefined) => ({ fieldsErrors }) => { + const updatedFieldsErrors = { ...fieldsErrors, - [setting]: validateField(setting) - }, - previewSettingActive: null -}); + ...errors, + }; + + const isFormValid = Object.values(updatedFieldsErrors).every(error => error === null); + onFormValidityUpdate(isFormValid); + + return { fieldsErrors: updatedFieldsErrors }; +}; + +/** + * State transitions: add setting field to form and errors + */ +export const addField = (field, validator, onFormValidityUpdate) => ({ fields, fieldsErrors }) => { + const fieldValue = ''; + const { error } = validator.validate({ [field]: fieldValue }); + const updatedFieldsErrors = updateFormErrors({ [field]: parseError(error) }, onFormValidityUpdate)({ fieldsErrors }); + + return ({ + fields: { + ...fields, + [field]: fieldValue, + }, + ...updatedFieldsErrors, + previewSettingActive: null + }); +}; /** * State transitions: remove setting from fields and errors */ -export const removeSetting = (setting) => ({ fields, fieldsErrors }) => { - const { [setting]: value, ...fieldsWithoutSetting } = fields; // eslint-disable-line no-unused-vars - const { [setting]: value2, ...fieldsErrorsWithoutSetting } = fieldsErrors; // eslint-disable-line no-unused-vars +export const removeField = (field, onFormValidityUpdate = () => undefined) => ({ fields, fieldsErrors }) => { + const { [field]: value, ...fieldsWithoutSetting } = fields; // eslint-disable-line no-unused-vars + const { [field]: value2, ...fieldsErrorsWithoutSetting } = fieldsErrors; // eslint-disable-line no-unused-vars + + const isFormValid = Object.values(fieldsErrorsWithoutSetting).every(error => error === null); + onFormValidityUpdate(isFormValid); + return { fields: fieldsWithoutSetting, fieldsErrors: fieldsErrorsWithoutSetting, @@ -63,6 +97,7 @@ export const removeSetting = (setting) => ({ fields, fieldsErrors }) => { export class AdvancedSettingsForm extends PureComponent { static propTypes = { + onFormValidityUpdate: PropTypes.func.isRequired, areErrorsVisible: PropTypes.bool.isRequired, schema: PropTypes.object.isRequired } @@ -74,28 +109,52 @@ export class AdvancedSettingsForm extends PureComponent { previewSettingActive: null, }; + constructor(props) { + super(props); + + this.validateFields = debounce(this.validateFields.bind(this), 500); + + this.validator = getValidator(Object.entries(this.props.schema).reduce((acc, [field, schema]) => ({ + ...acc, + [field]: schema.validate.label(schema.label) + }), {})); + } + toggle = () => { this.setState(({ isOpened }) => ({ isOpened: !isOpened, previewSettingActive: null })); } selectSetting = (setting) => { - this.setState(addSetting(setting)); + this.setState(addField(setting, this.validator, this.props.onFormValidityUpdate)); } unSelectSetting = (setting) => { - this.setState(removeSetting(setting)); + this.setState(removeField(setting, this.props.onFormValidityUpdate)); } - getSettingSelection = (checkIsSelected = true) => (setting) => checkIsSelected - ? typeof this.state.fields[setting] !== 'undefined' - : typeof this.state.fields[setting] === 'undefined' + isSettingSelected = setting => typeof this.state.fields[setting] !== 'undefined' setPreviewSettingActive = (previewSettingActive) => { this.setState({ previewSettingActive }); } + validateFields = (fields) => { + const { onFormValidityUpdate } = this.props; + const errors = {}; + + let error; + Object.entries(fields).forEach(([field, value]) => { + ({ error } = this.validator.validate({ [field]: value })); + + errors[field] = parseError(error); + }); + + this.setState(updateFormErrors(errors, onFormValidityUpdate)); + } + onFieldChange = (fields) => { this.setState(updateFields(fields)); + this.validateFields(fields); } renderRowSelectedSetting = (field, value, fieldSchema, areErrorsVisible, fieldErrors) => { @@ -160,13 +219,18 @@ export class AdvancedSettingsForm extends PureComponent { { Object.keys(schema) - .filter(this.getSettingSelection(false)) + .filter((setting) => !this.isSettingSelected(setting)) .map((field, i, arr) => { const fieldSchema = schema[field]; return ( - + this.setPreviewSettingActive(field)} + onMouseLeave={() => this.setPreviewSettingActive(null)} + > this.setPreviewSettingActive(field)} - onMouseEnter={() => this.setPreviewSettingActive(field)} > {fieldSchema.label} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js index a2269e28c2da8..51ac35125e803 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js @@ -28,6 +28,8 @@ import { EuiSuperSelect, } from '@elastic/eui'; +import Joi from 'joi'; + import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import routing from '../services/routing'; @@ -79,52 +81,52 @@ const schemaAdvancedFields = { maxReadRequestOperationCount: { label: 'Max read request operation count', description: 'The maximum number of operations to pull per read from the remote cluster.', - validate: {} + validate: Joi.number(), }, maxOutstandingReadRequests: { label: 'Max outstanding read requests', description: 'The maximum number of outstanding reads requests from the remote cluster.', - validate: {} + validate: Joi.number(), }, maxReadRequestSize: { label: 'Max read request size', description: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster (bye value).', - validate: {} + validate: Joi.number(), }, maxWriteRequestOperationCount: { label: 'Max write request operation count', description: 'The maximum number of operations per bulk write request executed on the follower.', - validate: {} + validate: Joi.number(), }, maxWriteRequestSize: { label: 'Max write request size', description: 'The maximum total bytes of operations per bulk write request executed on the follower.', - validate: {} + validate: Joi.number(), }, maxOutstandingWriteRequests: { label: 'Max outstanding write requests', description: 'The maximum number of outstanding write requests on the follower.', - validate: {} + validate: Joi.number(), }, maxWriteBufferCount: { label: 'Max write buffer count', description: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.', - validate: {} + validate: Joi.number(), }, maxWriteBufferSize: { label: 'Max write buffer size', description: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.', - validate: {} + validate: Joi.number(), }, maxRetryDelay: { label: 'Max retry delay', description: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.', - validate: {} + validate: Joi.number(), }, readPollTimeout: { label: 'Read poll timeout', description: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.', - validate: {} + validate: Joi.number(), }, }; /* eslint-enable */ @@ -154,6 +156,7 @@ export const FollowerIndexForm = injectI18n( this.state = { followerIndex, fieldsErrors: validateFollowerIndex(followerIndex), + advancedSettingsFormValid: true, areErrorsVisible: false, isNew, }; @@ -175,12 +178,14 @@ export const FollowerIndexForm = injectI18n( this.onFieldsChange({ remoteCluster }); }; + updateAdvancedSettingsFormValidity = (isValid) => this.setState({ advancedSettingsFormValid: isValid }) + getFields = () => { return this.state.followerIndex; }; isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === null); + return Object.values(this.state.fieldsErrors).every(error => error === null) && this.state.advancedSettingsFormValid; } sendForm = () => { @@ -545,6 +550,7 @@ export const FollowerIndexForm = injectI18n( {renderFormErrorWarning()} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js new file mode 100644 index 0000000000000..10df97fafcf07 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import Joi from 'joi'; +import { i18n } from '@kbn/i18n'; + +export const i18nValidationErrorMessages = { + 'number.base': field => ( + i18n.translate('xpack.formInputValidation.notNumberError', { + defaultMessage: '{field} must be a number.', + values: { field } + }) + ) +}; + +export const getValidator = (validators = {}) => { + return Joi.object().keys({ + ...validators + }); +}; From 2785e1943cfee1b3a104da3e7682990d14f9f5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Wed, 9 Jan 2019 15:26:07 +0100 Subject: [PATCH 04/21] Move form entry row to separate component --- .../app/components/advanced_settings_form.js | 114 +++------------ .../public/app/components/form_entry_row.js | 138 ++++++++++++++++++ .../public/app/components/index.js | 1 + 3 files changed, 162 insertions(+), 91 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js index 6cccf862ecf4d..49063c331780e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js @@ -12,29 +12,15 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiFieldText, EuiPanel, - EuiDescribedFormGroup, - EuiFormRow, EuiButtonIcon, EuiLink, EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { debounce } from 'lodash'; -import { getValidator, i18nValidationErrorMessages } from '../services/input_validation'; - -const parseError = (err) => { - if (!err) { - return null; - } - - const [error] = err.details; // Use the first error in the details array (error.details[0]) - const { type, context: { label } } = error; - const message = i18nValidationErrorMessages[type](label); - return { message }; -}; +import { FormEntryRow } from './form_entry_row'; +import { getValidator } from '../services/input_validation'; /** * State transitions: fields update @@ -64,20 +50,13 @@ export const updateFormErrors = (errors, onFormValidityUpdate = () => undefined) /** * State transitions: add setting field to form and errors */ -export const addField = (field, validator, onFormValidityUpdate) => ({ fields, fieldsErrors }) => { - const fieldValue = ''; - const { error } = validator.validate({ [field]: fieldValue }); - const updatedFieldsErrors = updateFormErrors({ [field]: parseError(error) }, onFormValidityUpdate)({ fieldsErrors }); - - return ({ - fields: { - ...fields, - [field]: fieldValue, - }, - ...updatedFieldsErrors, - previewSettingActive: null - }); -}; +export const addField = (field) => ({ fields }) => ({ + fields: { + ...fields, + [field]: '', + }, + previewSettingActive: null +}); /** * State transitions: remove setting from fields and errors @@ -112,8 +91,6 @@ export class AdvancedSettingsForm extends PureComponent { constructor(props) { super(props); - this.validateFields = debounce(this.validateFields.bind(this), 500); - this.validator = getValidator(Object.entries(this.props.schema).reduce((acc, [field, schema]) => ({ ...acc, [field]: schema.validate.label(schema.label) @@ -138,74 +115,29 @@ export class AdvancedSettingsForm extends PureComponent { this.setState({ previewSettingActive }); } - validateFields = (fields) => { - const { onFormValidityUpdate } = this.props; - const errors = {}; - - let error; - Object.entries(fields).forEach(([field, value]) => { - ({ error } = this.validator.validate({ [field]: value })); - - errors[field] = parseError(error); - }); - - this.setState(updateFormErrors(errors, onFormValidityUpdate)); - } - onFieldChange = (fields) => { this.setState(updateFields(fields)); - this.validateFields(fields); } - renderRowSelectedSetting = (field, value, fieldSchema, areErrorsVisible, fieldErrors) => { - const hasError = !!fieldErrors; - const isInvalid = hasError && (fieldErrors.alwaysVisible || areErrorsVisible); - - return ( - - - -

{fieldSchema.label}

-
-
- - this.unSelectSetting(field)} - iconType="minusInCircle" - aria-label="Remove setting" - /> - -
- )} - description={fieldSchema.description} - fullWidth - key={field} - > - - this.onFieldChange({ [field]: e.target.value })} - fullWidth - /> - - - ); + onFieldsErrorChange = (errors) => { + this.setState(updateFormErrors(errors, this.props.onFormValidityUpdate)); } renderSelectedSettings = () => { - const { fields, fieldsErrors } = this.state; + const { fields } = this.state; const { areErrorsVisible, schema } = this.props; + return Object.keys(fields).map((field) => ( - this.renderRowSelectedSetting(field, fields[field], schema[field], areErrorsVisible, fieldsErrors[field]) + )); } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js new file mode 100644 index 0000000000000..944c615aae8dd --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; + +import { + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiFieldText, + EuiDescribedFormGroup, + EuiFormRow, + EuiButtonIcon, +} from '@elastic/eui'; + +import { i18nValidationErrorMessages } from '../services/input_validation'; + +/** + * State transitions: fields update + */ +export const updateFields = (newValues) => ({ fields }) => ({ + fields: { + ...fields, + ...newValues, + }, +}); + +const parseError = (err) => { + if (!err) { + return null; + } + + const [error] = err.details; // Use the first error in the details array (error.details[0]) + const { type, context: { label } } = error; + const message = i18nValidationErrorMessages[type](label); + return { message }; +}; + +export class FormEntryRow extends PureComponent { + static propTypes = { + onValueUpdate: PropTypes.func.isRequired, + onErrorUpdate: PropTypes.func.isRequired, + onRemoveRow: PropTypes.func.isRequired, + defaultValue: PropTypes.string, + field: PropTypes.string.isRequired, + schema: PropTypes.object.isRequired, + areErrorsVisible: PropTypes.bool.isRequired, + validator: PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + + this.state = { + value: props.defaultValue || '', + error: this.validateField('', false) + }; + + this.validateField = debounce(this.validateField.bind(this), 500); + } + + onFieldChange = (value) => { + const { field, onValueUpdate } = this.props; + + this.setState({ value }); + onValueUpdate({ [field]: value }); + + // We don't add the error in the setState() call above + // because the "validateField()" call is debounced + this.validateField(value); + } + + validateField = (value, updateState = true) => { + const { field, validator, onErrorUpdate } = this.props; + + const error = parseError(validator.validate({ [field]: value }).error); + onErrorUpdate({ [field]: error }); + + if (updateState) { + this.setState({ error }); + } + + return error; + } + + render() { + const { field, schema, areErrorsVisible, onRemoveRow } = this.props; + const { value, error } = this.state; + + const hasError = !!error; + const isInvalid = hasError && areErrorsVisible; + + return ( + + + +

{schema.label}

+
+
+ + onRemoveRow(field)} + iconType="minusInCircle" + aria-label="Remove setting" + /> + +
+ )} + description={schema.description} + fullWidth + key={field} + > + + this.onFieldChange(e.target.value)} + fullWidth + /> + + + ); + } +} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js index 0c78710387bdd..fee57cbc03daf 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js @@ -18,3 +18,4 @@ export { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provide export { FollowerIndexForm } from './follower_index_form'; export { FollowerIndexPageTitle } from './follower_index_page_title'; export { AdvancedSettingsForm } from './advanced_settings_form'; +export { FormEntryRow } from './form_entry_row'; From b8933b75a6c9fa6b6c5eb0b47c28b84a5e8fbd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Wed, 9 Jan 2019 15:30:04 +0100 Subject: [PATCH 05/21] Add title to panel --- .../public/app/components/advanced_settings_form.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js index 49063c331780e..d988ba9b94d4b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js @@ -148,6 +148,10 @@ export class AdvancedSettingsForm extends PureComponent { return ( + +

Advanced settings

+
+ { Object.keys(schema) @@ -190,6 +194,7 @@ export class AdvancedSettingsForm extends PureComponent {

{schema[previewSettingActive].label}

+ {schema[previewSettingActive].description} From 7a1703fcf6b7e958d27680a428a24f83b01c5352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Wed, 9 Jan 2019 16:21:47 +0100 Subject: [PATCH 06/21] Add i18n translation of advanced settings --- .../app/components/advanced_settings_form.js | 329 ++++++++++-------- .../app/components/follower_index_form.js | 88 +++-- 2 files changed, 239 insertions(+), 178 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js index d988ba9b94d4b..b5239d9bbd857 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js @@ -17,7 +17,7 @@ import { EuiLink, EuiText, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { FormEntryRow } from './form_entry_row'; import { getValidator } from '../services/input_validation'; @@ -74,174 +74,195 @@ export const removeField = (field, onFormValidityUpdate = () => undefined) => ({ }; }; -export class AdvancedSettingsForm extends PureComponent { - static propTypes = { - onFormValidityUpdate: PropTypes.func.isRequired, - areErrorsVisible: PropTypes.bool.isRequired, - schema: PropTypes.object.isRequired - } +export const AdvancedSettingsForm = injectI18n( + class extends PureComponent { + static propTypes = { + onFormValidityUpdate: PropTypes.func.isRequired, + areErrorsVisible: PropTypes.bool.isRequired, + schema: PropTypes.object.isRequired + } - state = { - isOpened: false, - fields: {}, - fieldsErrors: {}, - previewSettingActive: null, - }; + state = { + isOpened: false, + fields: {}, + fieldsErrors: {}, + previewSettingActive: null, + }; - constructor(props) { - super(props); + constructor(props) { + super(props); - this.validator = getValidator(Object.entries(this.props.schema).reduce((acc, [field, schema]) => ({ - ...acc, - [field]: schema.validate.label(schema.label) - }), {})); - } + this.validator = getValidator(Object.entries(this.props.schema).reduce((acc, [field, schema]) => ({ + ...acc, + [field]: schema.validate.label(schema.label) + }), {})); + } - toggle = () => { - this.setState(({ isOpened }) => ({ isOpened: !isOpened, previewSettingActive: null })); - } + toggle = () => { + this.setState(({ isOpened }) => ({ isOpened: !isOpened, previewSettingActive: null })); + } - selectSetting = (setting) => { - this.setState(addField(setting, this.validator, this.props.onFormValidityUpdate)); - } + selectSetting = (setting) => { + this.setState(addField(setting, this.validator, this.props.onFormValidityUpdate)); + } - unSelectSetting = (setting) => { - this.setState(removeField(setting, this.props.onFormValidityUpdate)); - } + unSelectSetting = (setting) => { + this.setState(removeField(setting, this.props.onFormValidityUpdate)); + } - isSettingSelected = setting => typeof this.state.fields[setting] !== 'undefined' + isSettingSelected = setting => typeof this.state.fields[setting] !== 'undefined' - setPreviewSettingActive = (previewSettingActive) => { - this.setState({ previewSettingActive }); - } + setPreviewSettingActive = (previewSettingActive) => { + this.setState({ previewSettingActive }); + } - onFieldChange = (fields) => { - this.setState(updateFields(fields)); - } + onFieldChange = (fields) => { + this.setState(updateFields(fields)); + } - onFieldsErrorChange = (errors) => { - this.setState(updateFormErrors(errors, this.props.onFormValidityUpdate)); - } + onFieldsErrorChange = (errors) => { + this.setState(updateFormErrors(errors, this.props.onFormValidityUpdate)); + } - renderSelectedSettings = () => { - const { fields } = this.state; - const { areErrorsVisible, schema } = this.props; - - return Object.keys(fields).map((field) => ( - - )); - } + renderSelectedSettings = () => { + const { fields } = this.state; + const { areErrorsVisible, schema } = this.props; + + return Object.keys(fields).map((field) => ( + + )); + } - renderSettings = () => { - const { schema } = this.props; - const { previewSettingActive } = this.state; + renderPreview = () => { + const { previewSettingActive } = this.state; + const { schema } = this.props; - return ( - - - -

Advanced settings

+ const currentSetting = previewSettingActive && schema[previewSettingActive]; + + if (!currentSetting) { + return null; + } + + return ( + + +

{currentSetting.label}

- - - - { Object.keys(schema) - .filter((setting) => !this.isSettingSelected(setting)) - .map((field, i, arr) => { - const fieldSchema = schema[field]; - - return ( - - this.setPreviewSettingActive(field)} - onMouseLeave={() => this.setPreviewSettingActive(null)} - > - - this.selectSetting(field)} - iconType="plusInCircle" - aria-label="Add setting" - /> - - - this.setPreviewSettingActive(field)} - > - {fieldSchema.label} - - - - {i < arr.length - 1 && } - - ); - }) } - - - {previewSettingActive && ( - - -

{schema[previewSettingActive].label}

-
- - - {schema[previewSettingActive].description} - -
- )} -
-
-
-
- ); - } + + + {currentSetting.description} + +
+ ); + } - render() { - const { isOpened } = this.state; - return ( - - {this.renderSelectedSettings()} - - - {!isOpened && ( - - - - )} - {isOpened && ( - + renderSettings = () => { + const { schema } = this.props; + + return ( + + + +

- - )} - - - {isOpened && this.renderSettings()} - - ); +

+
+ + + + { Object.keys(schema) + .filter((setting) => !this.isSettingSelected(setting)) + .map((field, i, arr) => { + const fieldSchema = schema[field]; + + return ( + + this.setPreviewSettingActive(field)} + onBlur={() => this.setPreviewSettingActive(null)} + onMouseEnter={() => this.setPreviewSettingActive(field)} + onMouseLeave={() => this.setPreviewSettingActive(null)} + > + + this.selectSetting(field)} + iconType="plusInCircle" + aria-label="Add setting" + /> + + + this.setPreviewSettingActive(field)} + > + {fieldSchema.label} + + + + {i < arr.length - 1 && } + + ); + }) } + + + {this.renderPreview()} + + +
+
+ ); + } + + render() { + const { isOpened } = this.state; + return ( + + {this.renderSelectedSettings()} + + + {!isOpened && ( + + + + )} + {isOpened && ( + + + + )} + + + {isOpened && this.renderSettings()} + + ); + } } -} +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js index 51ac35125e803..ba0e0b2fc8dfd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js @@ -27,7 +27,7 @@ import { EuiTitle, EuiSuperSelect, } from '@elastic/eui'; - +import { i18n } from '@kbn/i18n'; import Joi from 'joi'; import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; @@ -76,56 +76,96 @@ export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ } }); -/* eslint-disable */ -const schemaAdvancedFields = { +/* eslint-disable max-len */ +const advancedSettingsFields = { maxReadRequestOperationCount: { - label: 'Max read request operation count', - description: 'The maximum number of operations to pull per read from the remote cluster.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { + defaultMessage: 'Max read request operation count' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' + }), validate: Joi.number(), }, maxOutstandingReadRequests: { - label: 'Max outstanding read requests', - description: 'The maximum number of outstanding reads requests from the remote cluster.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { + defaultMessage: 'Max outstanding read requests' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { + defaultMessage: 'The maximum number of outstanding reads requests from the remote cluster.' + }), validate: Joi.number(), }, maxReadRequestSize: { - label: 'Max read request size', - description: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster (bye value).', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { + defaultMessage: 'Max read request size' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { + defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster (bye value).' + }), validate: Joi.number(), }, maxWriteRequestOperationCount: { - label: 'Max write request operation count', - description: 'The maximum number of operations per bulk write request executed on the follower.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { + defaultMessage: 'Max write request operation count' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' + }), validate: Joi.number(), }, maxWriteRequestSize: { - label: 'Max write request size', - description: 'The maximum total bytes of operations per bulk write request executed on the follower.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { + defaultMessage: 'Max write request size' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { + defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' + }), validate: Joi.number(), }, maxOutstandingWriteRequests: { - label: 'Max outstanding write requests', - description: 'The maximum number of outstanding write requests on the follower.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { + defaultMessage: 'Max outstanding write requests' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { + defaultMessage: 'The maximum number of outstanding write requests on the follower.' + }), validate: Joi.number(), }, maxWriteBufferCount: { - label: 'Max write buffer count', - description: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { + defaultMessage: 'Max write buffer count' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { + defaultMessage: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.' + }), validate: Joi.number(), }, maxWriteBufferSize: { - label: 'Max write buffer size', - description: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { + defaultMessage: 'Max write buffer size' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { + defaultMessage: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.' + }), validate: Joi.number(), }, maxRetryDelay: { - label: 'Max retry delay', - description: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { + defaultMessage: 'Max retry delay' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { + defaultMessage: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.' + }), validate: Joi.number(), }, readPollTimeout: { - label: 'Read poll timeout', - description: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.', + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { + defaultMessage: 'Read poll timeout' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { + defaultMessage: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.' + }), validate: Joi.number(), }, }; @@ -549,7 +589,7 @@ export const FollowerIndexForm = injectI18n( From 33fe388a7027a043d030c46b211a8ae25d644471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Thu, 10 Jan 2019 17:49:31 +0100 Subject: [PATCH 07/21] Update Follower index form with toggle for advanced settings --- .../app/components/advanced_settings_form.js | 268 ------------- .../app/components/follower_index_form.js | 373 ++++++------------ .../public/app/components/form_entry_row.js | 66 +--- .../public/app/components/index.js | 1 - .../public/app/constants/form_schemas.js | 137 +++++++ .../public/app/constants/index.js | 1 + .../app/services/follower_index_validators.js | 100 ----- .../public/app/services/input_validation.js | 96 ++++- 8 files changed, 357 insertions(+), 685 deletions(-) delete mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js delete mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js deleted file mode 100644 index b5239d9bbd857..0000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/advanced_settings_form.js +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiTitle, - EuiSpacer, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiButtonIcon, - EuiLink, - EuiText, -} from '@elastic/eui'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; - -import { FormEntryRow } from './form_entry_row'; -import { getValidator } from '../services/input_validation'; - -/** - * State transitions: fields update - */ -export const updateFields = (newValues) => ({ fields }) => ({ - fields: { - ...fields, - ...newValues, - }, -}); - -/** - * State transitions: errors update - */ -export const updateFormErrors = (errors, onFormValidityUpdate = () => undefined) => ({ fieldsErrors }) => { - const updatedFieldsErrors = { - ...fieldsErrors, - ...errors, - }; - - const isFormValid = Object.values(updatedFieldsErrors).every(error => error === null); - onFormValidityUpdate(isFormValid); - - return { fieldsErrors: updatedFieldsErrors }; -}; - -/** - * State transitions: add setting field to form and errors - */ -export const addField = (field) => ({ fields }) => ({ - fields: { - ...fields, - [field]: '', - }, - previewSettingActive: null -}); - -/** - * State transitions: remove setting from fields and errors - */ -export const removeField = (field, onFormValidityUpdate = () => undefined) => ({ fields, fieldsErrors }) => { - const { [field]: value, ...fieldsWithoutSetting } = fields; // eslint-disable-line no-unused-vars - const { [field]: value2, ...fieldsErrorsWithoutSetting } = fieldsErrors; // eslint-disable-line no-unused-vars - - const isFormValid = Object.values(fieldsErrorsWithoutSetting).every(error => error === null); - onFormValidityUpdate(isFormValid); - - return { - fields: fieldsWithoutSetting, - fieldsErrors: fieldsErrorsWithoutSetting, - }; -}; - -export const AdvancedSettingsForm = injectI18n( - class extends PureComponent { - static propTypes = { - onFormValidityUpdate: PropTypes.func.isRequired, - areErrorsVisible: PropTypes.bool.isRequired, - schema: PropTypes.object.isRequired - } - - state = { - isOpened: false, - fields: {}, - fieldsErrors: {}, - previewSettingActive: null, - }; - - constructor(props) { - super(props); - - this.validator = getValidator(Object.entries(this.props.schema).reduce((acc, [field, schema]) => ({ - ...acc, - [field]: schema.validate.label(schema.label) - }), {})); - } - - toggle = () => { - this.setState(({ isOpened }) => ({ isOpened: !isOpened, previewSettingActive: null })); - } - - selectSetting = (setting) => { - this.setState(addField(setting, this.validator, this.props.onFormValidityUpdate)); - } - - unSelectSetting = (setting) => { - this.setState(removeField(setting, this.props.onFormValidityUpdate)); - } - - isSettingSelected = setting => typeof this.state.fields[setting] !== 'undefined' - - setPreviewSettingActive = (previewSettingActive) => { - this.setState({ previewSettingActive }); - } - - onFieldChange = (fields) => { - this.setState(updateFields(fields)); - } - - onFieldsErrorChange = (errors) => { - this.setState(updateFormErrors(errors, this.props.onFormValidityUpdate)); - } - - renderSelectedSettings = () => { - const { fields } = this.state; - const { areErrorsVisible, schema } = this.props; - - return Object.keys(fields).map((field) => ( - - )); - } - - renderPreview = () => { - const { previewSettingActive } = this.state; - const { schema } = this.props; - - const currentSetting = previewSettingActive && schema[previewSettingActive]; - - if (!currentSetting) { - return null; - } - - return ( - - -

{currentSetting.label}

-
- - - {currentSetting.description} - -
- ); - } - - renderSettings = () => { - const { schema } = this.props; - - return ( - - - -

- -

-
- - - - { Object.keys(schema) - .filter((setting) => !this.isSettingSelected(setting)) - .map((field, i, arr) => { - const fieldSchema = schema[field]; - - return ( - - this.setPreviewSettingActive(field)} - onBlur={() => this.setPreviewSettingActive(null)} - onMouseEnter={() => this.setPreviewSettingActive(field)} - onMouseLeave={() => this.setPreviewSettingActive(null)} - > - - this.selectSetting(field)} - iconType="plusInCircle" - aria-label="Add setting" - /> - - - this.setPreviewSettingActive(field)} - > - {fieldSchema.label} - - - - {i < arr.length - 1 && } - - ); - }) } - - - {this.renderPreview()} - - -
-
- ); - } - - render() { - const { isOpened } = this.state; - return ( - - {this.renderSelectedSettings()} - - - {!isOpened && ( - - - - )} - {isOpened && ( - - - - )} - - - {isOpened && this.renderSettings()} - - ); - } - } -); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js index ba0e0b2fc8dfd..1b70c51389a71 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js @@ -27,19 +27,13 @@ import { EuiTitle, EuiSuperSelect, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import Joi from 'joi'; -import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import routing from '../services/routing'; -import { API_STATUS } from '../constants'; +import { API_STATUS, follwerIndexFormSchema } from '../constants'; import { SectionError } from './section_error'; -import { AdvancedSettingsForm } from './advanced_settings_form'; -import { validateFollowerIndex } from '../services/follower_index_validators'; import { loadIndices } from '../services/api'; - -const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); +import { FormEntryRow } from './form_entry_row'; const getFirstConnectedCluster = (clusters) => { for (let i = 0; i < clusters.length; i++) { @@ -52,8 +46,9 @@ const getFirstConnectedCluster = (clusters) => { const getEmptyFollowerIndex = (remoteClusters) => ({ name: '', - remoteCluster: getFirstConnectedCluster(remoteClusters).name, + remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', leaderIndex: '', + ...Object.keys(follwerIndexFormSchema.advanced).reduce((acc, field) => ({ ...acc, [field]: '' }), {}) }); /** @@ -76,101 +71,6 @@ export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ } }); -/* eslint-disable max-len */ -const advancedSettingsFields = { - maxReadRequestOperationCount: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { - defaultMessage: 'Max read request operation count' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { - defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' - }), - validate: Joi.number(), - }, - maxOutstandingReadRequests: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { - defaultMessage: 'Max outstanding read requests' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { - defaultMessage: 'The maximum number of outstanding reads requests from the remote cluster.' - }), - validate: Joi.number(), - }, - maxReadRequestSize: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { - defaultMessage: 'Max read request size' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { - defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster (bye value).' - }), - validate: Joi.number(), - }, - maxWriteRequestOperationCount: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { - defaultMessage: 'Max write request operation count' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { - defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' - }), - validate: Joi.number(), - }, - maxWriteRequestSize: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { - defaultMessage: 'Max write request size' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { - defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' - }), - validate: Joi.number(), - }, - maxOutstandingWriteRequests: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { - defaultMessage: 'Max outstanding write requests' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { - defaultMessage: 'The maximum number of outstanding write requests on the follower.' - }), - validate: Joi.number(), - }, - maxWriteBufferCount: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { - defaultMessage: 'Max write buffer count' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { - defaultMessage: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.' - }), - validate: Joi.number(), - }, - maxWriteBufferSize: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { - defaultMessage: 'Max write buffer size' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { - defaultMessage: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.' - }), - validate: Joi.number(), - }, - maxRetryDelay: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { - defaultMessage: 'Max retry delay' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { - defaultMessage: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.' - }), - validate: Joi.number(), - }, - readPollTimeout: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { - defaultMessage: 'Read poll timeout' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { - defaultMessage: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.' - }), - validate: Joi.number(), - }, -}; -/* eslint-enable */ - export const FollowerIndexForm = injectI18n( class extends PureComponent { static propTypes = { @@ -190,42 +90,74 @@ export const FollowerIndexForm = injectI18n( const followerIndex = isNew ? getEmptyFollowerIndex(this.props.remoteClusters) : { + ...getEmptyFollowerIndex(), ...this.props.followerIndex, }; this.state = { + isNew, followerIndex, - fieldsErrors: validateFollowerIndex(followerIndex), - advancedSettingsFormValid: true, + fieldsErrors: {}, areErrorsVisible: false, - isNew, + areAdvancedSettingsVisible: false, }; this.validateIndexName = debounce(this.validateIndexName, 500); } onFieldsChange = (fields) => { - const errors = validateFollowerIndex(fields); this.setState(updateFields(fields)); - this.setState(updateFormErrors(errors)); if (this.props.apiError) { this.props.clearApiError(); } }; + onFieldsErrorChange = (errors) => { + this.setState(updateFormErrors(errors)); + } + + onIndexNameChange = ({ name }) => { + this.onFieldsChange({ name }); + this.validateIndexName(name); + } + + validateIndexName = async (name) => { + if (!name || !name.trim) { + return; + } + + const { intl } = this.props; + + try { + const indices = await loadIndices(); + const doesExist = indices.some(index => index.name === name); + if (doesExist) { + const message = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexForm.indexAlreadyExistError', + defaultMessage: 'An index with the same name already exists.' + }); + this.setState(updateFormErrors({ name: { message, alwaysVisible: true } })); + } + } catch (err) { + // Silently fail... + } + } + onClusterChange = (remoteCluster) => { this.onFieldsChange({ remoteCluster }); }; - updateAdvancedSettingsFormValidity = (isValid) => this.setState({ advancedSettingsFormValid: isValid }) - getFields = () => { return this.state.followerIndex; }; + toggleAdvancedSettings = () => { + this.setState(({ areAdvancedSettingsVisible }) => ({ areAdvancedSettingsVisible: !areAdvancedSettingsVisible })); + } + isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === null) && this.state.advancedSettingsFormValid; + return Object.values(this.state.fieldsErrors).every(error => error === null); } sendForm = () => { @@ -246,33 +178,6 @@ export const FollowerIndexForm = injectI18n( routing.navigate('/follower_indices'); }; - onIndexNameChange = (name) => { - this.onFieldsChange({ name }); - this.validateIndexName(name); - } - - validateIndexName = async (name) => { - if (!name || !name.trim) { - return; - } - - const { intl } = this.props; - - try { - const indices = await loadIndices(); - const doesExist = indices.some(index => index.name === name); - if (doesExist) { - const message = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexForm.indexAlreadyExistError', - defaultMessage: 'An index with the same name already exists.' - }); - this.setState(updateFormErrors({ name: { message, alwaysVisible: true } })); - } - } catch (err) { - // Silently fail... - } - } - /** * Secctions Renders */ @@ -303,72 +208,41 @@ export const FollowerIndexForm = injectI18n( renderForm = () => { const { - followerIndex: { - name, - remoteCluster, - leaderIndex, - }, + followerIndex, isNew, areErrorsVisible, + areAdvancedSettingsVisible, fieldsErrors, } = this.state; + const toggleAdvancedSettingButtonLabel = areAdvancedSettingsVisible + ? ( + + ) : ( + + ); + /** * Follower index name */ - const renderFollowerIndexName = () => { - const hasError = !!fieldsErrors.name; - const isInvalid = hasError && (fieldsErrors.name.alwaysVisible || areErrorsVisible); - - return ( - -

- -

- - )} - description={( - - )} - fullWidth - > - - )} - helpText={( - {indexNameIllegalCharacters} }} - /> - )} - error={fieldsErrors.name && fieldsErrors.name.message} - isInvalid={isInvalid} - fullWidth - > - this.onIndexNameChange(e.target.value)} - fullWidth - disabled={!isNew} - /> - -
- ); - }; + const renderFollowerIndexName = () => ( + + ); /** * Remote Cluster @@ -414,13 +288,13 @@ export const FollowerIndexForm = injectI18n( { isNew && ( )} { !isNew && ( @@ -434,62 +308,49 @@ export const FollowerIndexForm = injectI18n( /** * Leader index */ - const renderLeaderIndex = () => { - const hasError = !!fieldsErrors.leaderIndex; - const isInvalid = hasError && areErrorsVisible; + const renderLeaderIndex = () => ( + + ); - return ( - -

- -

- - )} - description={( - -

- -

-
- )} - fullWidth + /** + * Advanced settings + */ + const renderAdvancedSettings = () => ( + + - - )} - helpText={( - {indexNameIllegalCharacters} }} - /> - )} - isInvalid={isInvalid} - error={fieldsErrors.leaderIndex && fieldsErrors.leaderIndex.message} - fullWidth - > - this.onFieldsChange({ leaderIndex: e.target.value })} - fullWidth + { toggleAdvancedSettingButtonLabel } + + + {areAdvancedSettingsVisible && ( + Object.entries(follwerIndexFormSchema.advanced).map(([field, schema]) => ( + - -
- ); - }; + )) + )} +
+ ); /** * Form Error warning message @@ -585,14 +446,10 @@ export const FollowerIndexForm = injectI18n( {renderFollowerIndexName()} {renderRemoteClusterField()} {renderLeaderIndex()} + + {renderAdvancedSettings()} - - {renderFormErrorWarning()} {renderActions()} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index 944c615aae8dd..c847b3874a12b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -11,12 +11,9 @@ import { debounce } from 'lodash'; import { EuiTitle, - EuiFlexGroup, - EuiFlexItem, EuiFieldText, EuiDescribedFormGroup, EuiFormRow, - EuiButtonIcon, } from '@elastic/eui'; import { i18nValidationErrorMessages } from '../services/input_validation'; @@ -37,8 +34,8 @@ const parseError = (err) => { } const [error] = err.details; // Use the first error in the details array (error.details[0]) - const { type, context: { label } } = error; - const message = i18nValidationErrorMessages[type](label); + const { type, context } = error; + const message = i18nValidationErrorMessages[type](context); return { message }; }; @@ -46,74 +43,47 @@ export class FormEntryRow extends PureComponent { static propTypes = { onValueUpdate: PropTypes.func.isRequired, onErrorUpdate: PropTypes.func.isRequired, - onRemoveRow: PropTypes.func.isRequired, - defaultValue: PropTypes.string, field: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + error: PropTypes.object, schema: PropTypes.object.isRequired, + disabled: PropTypes.bool, areErrorsVisible: PropTypes.bool.isRequired, - validator: PropTypes.object.isRequired, }; - constructor(props) { - super(props); - - this.state = { - value: props.defaultValue || '', - error: this.validateField('', false) - }; - - this.validateField = debounce(this.validateField.bind(this), 500); + componentDidMount() { + this.validateField(this.props.value); + this.validateField = debounce(this.validateField.bind(this), 300); } onFieldChange = (value) => { const { field, onValueUpdate } = this.props; - this.setState({ value }); onValueUpdate({ [field]: value }); - // We don't add the error in the setState() call above - // because the "validateField()" call is debounced this.validateField(value); } - validateField = (value, updateState = true) => { - const { field, validator, onErrorUpdate } = this.props; + validateField = (value) => { + const { field, schema: { validator, label }, onErrorUpdate } = this.props; + const result = validator.label(label).validate(value); + const error = parseError(result.error); - const error = parseError(validator.validate({ [field]: value }).error); onErrorUpdate({ [field]: error }); - - if (updateState) { - this.setState({ error }); - } - - return error; } render() { - const { field, schema, areErrorsVisible, onRemoveRow } = this.props; - const { value, error } = this.state; + const { field, value, error, schema, disabled, areErrorsVisible } = this.props; const hasError = !!error; - const isInvalid = hasError && areErrorsVisible; + const isInvalid = hasError && (error.alwaysVisible || areErrorsVisible); return ( - - -

{schema.label}

-
-
- - onRemoveRow(field)} - iconType="minusInCircle" - aria-label="Remove setting" - /> - -
+ +

{schema.label}

+
)} description={schema.description} fullWidth @@ -121,6 +91,7 @@ export class FormEntryRow extends PureComponent { > this.onFieldChange(e.target.value)} + disabled={disabled === true} fullWidth /> diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js index fee57cbc03daf..18029cfc5271f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/index.js @@ -17,5 +17,4 @@ export { FollowerIndexResumeProvider } from './follower_index_resume_provider'; export { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provider'; export { FollowerIndexForm } from './follower_index_form'; export { FollowerIndexPageTitle } from './follower_index_page_title'; -export { AdvancedSettingsForm } from './advanced_settings_form'; export { FormEntryRow } from './form_entry_row'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js b/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js new file mode 100644 index 0000000000000..3d24a5e988469 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import Joi from 'joi'; +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; + +import { indexNameValidator } from '../services/input_validation'; + +const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); + +/* eslint-disable max-len */ +export const follwerIndexFormSchema = { + name: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameTitle', { + defaultMessage: 'Name' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameDescription', { + defaultMessage: 'A name for the follower index.' + }), + helpText: i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameHelpLabel', { + defaultMessage: 'Spaces and the characters {characterList} are not allowed.', + values: { characterList: {indexNameIllegalCharacters} } + }), + validator: indexNameValidator, + }, + leaderIndex: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexTitle', { + defaultMessage: 'Leader index' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription', { + defaultMessage: 'The leader index you want to replicate from the remote cluster.' + }), + helpText: i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameHelpLabel', { + defaultMessage: 'Spaces and the characters {characterList} are not allowed.', + values: { characterList: {indexNameIllegalCharacters} } + }), + validator: indexNameValidator, + }, + advanced: { + maxReadRequestOperationCount: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { + defaultMessage: 'Max read request operation count' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' + }), + validator: Joi.number().allow(''), + }, + maxOutstandingReadRequests: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { + defaultMessage: 'Max outstanding read requests' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { + defaultMessage: 'The maximum number of outstanding reads requests from the remote cluster.' + }), + validator: Joi.number().allow(''), + }, + maxReadRequestSize: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { + defaultMessage: 'Max read request size' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { + defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster (bye value).' + }), + validator: Joi.number().allow(''), + }, + maxWriteRequestOperationCount: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { + defaultMessage: 'Max write request operation count' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' + }), + validator: Joi.number().allow(''), + }, + maxWriteRequestSize: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { + defaultMessage: 'Max write request size' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { + defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' + }), + validator: Joi.number().allow(''), + }, + maxOutstandingWriteRequests: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { + defaultMessage: 'Max outstanding write requests' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { + defaultMessage: 'The maximum number of outstanding write requests on the follower.' + }), + validator: Joi.number().allow(''), + }, + maxWriteBufferCount: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { + defaultMessage: 'Max write buffer count' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { + defaultMessage: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.' + }), + validator: Joi.number().allow(''), + }, + maxWriteBufferSize: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { + defaultMessage: 'Max write buffer size' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { + defaultMessage: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.' + }), + validator: Joi.number().allow(''), + }, + maxRetryDelay: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { + defaultMessage: 'Max retry delay' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { + defaultMessage: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.' + }), + validator: Joi.number().allow(''), + }, + readPollTimeout: { + label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { + defaultMessage: 'Read poll timeout' + }), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { + defaultMessage: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.' + }), + validator: Joi.number().allow(''), + }, + } +}; +/* eslint-enable */ diff --git a/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js b/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js index 11fd188374b53..d590c467e212e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js @@ -6,3 +6,4 @@ export { API_STATUS } from './api'; export { SECTIONS } from './sections'; +export { follwerIndexFormSchema } from './form_schemas'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js deleted file mode 100644 index df31e2f3d1c23..0000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - indexNameBeginsWithPeriod, - findIllegalCharactersInIndexName, - indexNameContainsSpaces, -} from 'ui/indices'; - -const i18nLabels = { - indexName: i18n.translate( - 'xpack.crossClusterReplication.followerIndex.indexName', - { defaultMessage: 'Name' } - ), - leaderIndex: i18n.translate( - 'xpack.crossClusterReplication.followerIndex.leaderIndex', - { defaultMessage: 'Leader index' } - ) -}; - -const validateIndexName = (name, fieldName) => { - if (!name || !name.trim()) { - // Empty - return { - message: i18n.translate( - 'xpack.crossClusterReplication.followerIndex.indexNameValidation.errorEmpty', - { - defaultMessage: '{name} is required.', - values: { name: fieldName } - } - ) - }; - } else { - // Indices can't begin with a period, because that's reserved for system indices. - if (indexNameBeginsWithPeriod(name)) { - return { - message: i18n.translate('xpack.crossClusterReplication.followerIndex.indexNameValidation.beginsWithPeriod', { - defaultMessage: `The {name} can't begin with a period.`, - values: { name: fieldName.toLowerCase() } - }) - }; - } - - const illegalCharacters = findIllegalCharactersInIndexName(name); - - if (illegalCharacters.length) { - return { - message: {illegalCharacters.join(' ')}, - characterListLength: illegalCharacters.length, - }} - /> - }; - } - - if (indexNameContainsSpaces(name)) { - return { - message: i18n.translate('xpack.crossClusterReplication.followerIndex.indexNameValidation.noEmptySpace', { - defaultMessage: `Spaces are not allowed in the {name}.`, - values: { name: fieldName.toLowerCase() } - }) - }; - } - - return null; - } -}; - -export const validateFollowerIndex = (followerIndex) => { - const errors = {}; - let error = null; - let fieldValue; - - Object.keys(followerIndex).forEach((fieldName) => { - fieldValue = followerIndex[fieldName]; - error = null; - switch (fieldName) { - case 'name': - error = validateIndexName(fieldValue, i18nLabels.indexName); - break; - case 'leaderIndex': - error = validateIndexName(fieldValue, i18nLabels.leaderIndex); - break; - } - errors[fieldName] = error; - }); - - return errors; -}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js index 10df97fafcf07..36c3bd4be508b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js @@ -4,21 +4,95 @@ * you may not use this file except in compliance with the Elastic License. */ - -import Joi from 'joi'; +import React from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import Joi from 'joi'; +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; export const i18nValidationErrorMessages = { - 'number.base': field => ( - i18n.translate('xpack.formInputValidation.notNumberError', { - defaultMessage: '{field} must be a number.', - values: { field } + 'any.empty': ({ label }) => ( + i18n.translate('xpack.crossClusterReplication.formInputValidation.errorEmpty', { + defaultMessage: `'{label}' is required.`, + values: { label } }) + ), + 'number.base': ({ label }) => ( + i18n.translate('xpack.crossClusterReplication.formInputValidation.notNumberError', { + defaultMessage: `'{label}' must be a number.`, + values: { label } + }) + ), + 'string.firstChar': ({ label, char }) => ( + {char} + }} + /> + ), + 'string.illegalChars': ({ label, chars }) => ( + {chars} + }} + /> ) }; -export const getValidator = (validators = {}) => { - return Joi.object().keys({ - ...validators - }); -}; +const findCharactersInString = (string, chars) => ( + chars.reduce((chars, char) => { + if (string.includes(char)) { + chars.push(char); + } + + return chars; + }, []) +); + +const advancedStringValidation = (joi) => ({ + base: joi.string(), + name: 'extendedString', + language: { + firstCharNotAllowed: `can't begin with a period.`, + illegalChars: `can't contain the following character(s): {{illegalChars}}.`, + }, + rules: [ + { + name: 'firstCharNotAllowed', + params: { + char: joi.string().required() + }, + validate({ char }, value, state, options) { + if (value[0] === char) { + return this.createError('string.firstChar', { v: value, char }, state, options); + } + + return value; // Everything is OK + } + }, + { + name: 'illegalChars', + params: { + chars: joi.array().items(joi.string()).required() + }, + validate({ chars }, value, state, options) { + const illegalCharacters = findCharactersInString(value, chars); + if (illegalCharacters.length) { + return this.createError('string.illegalChars', { v: value, chars: illegalCharacters.join(' ') }, state, options); + } + + return value; // Everything is OK + } + } + ] +}); + +export const customJoi = Joi.extend(advancedStringValidation); // Add extendsion for advanced string validations + +export const indexNameValidator = customJoi.extendedString().firstCharNotAllowed('.').illegalChars(INDEX_ILLEGAL_CHARACTERS_VISIBLE); From c99996ed45453d80cb95353392a3954891beb694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Thu, 10 Jan 2019 18:16:11 +0100 Subject: [PATCH 08/21] Add server side serialisation for advanced settings --- .../common/services/utils.js | 12 +++++++ .../follower_index_serialization.test.js.snap | 17 ++++++++++ .../lib/follower_index_serialization.js | 31 +++++++++++++++++-- .../lib/follower_index_serialization.test.js | 17 ++++++---- .../server/routes/api/follower_index.js | 3 +- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/common/services/utils.js b/x-pack/plugins/cross_cluster_replication/common/services/utils.js index b8f245bfaefb4..83154c7e95cac 100644 --- a/x-pack/plugins/cross_cluster_replication/common/services/utils.js +++ b/x-pack/plugins/cross_cluster_replication/common/services/utils.js @@ -15,3 +15,15 @@ export const wait = (time = 1000) => (data) => { setTimeout(() => resolve(data), time); }); }; + +/** + * Utility to remove empty fields ("") from a request body + */ +export const removeEmptyFields = (body) => ( + Object.entries(body).reduce((acc, [key, value]) => { + if (value !== '') { + acc[key] = value; + } + return acc; + }, {}) +); diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap index 1c20f73287259..a41a67089d758 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap +++ b/x-pack/plugins/cross_cluster_replication/server/lib/__snapshots__/follower_index_serialization.test.js.snap @@ -96,3 +96,20 @@ Object { "writeBufferSizeBytes": "write buffer size in bytes", } `; + +exports[`[CCR] follower index serialization serializeFollowerIndex() serializes object to Elasticsearch follower index object 1`] = ` +Object { + "leader_index": "leader index", + "max_outstanding_read_requests": "foo", + "max_outstanding_write_requests": "foo", + "max_read_request_operation_count": "foo", + "max_read_request_size": "foo", + "max_retry_delay": "foo", + "max_write_buffer_count": "foo", + "max_write_buffer_size": "foo", + "max_write_request_operation_count": "foo", + "max_write_request_size": "foo", + "read_poll_timeout": "foo", + "remote_cluster": "remote cluster", +} +`; diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js index 7b5a0e453b65f..6d1da8aa6ff3e 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.js @@ -71,7 +71,32 @@ export const deserializeFollowerIndex = ({ index, shards }) => ({ export const deserializeListFollowerIndices = followerIndices => followerIndices.map(deserializeFollowerIndex); -export const serializeFollowerIndex = ({ remoteCluster, leaderIndex }) => ({ - remote_cluster: remoteCluster, - leader_index: leaderIndex, +export const serializeAdvancedSettings = ({ + maxReadRequestOperationCount, + maxOutstandingReadRequests, + maxReadRequestSize, + maxWriteRequestOperationCount, + maxWriteRequestSize, + maxOutstandingWriteRequests, + maxWriteBufferCount, + maxWriteBufferSize, + maxRetryDelay, + readPollTimeout, +}) => ({ + max_read_request_operation_count: maxReadRequestOperationCount, + max_outstanding_read_requests: maxOutstandingReadRequests, + max_read_request_size: maxReadRequestSize, + max_write_request_operation_count: maxWriteRequestOperationCount, + max_write_request_size: maxWriteRequestSize, + max_outstanding_write_requests: maxOutstandingWriteRequests, + max_write_buffer_count: maxWriteBufferCount, + max_write_buffer_size: maxWriteBufferSize, + max_retry_delay: maxRetryDelay, + read_poll_timeout: readPollTimeout, +}); + +export const serializeFollowerIndex = (followerIndex) => ({ + remote_cluster: followerIndex.remoteCluster, + leader_index: followerIndex.leaderIndex, + ...serializeAdvancedSettings(followerIndex) }); diff --git a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js index d4b5526c16a6b..3f08450884dd2 100644 --- a/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js +++ b/x-pack/plugins/cross_cluster_replication/server/lib/follower_index_serialization.test.js @@ -90,14 +90,19 @@ describe('[CCR] follower index serialization', () => { const deserializedFollowerIndex = { remoteCluster: 'remote cluster', leaderIndex: 'leader index', + maxReadRequestOperationCount: 'foo', + maxOutstandingReadRequests: 'foo', + maxReadRequestSize: 'foo', + maxWriteRequestOperationCount: 'foo', + maxWriteRequestSize: 'foo', + maxOutstandingWriteRequests: 'foo', + maxWriteBufferCount: 'foo', + maxWriteBufferSize: 'foo', + maxRetryDelay: 'foo', + readPollTimeout: 'foo', }; - const serializedFollowerIndex = { - remote_cluster: 'remote cluster', - leader_index: 'leader index', - }; - - expect(serializeFollowerIndex(deserializedFollowerIndex)).toEqual(serializedFollowerIndex); + expect(serializeFollowerIndex(deserializedFollowerIndex)).toMatchSnapshot(); }); }); }); diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js index cc7eeaef99514..93c4aa0f48649 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.js @@ -14,6 +14,7 @@ import { } from '../../lib/follower_index_serialization'; import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; import { API_BASE_PATH } from '../../../common/constants'; +import { removeEmptyFields } from '../../../common/services/utils'; export const registerFollowerIndexRoutes = (server) => { const isEsError = isEsErrorFactory(server); @@ -86,7 +87,7 @@ export const registerFollowerIndexRoutes = (server) => { handler: async (request) => { const callWithRequest = callWithRequestFactory(server, request); const { name, ...rest } = request.payload; - const body = serializeFollowerIndex(rest); + const body = removeEmptyFields(serializeFollowerIndex(rest)); try { return await callWithRequest('ccr.saveFollowerIndex', { name, body }); From 369a840c996978b86f8a64392e73c6877630aee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Fri, 11 Jan 2019 09:10:50 +0100 Subject: [PATCH 09/21] Make code review changes --- .../app/components/follower_index_form.js | 18 +++---- .../public/app/components/form_entry_row.js | 48 +++++++++++++----- .../public/app/constants/form_schemas.js | 49 +++++++++++-------- .../public/app/constants/index.js | 2 +- .../public/app/services/input_validation.js | 12 ++--- 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js index 1b70c51389a71..27a704382e680 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js @@ -28,9 +28,8 @@ import { EuiSuperSelect, } from '@elastic/eui'; - import routing from '../services/routing'; -import { API_STATUS, follwerIndexFormSchema } from '../constants'; +import { API_STATUS, followerIndexFormSchema } from '../constants'; import { SectionError } from './section_error'; import { loadIndices } from '../services/api'; import { FormEntryRow } from './form_entry_row'; @@ -48,7 +47,7 @@ const getEmptyFollowerIndex = (remoteClusters) => ({ name: '', remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', leaderIndex: '', - ...Object.keys(follwerIndexFormSchema.advanced).reduce((acc, field) => ({ ...acc, [field]: '' }), {}) + ...Object.keys(followerIndexFormSchema.advanced).reduce((acc, field) => ({ ...acc, [field]: '' }), {}) }); /** @@ -219,12 +218,12 @@ export const FollowerIndexForm = injectI18n( ? ( ) : ( ); @@ -236,7 +235,7 @@ export const FollowerIndexForm = injectI18n( field="name" value={followerIndex.name} error={fieldsErrors.name} - schema={follwerIndexFormSchema.name} + schema={followerIndexFormSchema.name} disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onIndexNameChange} @@ -313,7 +312,7 @@ export const FollowerIndexForm = injectI18n( field="leaderIndex" value={followerIndex.leaderIndex} error={fieldsErrors.leaderIndex} - schema={follwerIndexFormSchema.leaderIndex} + schema={followerIndexFormSchema.leaderIndex} disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onFieldsChange} @@ -327,7 +326,7 @@ export const FollowerIndexForm = injectI18n( const renderAdvancedSettings = () => ( @@ -335,7 +334,7 @@ export const FollowerIndexForm = injectI18n( {areAdvancedSettingsVisible && ( - Object.entries(follwerIndexFormSchema.advanced).map(([field, schema]) => ( + Object.entries(followerIndexFormSchema.advanced).map(([field, schema]) => ( )) )} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index c847b3874a12b..472c489c95f8e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -12,6 +12,7 @@ import { debounce } from 'lodash'; import { EuiTitle, EuiFieldText, + EuiFieldNumber, EuiDescribedFormGroup, EuiFormRow, } from '@elastic/eui'; @@ -44,7 +45,10 @@ export class FormEntryRow extends PureComponent { onValueUpdate: PropTypes.func.isRequired, onErrorUpdate: PropTypes.func.isRequired, field: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]).isRequired, error: PropTypes.object, schema: PropTypes.object.isRequired, disabled: PropTypes.bool, @@ -57,9 +61,9 @@ export class FormEntryRow extends PureComponent { } onFieldChange = (value) => { - const { field, onValueUpdate } = this.props; - - onValueUpdate({ [field]: value }); + const { field, onValueUpdate, schema: { validator } } = this.props; + const isNumber = validator._type === 'number'; + onValueUpdate({ [field]: isNumber ? parseInt(value, 10) : value }); this.validateField(value); } @@ -72,8 +76,34 @@ export class FormEntryRow extends PureComponent { onErrorUpdate({ [field]: error }); } + renderField = (isInvalid) => { + const { value, schema: { validator }, disabled } = this.props; + switch (validator._type) { + case "number": + return ( + this.onFieldChange(e.target.value)} + disabled={disabled === true} + fullWidth + /> + ); + default: + return ( + this.onFieldChange(e.target.value)} + disabled={disabled === true} + fullWidth + /> + ); + } + } + render() { - const { field, value, error, schema, disabled, areErrorsVisible } = this.props; + const { field, error, schema, areErrorsVisible } = this.props; const hasError = !!error; const isInvalid = hasError && (error.alwaysVisible || areErrorsVisible); @@ -96,13 +126,7 @@ export class FormEntryRow extends PureComponent { isInvalid={isInvalid} fullWidth > - this.onFieldChange(e.target.value)} - disabled={disabled === true} - fullWidth - /> + {this.renderField(isInvalid)} ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js b/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js index 3d24a5e988469..02874861ced5e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js @@ -6,6 +6,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import Joi from 'joi'; import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; @@ -14,7 +15,7 @@ import { indexNameValidator } from '../services/input_validation'; const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); /* eslint-disable max-len */ -export const follwerIndexFormSchema = { +export const followerIndexFormSchema = { name: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameTitle', { defaultMessage: 'Name' @@ -22,10 +23,13 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameDescription', { defaultMessage: 'A name for the follower index.' }), - helpText: i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameHelpLabel', { - defaultMessage: 'Spaces and the characters {characterList} are not allowed.', - values: { characterList: {indexNameIllegalCharacters} } - }), + helpText: ( + {indexNameIllegalCharacters} }} + /> + ), validator: indexNameValidator, }, leaderIndex: { @@ -35,10 +39,13 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription', { defaultMessage: 'The leader index you want to replicate from the remote cluster.' }), - helpText: i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameHelpLabel', { - defaultMessage: 'Spaces and the characters {characterList} are not allowed.', - values: { characterList: {indexNameIllegalCharacters} } - }), + helpText: ( + {indexNameIllegalCharacters} }} + /> + ), validator: indexNameValidator, }, advanced: { @@ -49,25 +56,25 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' }), - validator: Joi.number().allow(''), + validator: Joi.number().empty(''), }, maxOutstandingReadRequests: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { defaultMessage: 'Max outstanding read requests' }), description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { - defaultMessage: 'The maximum number of outstanding reads requests from the remote cluster.' + defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.' }), - validator: Joi.number().allow(''), + validator: Joi.number().empty(''), }, maxReadRequestSize: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { defaultMessage: 'Max read request size' }), description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { - defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster (bye value).' + defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.' }), - validator: Joi.number().allow(''), + validator: Joi.string().empty(''), }, maxWriteRequestOperationCount: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { @@ -76,7 +83,7 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' }), - validator: Joi.number().allow(''), + validator: Joi.number().empty(''), }, maxWriteRequestSize: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { @@ -85,7 +92,7 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' }), - validator: Joi.number().allow(''), + validator: Joi.string().empty(''), }, maxOutstandingWriteRequests: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { @@ -94,7 +101,7 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { defaultMessage: 'The maximum number of outstanding write requests on the follower.' }), - validator: Joi.number().allow(''), + validator: Joi.number().empty(''), }, maxWriteBufferCount: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { @@ -103,7 +110,7 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { defaultMessage: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.' }), - validator: Joi.number().allow(''), + validator: Joi.number().empty(''), }, maxWriteBufferSize: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { @@ -112,7 +119,7 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { defaultMessage: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.' }), - validator: Joi.number().allow(''), + validator: Joi.string().empty(''), }, maxRetryDelay: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { @@ -121,7 +128,7 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { defaultMessage: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.' }), - validator: Joi.number().allow(''), + validator: Joi.string().empty(''), }, readPollTimeout: { label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { @@ -130,7 +137,7 @@ export const follwerIndexFormSchema = { description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { defaultMessage: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.' }), - validator: Joi.number().allow(''), + validator: Joi.string().empty(''), }, } }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js b/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js index d590c467e212e..1e2eb368ee5ed 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js @@ -6,4 +6,4 @@ export { API_STATUS } from './api'; export { SECTIONS } from './sections'; -export { follwerIndexFormSchema } from './form_schemas'; +export { followerIndexFormSchema } from './form_schemas'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js index 36c3bd4be508b..808f1e682b7f1 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js @@ -13,20 +13,20 @@ import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; export const i18nValidationErrorMessages = { 'any.empty': ({ label }) => ( i18n.translate('xpack.crossClusterReplication.formInputValidation.errorEmpty', { - defaultMessage: `'{label}' is required.`, + defaultMessage: `{label} is required.`, values: { label } }) ), 'number.base': ({ label }) => ( i18n.translate('xpack.crossClusterReplication.formInputValidation.notNumberError', { - defaultMessage: `'{label}' must be a number.`, + defaultMessage: `{label} must be a number.`, values: { label } }) ), 'string.firstChar': ({ label, char }) => ( {char} @@ -36,7 +36,7 @@ export const i18nValidationErrorMessages = { 'string.illegalChars': ({ label, chars }) => ( {chars} @@ -59,7 +59,7 @@ const advancedStringValidation = (joi) => ({ base: joi.string(), name: 'extendedString', language: { - firstCharNotAllowed: `can't begin with a period.`, + firstCharNotAllowed: `can't begin with a {{char}}.`, illegalChars: `can't contain the following character(s): {{illegalChars}}.`, }, rules: [ @@ -93,6 +93,6 @@ const advancedStringValidation = (joi) => ({ ] }); -export const customJoi = Joi.extend(advancedStringValidation); // Add extendsion for advanced string validations +export const customJoi = Joi.extend(advancedStringValidation); // Add extension for advanced string validations export const indexNameValidator = customJoi.extendedString().firstCharNotAllowed('.').illegalChars(INDEX_ILLEGAL_CHARACTERS_VISIBLE); From c2a82a05d23d524fe8c7ebe91ea270ce27d173dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Sun, 13 Jan 2019 18:19:47 +0100 Subject: [PATCH 10/21] Fix test: mock constant dependency --- .../public/app/store/reducers/api.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js index 818045919caf7..43d1da3f242a2 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/api.test.js @@ -8,6 +8,17 @@ import { reducer, initialState } from './api'; import { API_STATUS } from '../../constants'; import { apiRequestStart, apiRequestEnd, setApiError } from '../actions'; +jest.mock('../../constants', () => ({ + API_STATUS: { + IDLE: 'idle', + LOADING: 'loading', + }, + SECTIONS: { + AUTO_FOLLOW_PATTERN: 'autoFollowPattern', + FOLLOWER_INDEX: 'followerIndex', + } +})); + describe('CCR Api reducers', () => { const scope = 'testSection'; From 66b6d67187c2714a552945fc56b0989412803b77 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 16 Jan 2019 12:09:04 -0800 Subject: [PATCH 11/21] Localize schema properties within follower_index_form module. --- .../advanced_settings_fields.js | 146 ++++++++++++++++++ .../follower_index_form.js | 80 +++++++--- .../follower_index_form.test.js | 0 .../components/follower_index_form/index.js | 7 + .../public/app/components/form_entry_row.js | 28 ++-- .../public/app/constants/form_schemas.js | 144 ----------------- .../public/app/constants/index.js | 1 - 7 files changed, 231 insertions(+), 175 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js rename x-pack/plugins/cross_cluster_replication/public/app/components/{ => follower_index_form}/follower_index_form.js (81%) rename x-pack/plugins/cross_cluster_replication/public/app/components/{ => follower_index_form}/follower_index_form.test.js (100%) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js delete mode 100644 x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js new file mode 100644 index 0000000000000..d26501aac6432 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { i18n } from '@kbn/i18n'; + +export const advancedSettingsFields = [ + { + field: 'maxReadRequestOperationCount', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { + defaultMessage: 'Max read request operation count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' + } + ), + validator: Joi.number().empty(''), + }, { + field: 'maxOutstandingReadRequests', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { + defaultMessage: 'Max outstanding read requests' + } + ), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { + defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.' + }), + validator: Joi.number().empty(''), + }, { + field: 'maxReadRequestSize', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { + defaultMessage: 'Max read request size' + } + ), + description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { + defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.' + }), + validator: Joi.string().empty(''), + }, { + field: 'maxWriteRequestOperationCount', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { + defaultMessage: 'Max write request operation count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { + defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' + } + ), + validator: Joi.number().empty(''), + }, { + field: 'maxWriteRequestSize', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { + defaultMessage: 'Max write request size' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { + defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' + } + ), + validator: Joi.string().empty(''), + }, { + field: 'maxOutstandingWriteRequests', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { + defaultMessage: 'Max outstanding write requests' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { + defaultMessage: 'The maximum number of outstanding write requests on the follower.' + } + ), + validator: Joi.number().empty(''), + }, { + field: 'maxWriteBufferCount', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { + defaultMessage: 'Max write buffer count' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { + defaultMessage: `The maximum number of operations that can be queued for writing; when this + limit is reached, reads from the remote cluster will be deferred until the number of queued + operations goes below the limit.` + } + ), + validator: Joi.number().empty(''), + }, { + field: 'maxWriteBufferSize', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { + defaultMessage: 'Max write buffer size' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { + defaultMessage: `The maximum total bytes of operations that can be queued for writing; when + this limit is reached, reads from the remote cluster will be deferred until the total bytes + of queued operations goes below the limit.` + } + ), + validator: Joi.string().empty(''), + }, { + field: 'maxRetryDelay', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { + defaultMessage: 'Max retry delay' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { + defaultMessage: `The maximum time to wait before retrying an operation that failed exceptionally; + an exponential backoff strategy is employed when retrying.` + } + ), + validator: Joi.string().empty(''), + }, { + field: 'readPollTimeout', + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { + defaultMessage: 'Read poll timeout' + } + ), + description: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { + defaultMessage: `The maximum time to wait for new operations on the remote cluster when the + follower index is synchronized with the leader index; when the timeout has elapsed, the + poll for operations will return to the follower so that it can update some statistics, and + then the follower will immediately attempt to read from the leader again.` + } + ), + validator: Joi.string().empty(''), + }, +]; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js similarity index 81% rename from x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index 27a704382e680..d4353aebcf2bc 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -6,8 +6,10 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { debounce } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import { EuiButton, @@ -28,11 +30,15 @@ import { EuiSuperSelect, } from '@elastic/eui'; -import routing from '../services/routing'; -import { API_STATUS, followerIndexFormSchema } from '../constants'; -import { SectionError } from './section_error'; -import { loadIndices } from '../services/api'; -import { FormEntryRow } from './form_entry_row'; +import { indexNameValidator } from '../../services/input_validation'; +import routing from '../../services/routing'; +import { loadIndices } from '../../services/api'; +import { API_STATUS } from '../../constants'; +import { SectionError } from '../section_error'; +import { FormEntryRow } from '../form_entry_row'; +import { advancedSettingsFields } from './advanced_settings_fields'; + +const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); const getFirstConnectedCluster = (clusters) => { for (let i = 0; i < clusters.length; i++) { @@ -47,7 +53,7 @@ const getEmptyFollowerIndex = (remoteClusters) => ({ name: '', remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', leaderIndex: '', - ...Object.keys(followerIndexFormSchema.advanced).reduce((acc, field) => ({ ...acc, [field]: '' }), {}) + ...advancedSettingsFields.reduce((acc, advancedSetting) => ({ ...acc, [advancedSetting.field]: '' }), {}) }); /** @@ -235,7 +241,20 @@ export const FollowerIndexForm = injectI18n( field="name" value={followerIndex.name} error={fieldsErrors.name} - schema={followerIndexFormSchema.name} + label={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameTitle', { + defaultMessage: 'Name' + })} + description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameDescription', { + defaultMessage: 'A name for the follower index.' + })} + helpText={( + {indexNameIllegalCharacters} }} + /> + )} + validator={indexNameValidator} disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onIndexNameChange} @@ -312,7 +331,20 @@ export const FollowerIndexForm = injectI18n( field="leaderIndex" value={followerIndex.leaderIndex} error={fieldsErrors.leaderIndex} - schema={followerIndexFormSchema.leaderIndex} + label={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexTitle', { + defaultMessage: 'Leader index' + })} + description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription', { + defaultMessage: 'The leader index you want to replicate from the remote cluster.' + })} + helpText={( + {indexNameIllegalCharacters} }} + /> + )} + validator={indexNameValidator} disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onFieldsChange} @@ -334,18 +366,24 @@ export const FollowerIndexForm = injectI18n( {areAdvancedSettingsVisible && ( - Object.entries(followerIndexFormSchema.advanced).map(([field, schema]) => ( - - )) + advancedSettingsFields.map((advancedSetting) => { + const { field, label, description, helpText, validator } = advancedSetting; + return ( + + ); + }) )} ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js similarity index 100% rename from x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js new file mode 100644 index 0000000000000..265780b620a7c --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { FollowerIndexForm } from './follower_index_form'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index 472c489c95f8e..990de02d41880 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -42,6 +42,10 @@ const parseError = (err) => { export class FormEntryRow extends PureComponent { static propTypes = { + label: PropTypes.node, + description: PropTypes.node, + helpText: PropTypes.node, + validator: PropTypes.object, onValueUpdate: PropTypes.func.isRequired, onErrorUpdate: PropTypes.func.isRequired, field: PropTypes.string.isRequired, @@ -50,7 +54,6 @@ export class FormEntryRow extends PureComponent { PropTypes.number ]).isRequired, error: PropTypes.object, - schema: PropTypes.object.isRequired, disabled: PropTypes.bool, areErrorsVisible: PropTypes.bool.isRequired, }; @@ -61,7 +64,7 @@ export class FormEntryRow extends PureComponent { } onFieldChange = (value) => { - const { field, onValueUpdate, schema: { validator } } = this.props; + const { field, onValueUpdate, validator } = this.props; const isNumber = validator._type === 'number'; onValueUpdate({ [field]: isNumber ? parseInt(value, 10) : value }); @@ -69,7 +72,7 @@ export class FormEntryRow extends PureComponent { } validateField = (value) => { - const { field, schema: { validator, label }, onErrorUpdate } = this.props; + const { field, validator, label, onErrorUpdate } = this.props; const result = validator.label(label).validate(value); const error = parseError(result.error); @@ -77,7 +80,7 @@ export class FormEntryRow extends PureComponent { } renderField = (isInvalid) => { - const { value, schema: { validator }, disabled } = this.props; + const { value, validator, disabled } = this.props; switch (validator._type) { case "number": return ( @@ -103,7 +106,14 @@ export class FormEntryRow extends PureComponent { } render() { - const { field, error, schema, areErrorsVisible } = this.props; + const { + field, + error, + label, + description, + helpText, + areErrorsVisible, + } = this.props; const hasError = !!error; const isInvalid = hasError && (error.alwaysVisible || areErrorsVisible); @@ -112,16 +122,16 @@ export class FormEntryRow extends PureComponent { -

{schema.label}

+

{label}

)} - description={schema.description} + description={description} fullWidth key={field} > {indexNameIllegalCharacters} }} - /> - ), - validator: indexNameValidator, - }, - leaderIndex: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexTitle', { - defaultMessage: 'Leader index' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription', { - defaultMessage: 'The leader index you want to replicate from the remote cluster.' - }), - helpText: ( - {indexNameIllegalCharacters} }} - /> - ), - validator: indexNameValidator, - }, - advanced: { - maxReadRequestOperationCount: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { - defaultMessage: 'Max read request operation count' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountDescription', { - defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' - }), - validator: Joi.number().empty(''), - }, - maxOutstandingReadRequests: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { - defaultMessage: 'Max outstanding read requests' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { - defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.' - }), - validator: Joi.number().empty(''), - }, - maxReadRequestSize: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { - defaultMessage: 'Max read request size' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { - defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.' - }), - validator: Joi.string().empty(''), - }, - maxWriteRequestOperationCount: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { - defaultMessage: 'Max write request operation count' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountDescription', { - defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' - }), - validator: Joi.number().empty(''), - }, - maxWriteRequestSize: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { - defaultMessage: 'Max write request size' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeDescription', { - defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' - }), - validator: Joi.string().empty(''), - }, - maxOutstandingWriteRequests: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { - defaultMessage: 'Max outstanding write requests' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsDescription', { - defaultMessage: 'The maximum number of outstanding write requests on the follower.' - }), - validator: Joi.number().empty(''), - }, - maxWriteBufferCount: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { - defaultMessage: 'Max write buffer count' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountDescription', { - defaultMessage: 'The maximum number of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the number of queued operations goes below the limit.' - }), - validator: Joi.number().empty(''), - }, - maxWriteBufferSize: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { - defaultMessage: 'Max write buffer size' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeDescription', { - defaultMessage: 'The maximum total bytes of operations that can be queued for writing; when this limit is reached, reads from the remote cluster will be deferred until the total bytes of queued operations goes below the limit.' - }), - validator: Joi.string().empty(''), - }, - maxRetryDelay: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { - defaultMessage: 'Max retry delay' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayDescription', { - defaultMessage: 'The maximum time to wait before retrying an operation that failed exceptionally; an exponential backoff strategy is employed when retrying.' - }), - validator: Joi.string().empty(''), - }, - readPollTimeout: { - label: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { - defaultMessage: 'Read poll timeout' - }), - description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutDescription', { - defaultMessage: 'The maximum time to wait for new operations on the remote cluster when the follower index is synchronized with the leader index; when the timeout has elapsed, the poll for operations will return to the follower so that it can update some statistics, and then the follower will immediately attempt to read from the leader again.' - }), - validator: Joi.string().empty(''), - }, - } -}; -/* eslint-enable */ diff --git a/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js b/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js index 1e2eb368ee5ed..11fd188374b53 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/constants/index.js @@ -6,4 +6,3 @@ export { API_STATUS } from './api'; export { SECTIONS } from './sections'; -export { followerIndexFormSchema } from './form_schemas'; From 3eee87f5be3f45ba8950b9dd0668d989b5f3ba9b Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 16 Jan 2019 14:45:26 -0800 Subject: [PATCH 12/21] Cache emptyAdvancedSettings object instead of calculating it on the fly. --- .../follower_index_form/advanced_settings_fields.js | 4 ++++ .../app/components/follower_index_form/follower_index_form.js | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js index d26501aac6432..8daa73c61f674 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -144,3 +144,7 @@ export const advancedSettingsFields = [ validator: Joi.string().empty(''), }, ]; + +export const emptyAdvancedSettings = advancedSettingsFields.reduce((obj, advancedSetting) => { + return { ...obj, [advancedSetting.field]: '' }; +}, {}); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index d4353aebcf2bc..5b250d8e845be 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -36,7 +36,7 @@ import { loadIndices } from '../../services/api'; import { API_STATUS } from '../../constants'; import { SectionError } from '../section_error'; import { FormEntryRow } from '../form_entry_row'; -import { advancedSettingsFields } from './advanced_settings_fields'; +import { advancedSettingsFields, emptyAdvancedSettings } from './advanced_settings_fields'; const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); @@ -53,7 +53,7 @@ const getEmptyFollowerIndex = (remoteClusters) => ({ name: '', remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', leaderIndex: '', - ...advancedSettingsFields.reduce((acc, advancedSetting) => ({ ...acc, [advancedSetting.field]: '' }), {}) + ...emptyAdvancedSettings, }); /** From f4902814521a49b5b9df783c162f61b73657a9b8 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 16 Jan 2019 14:45:55 -0800 Subject: [PATCH 13/21] Invoke trim within validateIndexName(). --- .../app/components/follower_index_form/follower_index_form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index 5b250d8e845be..6b9541cbf8069 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -128,7 +128,7 @@ export const FollowerIndexForm = injectI18n( } validateIndexName = async (name) => { - if (!name || !name.trim) { + if (!name || !name.trim()) { return; } From a5c49cf810e80007ec51d3fdbe338c478eaec9ce Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 16 Jan 2019 16:25:03 -0800 Subject: [PATCH 14/21] Show loading feedback while index name is being validated. - Show fatal error if validation fails without an Angular $http error. --- .../follower_index_form.js | 73 +++++++++++++++---- .../public/app/components/form_entry_row.js | 5 +- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index 6b9541cbf8069..261d1c19e3462 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -10,6 +10,7 @@ import { debounce } from 'lodash'; import { i18n } from '@kbn/i18n'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; +import { fatalError } from 'ui/notify'; import { EuiButton, @@ -105,6 +106,7 @@ export const FollowerIndexForm = injectI18n( fieldsErrors: {}, areErrorsVisible: false, areAdvancedSettingsVisible: false, + isValidatingIndexName: false, }; this.validateIndexName = debounce(this.validateIndexName, 500); @@ -120,18 +122,27 @@ export const FollowerIndexForm = injectI18n( onFieldsErrorChange = (errors) => { this.setState(updateFormErrors(errors)); - } + }; onIndexNameChange = ({ name }) => { this.onFieldsChange({ name }); - this.validateIndexName(name); - } - validateIndexName = async (name) => { if (!name || !name.trim()) { + this.setState({ + isValidatingIndexName: false, + }); + return; } + this.setState({ + isValidatingIndexName: true, + }); + + this.validateIndexName(name); + }; + + validateIndexName = async (name) => { const { intl } = this.props; try { @@ -144,10 +155,27 @@ export const FollowerIndexForm = injectI18n( }); this.setState(updateFormErrors({ name: { message, alwaysVisible: true } })); } - } catch (err) { - // Silently fail... + + this.setState({ + isValidatingIndexName: false, + }); + } catch (error) { + // Expect an error in the shape provided by Angular's $http service. + if (error && error.data) { + // All validation does is check for a name collision, so we can just let the user attempt + // to save the follower index and get an error back from the API. + this.setState({ + isValidatingIndexName: false, + }); + } + + // This error isn't an HTTP error, so let the fatal error screen tell the user something + // unexpected happened. + fatalError(error, i18n.translate('xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', { + defaultMessage: 'Follower Index Forn index name validation', + })); } - } + }; onClusterChange = (remoteCluster) => { this.onFieldsChange({ remoteCluster }); @@ -218,6 +246,7 @@ export const FollowerIndexForm = injectI18n( areErrorsVisible, areAdvancedSettingsVisible, fieldsErrors, + isValidatingIndexName, } = this.state; const toggleAdvancedSettingButtonLabel = areAdvancedSettingsVisible @@ -236,6 +265,27 @@ export const FollowerIndexForm = injectI18n( /** * Follower index name */ + + const indexNameHelpText = ( + + {isValidatingIndexName && ( +

+ +

+ )} +

+ {indexNameIllegalCharacters} }} + /> +

+
+ ); + const renderFollowerIndexName = () => ( {indexNameIllegalCharacters} }} - /> - )} + helpText={indexNameHelpText} validator={indexNameValidator} + isLoading={isValidatingIndexName} disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onIndexNameChange} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index 990de02d41880..b667e108a57ac 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -53,6 +53,7 @@ export class FormEntryRow extends PureComponent { PropTypes.string, PropTypes.number ]).isRequired, + isLoading: PropTypes.bool, error: PropTypes.object, disabled: PropTypes.bool, areErrorsVisible: PropTypes.bool.isRequired, @@ -80,7 +81,7 @@ export class FormEntryRow extends PureComponent { } renderField = (isInvalid) => { - const { value, validator, disabled } = this.props; + const { value, validator, disabled, isLoading } = this.props; switch (validator._type) { case "number": return ( @@ -89,6 +90,7 @@ export class FormEntryRow extends PureComponent { value={value} onChange={e => this.onFieldChange(e.target.value)} disabled={disabled === true} + isLoading={isLoading} fullWidth /> ); @@ -99,6 +101,7 @@ export class FormEntryRow extends PureComponent { value={value} onChange={e => this.onFieldChange(e.target.value)} disabled={disabled === true} + isLoading={isLoading} fullWidth /> ); From c7b28c531f10e69b1fcb82ccdba261a969360457 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 16 Jan 2019 18:00:09 -0800 Subject: [PATCH 15/21] Ignore advanced settings input when that section is hidden. - Cache and restore input when the section is shown again. - Add 'Advanced settings' section title and description. - Fix heading hierarchy so there are no gaps between levels. - Move validation out of form_entry_row and into follower_index_form so we can revalidate the entire form whenever the a field's input changes. --- .../follower_index_form.js | 220 +++++++++++++----- .../public/app/components/form_entry_row.js | 42 +--- 2 files changed, 167 insertions(+), 95 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index 261d1c19e3462..d0a1951b6fa16 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -22,16 +22,17 @@ import { EuiFlexItem, EuiForm, EuiFormRow, + EuiHorizontalRule, EuiLoadingKibana, EuiLoadingSpinner, EuiOverlayMask, EuiSpacer, + EuiSuperSelect, EuiText, EuiTitle, - EuiSuperSelect, } from '@elastic/eui'; -import { indexNameValidator } from '../../services/input_validation'; +import { indexNameValidator, i18nValidationErrorMessages } from '../../services/input_validation'; import routing from '../../services/routing'; import { loadIndices } from '../../services/api'; import { API_STATUS } from '../../constants'; @@ -77,6 +78,17 @@ export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ } }); +const parseError = (err) => { + if (!err) { + return null; + } + + const error = err.details[0]; + const { type, context } = error; + const message = i18nValidationErrorMessages[type](context); + return { message }; +}; + export const FollowerIndexForm = injectI18n( class extends PureComponent { static propTypes = { @@ -114,14 +126,27 @@ export const FollowerIndexForm = injectI18n( onFieldsChange = (fields) => { this.setState(updateFields(fields)); + this.setState(updateFormErrors(this.getFieldsErrors(fields))); if (this.props.apiError) { this.props.clearApiError(); } }; - onFieldsErrorChange = (errors) => { - this.setState(updateFormErrors(errors)); + getFieldsErrors = (changedFields) => { + const newFields = { + ...this.state.fields, + ...changedFields, + }; + + return advancedSettingsFields.reduce((errors, advancedSetting) => { + const { field, validator, label } = advancedSetting; + const value = newFields[field]; + const result = validator.label(label).validate(value); + const error = parseError(result.error); + errors[field] = error; + return errors; + }, {}); }; onIndexNameChange = ({ name }) => { @@ -186,7 +211,34 @@ export const FollowerIndexForm = injectI18n( }; toggleAdvancedSettings = () => { - this.setState(({ areAdvancedSettingsVisible }) => ({ areAdvancedSettingsVisible: !areAdvancedSettingsVisible })); + this.setState(({ areAdvancedSettingsVisible, cachedAdvancedSettings }) => { + // Hide settings, clear fields, and create cache. + if (areAdvancedSettingsVisible) { + const fields = this.getFields(); + + const newCachedAdvancedSettings = advancedSettingsFields.reduce((cache, { field }) => { + const value = fields[field]; + if (value !== '') { + cache[field] = value; + } + return cache; + }, {}); + + this.onFieldsChange(emptyAdvancedSettings); + + return { + areAdvancedSettingsVisible: false, + cachedAdvancedSettings: newCachedAdvancedSettings, + }; + } + + // Show settings and restore fields from the cache. + this.onFieldsChange(cachedAdvancedSettings); + return { + areAdvancedSettingsVisible: true, + cachedAdvancedSettings: {}, + }; + }); } isFormValid() { @@ -249,19 +301,6 @@ export const FollowerIndexForm = injectI18n( isValidatingIndexName, } = this.state; - const toggleAdvancedSettingButtonLabel = areAdvancedSettingsVisible - ? ( - - ) : ( - - ); - /** * Follower index name */ @@ -286,14 +325,23 @@ export const FollowerIndexForm = injectI18n(
); + const indexNameLabel = i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameTitle', { + defaultMessage: 'Name' + } + ); + const renderFollowerIndexName = () => ( +

{indexNameLabel}

+ + )} + label={indexNameLabel} description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionFollowerIndexNameDescription', { defaultMessage: 'A name for the follower index.' })} @@ -303,7 +351,6 @@ export const FollowerIndexForm = injectI18n( disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onIndexNameChange} - onErrorUpdate={this.onFieldsErrorChange} /> ); @@ -322,12 +369,12 @@ export const FollowerIndexForm = injectI18n( -

+

-

+ )} description={( @@ -353,6 +400,7 @@ export const FollowerIndexForm = injectI18n( options={remoteClustersOptions} valueOfSelected={followerIndex.remoteCluster} onChange={this.onClusterChange} + fullWidth /> )} { !isNew && ( @@ -371,14 +419,24 @@ export const FollowerIndexForm = injectI18n( /** * Leader index */ + + const leaderIndexLabel = i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexTitle', { + defaultMessage: 'Leader index' + } + ); + const renderLeaderIndex = () => ( +

{leaderIndexLabel}

+ + )} + label={leaderIndexLabel} description={i18n.translate('xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription', { defaultMessage: 'The leader index you want to replicate from the remote cluster.' })} @@ -393,43 +451,91 @@ export const FollowerIndexForm = injectI18n( disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onFieldsChange} - onErrorUpdate={this.onFieldsErrorChange} /> ); /** * Advanced settings */ + + const toggleAdvancedSettingButtonLabel = areAdvancedSettingsVisible + ? ( + + ) : ( + + ); + const renderAdvancedSettings = () => ( - - { toggleAdvancedSettingButtonLabel } - - + + + +

+ +

+ + )} + description={( + +

+ +

+ + + { toggleAdvancedSettingButtonLabel } + +
+ )} + fullWidth + /> + {areAdvancedSettingsVisible && ( - advancedSettingsFields.map((advancedSetting) => { - const { field, label, description, helpText, validator } = advancedSetting; - return ( - - ); - }) + + + + {advancedSettingsFields.map((advancedSetting) => { + const { field, label, description, helpText, validator } = advancedSetting; + return ( + +

{label}

+ + )} + label={label} + description={description} + helpText={helpText} + validator={validator} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onFieldsChange} + /> + ); + })} +
)} + +
); @@ -446,7 +552,6 @@ export const FollowerIndexForm = injectI18n( return ( - + + ); }; @@ -530,9 +637,8 @@ export const FollowerIndexForm = injectI18n( {renderAdvancedSettings()} - + {renderFormErrorWarning()} - {renderActions()} ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index b667e108a57ac..07035fae61f6d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -4,21 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ - import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { debounce } from 'lodash'; import { - EuiTitle, EuiFieldText, EuiFieldNumber, EuiDescribedFormGroup, EuiFormRow, } from '@elastic/eui'; -import { i18nValidationErrorMessages } from '../services/input_validation'; - /** * State transitions: fields update */ @@ -29,55 +24,29 @@ export const updateFields = (newValues) => ({ fields }) => ({ }, }); -const parseError = (err) => { - if (!err) { - return null; - } - - const [error] = err.details; // Use the first error in the details array (error.details[0]) - const { type, context } = error; - const message = i18nValidationErrorMessages[type](context); - return { message }; -}; - export class FormEntryRow extends PureComponent { static propTypes = { + title: PropTypes.node, label: PropTypes.node, description: PropTypes.node, helpText: PropTypes.node, validator: PropTypes.object, onValueUpdate: PropTypes.func.isRequired, - onErrorUpdate: PropTypes.func.isRequired, field: PropTypes.string.isRequired, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number ]).isRequired, isLoading: PropTypes.bool, - error: PropTypes.object, + error: PropTypes.node, disabled: PropTypes.bool, areErrorsVisible: PropTypes.bool.isRequired, }; - componentDidMount() { - this.validateField(this.props.value); - this.validateField = debounce(this.validateField.bind(this), 300); - } - onFieldChange = (value) => { const { field, onValueUpdate, validator } = this.props; const isNumber = validator._type === 'number'; onValueUpdate({ [field]: isNumber ? parseInt(value, 10) : value }); - - this.validateField(value); - } - - validateField = (value) => { - const { field, validator, label, onErrorUpdate } = this.props; - const result = validator.label(label).validate(value); - const error = parseError(result.error); - - onErrorUpdate({ [field]: error }); } renderField = (isInvalid) => { @@ -112,6 +81,7 @@ export class FormEntryRow extends PureComponent { const { field, error, + title, label, description, helpText, @@ -123,11 +93,7 @@ export class FormEntryRow extends PureComponent { return ( -

{label}

- - )} + title={title} description={description} fullWidth key={field} From 9d3901238d5d330a6120f64f53456df1cc2653ec Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 17 Jan 2019 13:18:49 -0800 Subject: [PATCH 16/21] Remove dependency upon Joi. - Uncouple FormEntryRow from Joi. - Define validators as plain functions, to allow for bytes and time format validation. - Provide flexibility in how error messages are written. --- .../advanced_settings_fields.js | 16 +- .../follower_index_form.js | 63 ++++---- .../public/app/components/form_entry_row.js | 14 +- .../public/app/services/input_validation.js | 142 ++++++++---------- 4 files changed, 109 insertions(+), 126 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js index 8daa73c61f674..1e006c4a03179 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; import { i18n } from '@kbn/i18n'; export const advancedSettingsFields = [ @@ -20,7 +19,7 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' } ), - validator: Joi.number().empty(''), + type: 'number', }, { field: 'maxOutstandingReadRequests', label: i18n.translate( @@ -31,7 +30,7 @@ export const advancedSettingsFields = [ description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.' }), - validator: Joi.number().empty(''), + type: 'number', }, { field: 'maxReadRequestSize', label: i18n.translate( @@ -42,7 +41,6 @@ export const advancedSettingsFields = [ description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.' }), - validator: Joi.string().empty(''), }, { field: 'maxWriteRequestOperationCount', label: i18n.translate( @@ -55,7 +53,7 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' } ), - validator: Joi.number().empty(''), + type: 'number', }, { field: 'maxWriteRequestSize', label: i18n.translate( @@ -68,7 +66,6 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' } ), - validator: Joi.string().empty(''), }, { field: 'maxOutstandingWriteRequests', label: i18n.translate( @@ -81,7 +78,7 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum number of outstanding write requests on the follower.' } ), - validator: Joi.number().empty(''), + type: 'number', }, { field: 'maxWriteBufferCount', label: i18n.translate( @@ -96,7 +93,7 @@ export const advancedSettingsFields = [ operations goes below the limit.` } ), - validator: Joi.number().empty(''), + type: 'number', }, { field: 'maxWriteBufferSize', label: i18n.translate( @@ -111,7 +108,6 @@ export const advancedSettingsFields = [ of queued operations goes below the limit.` } ), - validator: Joi.string().empty(''), }, { field: 'maxRetryDelay', label: i18n.translate( @@ -125,7 +121,6 @@ export const advancedSettingsFields = [ an exponential backoff strategy is employed when retrying.` } ), - validator: Joi.string().empty(''), }, { field: 'readPollTimeout', label: i18n.translate( @@ -141,7 +136,6 @@ export const advancedSettingsFields = [ then the follower will immediately attempt to read from the leader again.` } ), - validator: Joi.string().empty(''), }, ]; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index d0a1951b6fa16..c2c2d890e10ef 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -32,7 +32,7 @@ import { EuiTitle, } from '@elastic/eui'; -import { indexNameValidator, i18nValidationErrorMessages } from '../../services/input_validation'; +import { indexNameValidator, leaderIndexValidator } from '../../services/input_validation'; import routing from '../../services/routing'; import { loadIndices } from '../../services/api'; import { API_STATUS } from '../../constants'; @@ -42,6 +42,15 @@ import { advancedSettingsFields, emptyAdvancedSettings } from './advanced_settin const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); +const fieldToValidatorMap = advancedSettingsFields.reduce((map, advancedSetting) => { + const { field, validator } = advancedSetting; + map[field] = validator; + return map; +}, { + 'name': indexNameValidator, + 'leaderIndex': leaderIndexValidator, +}); + const getFirstConnectedCluster = (clusters) => { for (let i = 0; i < clusters.length; i++) { if (clusters[i].isConnected) { @@ -78,17 +87,6 @@ export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ } }); -const parseError = (err) => { - if (!err) { - return null; - } - - const error = err.details[0]; - const { type, context } = error; - const message = i18nValidationErrorMessages[type](context); - return { message }; -}; - export const FollowerIndexForm = injectI18n( class extends PureComponent { static propTypes = { @@ -112,10 +110,12 @@ export const FollowerIndexForm = injectI18n( ...this.props.followerIndex, }; + const fieldsErrors = this.getFieldsErrors(followerIndex); + this.state = { isNew, followerIndex, - fieldsErrors: {}, + fieldsErrors, areErrorsVisible: false, areAdvancedSettingsVisible: false, isValidatingIndexName: false, @@ -126,25 +126,29 @@ export const FollowerIndexForm = injectI18n( onFieldsChange = (fields) => { this.setState(updateFields(fields)); - this.setState(updateFormErrors(this.getFieldsErrors(fields))); + + const newFields = { + ...this.state.fields, + ...fields, + }; + + this.setState(updateFormErrors(this.getFieldsErrors(newFields))); if (this.props.apiError) { this.props.clearApiError(); } }; - getFieldsErrors = (changedFields) => { - const newFields = { - ...this.state.fields, - ...changedFields, - }; - - return advancedSettingsFields.reduce((errors, advancedSetting) => { - const { field, validator, label } = advancedSetting; + getFieldsErrors = (newFields) => { + return Object.keys(newFields).reduce((errors, field) => { + const validator = fieldToValidatorMap[field]; const value = newFields[field]; - const result = validator.label(label).validate(value); - const error = parseError(result.error); - errors[field] = error; + + if (validator) { + const error = validator(value); + errors[field] = error; + } + return errors; }, {}); }; @@ -242,7 +246,7 @@ export const FollowerIndexForm = injectI18n( } isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === null); + return Object.values(this.state.fieldsErrors).every(error => error === undefined); } sendForm = () => { @@ -311,7 +315,7 @@ export const FollowerIndexForm = injectI18n(

)} @@ -346,7 +350,6 @@ export const FollowerIndexForm = injectI18n( defaultMessage: 'A name for the follower index.' })} helpText={indexNameHelpText} - validator={indexNameValidator} isLoading={isValidatingIndexName} disabled={!isNew} areErrorsVisible={areErrorsVisible} @@ -447,7 +450,6 @@ export const FollowerIndexForm = injectI18n( values={{ characterList: {indexNameIllegalCharacters} }} /> )} - validator={indexNameValidator} disabled={!isNew} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onFieldsChange} @@ -511,7 +513,7 @@ export const FollowerIndexForm = injectI18n( {advancedSettingsFields.map((advancedSetting) => { - const { field, label, description, helpText, validator } = advancedSetting; + const { field, label, description, helpText } = advancedSetting; return ( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index 07035fae61f6d..52501497fc867 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -30,7 +30,7 @@ export class FormEntryRow extends PureComponent { label: PropTypes.node, description: PropTypes.node, helpText: PropTypes.node, - validator: PropTypes.object, + type: PropTypes.string, onValueUpdate: PropTypes.func.isRequired, field: PropTypes.string.isRequired, value: PropTypes.oneOfType([ @@ -44,15 +44,15 @@ export class FormEntryRow extends PureComponent { }; onFieldChange = (value) => { - const { field, onValueUpdate, validator } = this.props; - const isNumber = validator._type === 'number'; + const { field, onValueUpdate, type } = this.props; + const isNumber = type === 'number'; onValueUpdate({ [field]: isNumber ? parseInt(value, 10) : value }); } renderField = (isInvalid) => { - const { value, validator, disabled, isLoading } = this.props; - switch (validator._type) { - case "number": + const { value, type, disabled, isLoading } = this.props; + switch (type) { + case 'number': return ( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js index 808f1e682b7f1..877fc1d5a6105 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/input_validation.js @@ -5,94 +5,82 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import Joi from 'joi'; import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; -export const i18nValidationErrorMessages = { - 'any.empty': ({ label }) => ( - i18n.translate('xpack.crossClusterReplication.formInputValidation.errorEmpty', { - defaultMessage: `{label} is required.`, - values: { label } - }) - ), - 'number.base': ({ label }) => ( - i18n.translate('xpack.crossClusterReplication.formInputValidation.notNumberError', { - defaultMessage: `{label} must be a number.`, - values: { label } - }) - ), - 'string.firstChar': ({ label, char }) => ( - {char} - }} - /> - ), - 'string.illegalChars': ({ label, chars }) => ( - {chars} - }} - /> - ) +const isEmpty = value => { + return !value || !value.trim().length; }; -const findCharactersInString = (string, chars) => ( - chars.reduce((chars, char) => { - if (string.includes(char)) { +const beginsWithPeriod = value => { + return value[0] === '.'; +}; + +const findIllegalCharacters = value => { + return INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { + if (value.includes(char)) { chars.push(char); } return chars; - }, []) -); + }, []); +}; -const advancedStringValidation = (joi) => ({ - base: joi.string(), - name: 'extendedString', - language: { - firstCharNotAllowed: `can't begin with a {{char}}.`, - illegalChars: `can't contain the following character(s): {{illegalChars}}.`, - }, - rules: [ - { - name: 'firstCharNotAllowed', - params: { - char: joi.string().required() - }, - validate({ char }, value, state, options) { - if (value[0] === char) { - return this.createError('string.firstChar', { v: value, char }, state, options); - } +export const indexNameValidator = (value) => { + if (isEmpty(value)) { + return [( + + )]; + } - return value; // Everything is OK - } - }, - { - name: 'illegalChars', - params: { - chars: joi.array().items(joi.string()).required() - }, - validate({ chars }, value, state, options) { - const illegalCharacters = findCharactersInString(value, chars); - if (illegalCharacters.length) { - return this.createError('string.illegalChars', { v: value, chars: illegalCharacters.join(' ') }, state, options); - } + if (beginsWithPeriod(value)) { + return [( + + )]; + } - return value; // Everything is OK - } - } - ] -}); + const illegalCharacters = findIllegalCharacters(value); + + if (illegalCharacters.length) { + return [( + {illegalCharacters.join(' ')} }} + /> + )]; + } -export const customJoi = Joi.extend(advancedStringValidation); // Add extension for advanced string validations + return undefined; +}; + +export const leaderIndexValidator = (value) => { + if (isEmpty(value)) { + return [( + + )]; + } + + const illegalCharacters = findIllegalCharacters(value); -export const indexNameValidator = customJoi.extendedString().firstCharNotAllowed('.').illegalChars(INDEX_ILLEGAL_CHARACTERS_VISIBLE); + if (illegalCharacters.length) { + return [( + {illegalCharacters.join(' ')} }} + /> + )]; + } + + return undefined; +}; From 4f6b6e98aaed715b76d3310872860ac09fa60c4c Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 17 Jan 2019 14:37:40 -0800 Subject: [PATCH 17/21] Update snapshots. --- .../follower_index_form.test.js.snap | 0 .../remote_cluster_list.test.js.snap | 132 +++++++++--------- 2 files changed, 64 insertions(+), 68 deletions(-) rename x-pack/plugins/cross_cluster_replication/public/app/components/{ => follower_index_form}/__snapshots__/follower_index_form.test.js.snap (100%) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap similarity index 100% rename from x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap rename to x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap index cc7ca735d1767..0f8537289951f 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_list/__snapshots__/remote_cluster_list.test.js.snap @@ -2,90 +2,86 @@ exports[`RemoteClusterList renders empty state when loading is complete and there are no clusters 1`] = `
+ + + +
+ - - - - + Add your first remote cluster +
+
+

+ Remote clusters create a uni-directional connection from your local cluster to other clusters. +

+
+ +
+ From d01516d3c7b2d9b6fe6d61f1c8eb81fba8de8c0c Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 17 Jan 2019 17:41:08 -0800 Subject: [PATCH 18/21] Fix bug caused by FormEntryRow receiving an error object. --- .../follower_index_form/follower_index_form.js | 18 +++++++++++------- .../public/app/components/form_entry_row.js | 7 +++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index c2c2d890e10ef..ad1400189ef01 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -172,17 +172,21 @@ export const FollowerIndexForm = injectI18n( }; validateIndexName = async (name) => { - const { intl } = this.props; - try { const indices = await loadIndices(); const doesExist = indices.some(index => index.name === name); if (doesExist) { - const message = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexForm.indexAlreadyExistError', - defaultMessage: 'An index with the same name already exists.' - }); - this.setState(updateFormErrors({ name: { message, alwaysVisible: true } })); + const error = { + message: ( + + ), + alwaysVisible: true, + }; + + this.setState(updateFormErrors({ name: error })); } this.setState({ diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index 52501497fc867..59b6761e54367 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -38,7 +38,10 @@ export class FormEntryRow extends PureComponent { PropTypes.number ]).isRequired, isLoading: PropTypes.bool, - error: PropTypes.node, + error: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.object, + ]), disabled: PropTypes.bool, areErrorsVisible: PropTypes.bool.isRequired, }; @@ -101,7 +104,7 @@ export class FormEntryRow extends PureComponent { From cacfcf19859756b35d027c24628b303e8cd7c212 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 17 Jan 2019 17:41:41 -0800 Subject: [PATCH 19/21] Mark advanced settings fields as optional. --- .../advanced_settings_fields.js | 70 ++++++++++++++++--- .../follower_index_form.js | 6 +- .../public/app/components/form_entry_row.js | 2 +- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js index 1e006c4a03179..6eeb303639046 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const advancedSettingsFields = [ { field: 'maxReadRequestOperationCount', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountTitle', { defaultMessage: 'Max read request operation count' } @@ -19,10 +19,15 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum number of operations to pull per read from the remote cluster.' } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestOperationCountLabel', { + defaultMessage: 'Max read request operation count (optional)' + } + ), type: 'number', }, { field: 'maxOutstandingReadRequests', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsTitle', { defaultMessage: 'Max outstanding read requests' } @@ -30,10 +35,15 @@ export const advancedSettingsFields = [ description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsDescription', { defaultMessage: 'The maximum number of outstanding read requests from the remote cluster.' }), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingReadRequestsLabel', { + defaultMessage: 'Max outstanding read requests (optional)' + } + ), type: 'number', }, { field: 'maxReadRequestSize', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeTitle', { defaultMessage: 'Max read request size' } @@ -41,9 +51,14 @@ export const advancedSettingsFields = [ description: i18n.translate('xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeDescription', { defaultMessage: 'The maximum size in bytes of per read of a batch of operations pulled from the remote cluster.' }), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxReadRequestSizeLabel', { + defaultMessage: 'Max read request size (optional)' + } + ), }, { field: 'maxWriteRequestOperationCount', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountTitle', { defaultMessage: 'Max write request operation count' } @@ -53,10 +68,15 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum number of operations per bulk write request executed on the follower.' } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestOperationCountLabel', { + defaultMessage: 'Max write request operation count (optional)' + } + ), type: 'number', }, { field: 'maxWriteRequestSize', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeTitle', { defaultMessage: 'Max write request size' } @@ -66,9 +86,14 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum total bytes of operations per bulk write request executed on the follower.' } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteRequestSizeLabel', { + defaultMessage: 'Max write request size (optional)' + } + ), }, { field: 'maxOutstandingWriteRequests', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsTitle', { defaultMessage: 'Max outstanding write requests' } @@ -78,10 +103,15 @@ export const advancedSettingsFields = [ defaultMessage: 'The maximum number of outstanding write requests on the follower.' } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxOutstandingWriteRequestsLabel', { + defaultMessage: 'Max outstanding write requests (optional)' + } + ), type: 'number', }, { field: 'maxWriteBufferCount', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountTitle', { defaultMessage: 'Max write buffer count' } @@ -93,10 +123,15 @@ export const advancedSettingsFields = [ operations goes below the limit.` } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferCountLabel', { + defaultMessage: 'Max write buffer count (optional)' + } + ), type: 'number', }, { field: 'maxWriteBufferSize', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeTitle', { defaultMessage: 'Max write buffer size' } @@ -108,9 +143,14 @@ export const advancedSettingsFields = [ of queued operations goes below the limit.` } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxWriteBufferSizeLabel', { + defaultMessage: 'Max write buffer size (optional)' + } + ), }, { field: 'maxRetryDelay', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayTitle', { defaultMessage: 'Max retry delay' } @@ -121,9 +161,14 @@ export const advancedSettingsFields = [ an exponential backoff strategy is employed when retrying.` } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.maxRetryDelayLabel', { + defaultMessage: 'Max retry delay (optional)' + } + ), }, { field: 'readPollTimeout', - label: i18n.translate( + title: i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutTitle', { defaultMessage: 'Read poll timeout' } @@ -136,6 +181,11 @@ export const advancedSettingsFields = [ then the follower will immediately attempt to read from the leader again.` } ), + label: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.advancedSettings.readPollTimeoutLabel', { + defaultMessage: 'Read poll timeout (optional)' + } + ), }, ]; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js index ad1400189ef01..db9258414b32c 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js @@ -517,7 +517,7 @@ export const FollowerIndexForm = injectI18n( {advancedSettingsFields.map((advancedSetting) => { - const { field, label, description, helpText } = advancedSetting; + const { field, title, description, label, helpText } = advancedSetting; return ( -

{label}

+

{title}

)} - label={label} description={description} + label={label} helpText={helpText} areErrorsVisible={areErrorsVisible} onValueUpdate={this.onFieldsChange} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js index 59b6761e54367..bfc7d8e3c6c7b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/form_entry_row.js @@ -27,8 +27,8 @@ export const updateFields = (newValues) => ({ fields }) => ({ export class FormEntryRow extends PureComponent { static propTypes = { title: PropTypes.node, - label: PropTypes.node, description: PropTypes.node, + label: PropTypes.node, helpText: PropTypes.node, type: PropTypes.string, onValueUpdate: PropTypes.func.isRequired, From 228aa0857d61829cb2afddfacf7f717720394b73 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 17 Jan 2019 17:57:31 -0800 Subject: [PATCH 20/21] Add help text for advanced settings fields, with links to docs. --- .../advanced_settings_fields.js | 38 +++++++++++++++++++ .../app/services/documentation_links.js | 2 + 2 files changed, 40 insertions(+) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js index 6eeb303639046..1d51f80a39118 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -4,7 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { byteUnitsUrl, timeUnitsUrl } from '../../services/documentation_links'; + +const byteUnitsHelpText = ( + + + + ) }} + /> +); + +const timeUnitsHelpText = ( + + + + ) }} + /> +); export const advancedSettingsFields = [ { @@ -56,6 +89,7 @@ export const advancedSettingsFields = [ defaultMessage: 'Max read request size (optional)' } ), + helpText: byteUnitsHelpText, }, { field: 'maxWriteRequestOperationCount', title: i18n.translate( @@ -91,6 +125,7 @@ export const advancedSettingsFields = [ defaultMessage: 'Max write request size (optional)' } ), + helpText: byteUnitsHelpText, }, { field: 'maxOutstandingWriteRequests', title: i18n.translate( @@ -148,6 +183,7 @@ export const advancedSettingsFields = [ defaultMessage: 'Max write buffer size (optional)' } ), + helpText: byteUnitsHelpText, }, { field: 'maxRetryDelay', title: i18n.translate( @@ -166,6 +202,7 @@ export const advancedSettingsFields = [ defaultMessage: 'Max retry delay (optional)' } ), + helpText: timeUnitsHelpText, }, { field: 'readPollTimeout', title: i18n.translate( @@ -186,6 +223,7 @@ export const advancedSettingsFields = [ defaultMessage: 'Read poll timeout (optional)' } ), + helpText: timeUnitsHelpText, }, ]; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js index 67e0a9fdc2054..585ca7e0f5cf1 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js @@ -10,3 +10,5 @@ const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LIN export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; +export const byteUnitsUrl = `${esBase}/common-options.html#byte-units`; +export const timeUnitsUrl = `${esBase}/common-options.html#time-units`; From c70b9977207bbf8d1552ca3bfe40146531248caf Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Fri, 18 Jan 2019 08:50:17 -0800 Subject: [PATCH 21/21] Fix i18n key collisions. --- .../follower_index_form/advanced_settings_fields.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js index 1d51f80a39118..da0dfe854ba3a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js @@ -11,12 +11,12 @@ import { byteUnitsUrl, timeUnitsUrl } from '../../services/documentation_links'; const byteUnitsHelpText = ( @@ -26,12 +26,12 @@ const byteUnitsHelpText = ( const timeUnitsHelpText = (