From 298803d51855eb1be7cdfd9558d2a4522e75db1b 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/23] [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 00000000000000..7663a82104e666 --- /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 c06856f7306955..a2269e28c2da86 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 5bf4ed3512a573..0866e703f8c2e5 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 @@ -15,3 +15,4 @@ export { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_p export { FollowerIndexDeleteProvider } from './follower_index_delete_provider'; export { FollowerIndexForm } from './follower_index_form'; export { FollowerIndexPageTitle } from './follower_index_page_title'; +export { AdvancedSettingsForm } from './advanced_settings_form'; From 5d81b44931505bc0789ee9ecbfb68a2eb31c1471 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/23] 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 7663a82104e666..8b62ea26039adc 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/23] 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 8b62ea26039adc..6cccf862ecf4d3 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 a2269e28c2da86..51ac35125e803c 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 00000000000000..10df97fafcf071 --- /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 a2f6bea3b9a29140c51ff953939aa8975fd75d31 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/23] 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 6cccf862ecf4d3..49063c331780e4 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 00000000000000..944c615aae8ddb --- /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 0866e703f8c2e5..4113920eb5b3d0 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 @@ -16,3 +16,4 @@ export { FollowerIndexDeleteProvider } from './follower_index_delete_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'; From 842f88a4846d29d09a66186895d1e787fb7bf7a0 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/23] 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 49063c331780e4..d988ba9b94d4b3 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 5211cf5c9d797541fcaecd6dfca04acfbe545338 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/23] 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 d988ba9b94d4b3..b5239d9bbd8577 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 51ac35125e803c..ba0e0b2fc8dfd1 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 dd9b8bf441c80417ba03615559f24dcd9775696c 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/23] 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 b5239d9bbd8577..00000000000000 --- 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 ba0e0b2fc8dfd1..1b70c51389a710 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 944c615aae8ddb..c847b3874a12b9 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 4113920eb5b3d0..016b5622835266 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 @@ -15,5 +15,4 @@ export { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_p export { FollowerIndexDeleteProvider } from './follower_index_delete_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 00000000000000..3d24a5e988469b --- /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 11fd188374b53a..d590c467e212e7 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 df31e2f3d1c23f..00000000000000 --- 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 10df97fafcf071..36c3bd4be508bc 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 45303297109c3ee5c6adf8bc4df1217c97f9282b 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/23] 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 b8f245bfaefb4d..83154c7e95caca 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 1c20f73287259f..a41a67089d7580 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 7b5a0e453b65fe..6d1da8aa6ff3ef 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 d4b5526c16a6ba..3f08450884dd29 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 5328f5fe35db68..60da90497295ee 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 72c266b52d6b10825fc622a79afd662e342f9910 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/23] 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 1b70c51389a710..27a704382e6803 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 c847b3874a12b9..472c489c95f8e9 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 3d24a5e988469b..02874861ced5ec 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 d590c467e212e7..1e2eb368ee5ede 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 36c3bd4be508bc..808f1e682b7f18 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 f6360eae36a34c1274d486404bdb1f952f6ccdf1 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/23] 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 818045919caf7b..43d1da3f242a2e 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 9aaab0e2b1c782061102f4f820a68e46889e922e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Fri, 11 Jan 2019 11:58:14 +0100 Subject: [PATCH 11/23] Add section to edit follower index --- .../public/app/app.js | 2 + .../app/components/follower_index_form.js | 38 +-- .../auto_follow_pattern_edit.js | 1 + .../follower_index_edit.container.js | 49 ++++ .../follower_index_edit.js | 237 ++++++++++++++++++ .../app/sections/follower_index_edit/index.js | 7 + .../public/app/sections/index.js | 1 + .../public/app/services/api.js | 4 + .../public/app/store/action_types.js | 3 +- .../app/store/actions/follower_index.js | 34 ++- .../app/store/reducers/follower_index.js | 3 + .../public/app/store/selectors/index.js | 3 +- .../server/routes/api/follower_index.js | 29 +++ .../server/routes/api/follower_index.test.js | 1 + 14 files changed, 384 insertions(+), 28 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/app.js b/x-pack/plugins/cross_cluster_replication/public/app/app.js index 9f43d1788258ff..0663b6fae8c356 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js @@ -16,6 +16,7 @@ import { AutoFollowPatternAdd, AutoFollowPatternEdit, FollowerIndexAdd, + FollowerIndexEdit, } from './sections'; export class App extends Component { @@ -54,6 +55,7 @@ export class App extends Component { + 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 27a704382e6803..8248ef709c3f84 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 @@ -78,7 +78,7 @@ export const FollowerIndexForm = injectI18n( followerIndex: PropTypes.object, apiError: PropTypes.object, apiStatus: PropTypes.string.isRequired, - remoteClusters: PropTypes.array.isRequired, + remoteClusters: PropTypes.array, } constructor(props) { @@ -98,7 +98,7 @@ export const FollowerIndexForm = injectI18n( followerIndex, fieldsErrors: {}, areErrorsVisible: false, - areAdvancedSettingsVisible: false, + areAdvancedSettingsVisible: isNew ? false : true, }; this.validateIndexName = debounce(this.validateIndexName, 500); @@ -247,12 +247,14 @@ export const FollowerIndexForm = injectI18n( * Remote Cluster */ const renderRemoteClusterField = () => { - const remoteClustersOptions = this.props.remoteClusters.map(({ name, isConnected }) => ({ - value: name, - inputDisplay: isConnected ? name : `${name} (not connected)`, - disabled: !isConnected, - 'data-test-subj': `option-${name}` - })); + const remoteClustersOptions = this.props.remoteClusters + ? this.props.remoteClusters.map(({ name, isConnected }) => ({ + value: name, + inputDisplay: isConnected ? name : `${name} (not connected)`, + disabled: !isConnected, + 'data-test-subj': `option-${name}` + })) + : {}; return ( ( - - { toggleAdvancedSettingButtonLabel } - - + { isNew && ( + + + { toggleAdvancedSettingButtonLabel } + + + + ) } {areAdvancedSettingsVisible && ( Object.entries(followerIndexFormSchema.advanced).map(([field, schema]) => ( ({ + apiStatus: { + get: getApiStatus(`${scope}-get`)(state), + save: getApiStatus(`${scope}-save`)(state), + }, + apiError: { + get: getApiError(`${scope}-get`)(state), + save: getApiError(`${scope}-save`)(state), + }, + followerIndexId: getSelectedFollowerIndexId('edit')(state), + followerIndex: getSelectedFollowerIndex('edit')(state), +}); + +const mapDispatchToProps = dispatch => ({ + getFollowerIndex: (id) => dispatch(getFollowerIndex(id)), + selectFollowerIndex: (id) => dispatch(selectEditFollowerIndex(id)), + saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex, true)), + clearApiError: () => dispatch(clearApiError(scope)), +}); + +export const FollowerIndexEdit = connect( + mapStateToProps, + mapDispatchToProps +)(FollowerIndexEditView); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js new file mode 100644 index 00000000000000..ac51d212c1d6cf --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -0,0 +1,237 @@ +/* + * 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, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import chrome from 'ui/chrome'; +import { MANAGEMENT_BREADCRUMB } from 'ui/management'; + +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiButton, + EuiCallOut, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import routing from '../../services/routing'; +import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; +import { + FollowerIndexForm, + FollowerIndexPageTitle, + SectionLoading, + SectionError, +} from '../../components'; +import { API_STATUS } from '../../constants'; + +export const FollowerIndexEdit = injectI18n( + class extends PureComponent { + static propTypes = { + getFollowerIndex: PropTypes.func.isRequired, + selectFollowerIndex: PropTypes.func.isRequired, + saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + apiError: PropTypes.object.isRequired, + apiStatus: PropTypes.object.isRequired, + followerIndex: PropTypes.object, + followerIndexId: PropTypes.string, + } + + static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) { + if (lastFollowerIndexId !== followerIndexId) { + return { lastFollowerIndexId: followerIndexId }; + } + return null; + } + + state = { lastFollowerIndexId: undefined } + + componentDidMount() { + const { match: { params: { id } }, selectFollowerIndex } = this.props; + const decodedId = decodeURIComponent(id); + + selectFollowerIndex(decodedId); + + chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, editBreadcrumb ]); + } + + componentDidUpdate(prevProps, prevState) { + const { followerIndex, getFollowerIndex } = this.props; + if (!followerIndex && prevState.lastFollowerIndexId !== this.state.lastFollowerIndexId) { + // Fetch the auto-follow pattern on the server + getFollowerIndex(this.state.lastFollowerIndexId); + } + } + + componentWillUnmount() { + this.props.clearApiError(); + } + + renderLoadingFollowerIndex() { + return ( + + + + ); + } + + renderGetFollowerIndexError(error) { + const { intl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorTitle', + defaultMessage: 'Error loading follower index', + }); + + return ( + + + + + + + + + + + + ); + } + + renderEmptyClusters() { + const { intl, match: { url: currentUrl } } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.emptyRemoteClustersCallOutTitle', + defaultMessage: 'No remote cluster found' + }); + + return ( + + +

+ +

+ + + + +
+
+ ); + } + + renderNoConnectedCluster() { + const { intl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.noRemoteClustersConnectedCallOutTitle', + defaultMessage: 'Remote cluster connection error' + }); + + return ( + + +

+ +

+ + + +
+
+ ); + } + + render() { + const { + saveFollowerIndex, + clearApiError, + apiStatus, + apiError, + followerIndex, + } = this.props; + + /* remove non-editable properties */ + const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars + + return ( + + + + + )} + /> + + {apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()} + + {apiError.get && this.renderGetFollowerIndexError(apiError.get)} + + { followerIndex && ( + + ) } + + + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/index.js new file mode 100644 index 00000000000000..7bc01ebd874e84 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/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 { FollowerIndexEdit } from './follower_index_edit.container'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js index 5a72353730d24d..510812426265f6 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/index.js @@ -8,3 +8,4 @@ export { CrossClusterReplicationHome } from './home'; export { AutoFollowPatternAdd } from './auto_follow_pattern_add'; export { AutoFollowPatternEdit } from './auto_follow_pattern_edit'; export { FollowerIndexAdd } from './follower_index_add'; +export { FollowerIndexEdit } from './follower_index_edit'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js index 4fa43d19f552ef..d122b1fbf17ffe 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js @@ -89,6 +89,10 @@ export const unfollowLeaderIndex = (id) => { return httpClient.put(`${apiPrefix}/follower_indices/${ids}/unfollow`).then(extractData); }; +export const updateFollowerIndex = (id, followerIndex) => ( + httpClient.put(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, followerIndex).then(extractData) +); + /* Stats */ export const loadAutoFollowStats = () => ( httpClient.get(`${apiPrefixIndexManagement}/stats/auto-follow`).then(extractData) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js index 24d8c901af112c..3a9a9c33bafc2a 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/action_types.js @@ -21,8 +21,9 @@ export const AUTO_FOLLOW_PATTERN_DELETE = 'AUTO_FOLLOW_PATTERN_DELETE'; // Follower index export const FOLLOWER_INDEX_SELECT_DETAIL = 'FOLLOWER_INDEX_SELECT_DETAIL'; +export const FOLLOWER_INDEX_SELECT_EDIT = 'FOLLOWER_INDEX_SELECT_EDIT'; export const FOLLOWER_INDEX_LOAD = 'FOLLOWER_INDEX_LOAD'; -export const FOLLOWER_INDEX_GET = 'AUTO_FOLLOW_PATTERN_GET'; +export const FOLLOWER_INDEX_GET = 'FOLLOWER_INDEX_GET'; export const FOLLOWER_INDEX_CREATE = 'FOLLOWER_INDEX_CREATE'; export const FOLLOWER_INDEX_PAUSE = 'FOLLOWER_INDEX_PAUSE'; export const FOLLOWER_INDEX_RESUME = 'FOLLOWER_INDEX_RESUME'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js index 16a1b9a7fb74e5..b3810d5dce39d1 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js @@ -14,6 +14,7 @@ import { pauseFollowerIndex as pauseFollowerIndexRequest, resumeFollowerIndex as resumeFollowerIndexRequest, unfollowLeaderIndex as unfollowLeaderIndexRequest, + updateFollowerIndex as updateFollowerIndexRequest, } from '../../services/api'; import * as t from '../action_types'; import { sendApiRequest } from './api'; @@ -26,6 +27,11 @@ export const selectDetailFollowerIndex = (id) => ({ payload: id }); +export const selectEditFollowerIndex = (id) => ({ + type: t.FOLLOWER_INDEX_SELECT_EDIT, + payload: id +}); + export const loadFollowerIndices = (isUpdating = false) => sendApiRequest({ label: t.FOLLOWER_INDEX_LOAD, @@ -39,25 +45,33 @@ export const loadFollowerIndices = (isUpdating = false) => export const getFollowerIndex = (id) => sendApiRequest({ label: t.FOLLOWER_INDEX_GET, - scope, + scope: `${scope}-get`, handler: async () => ( await getFollowerIndexRequest(id) ) }); -export const saveFollowerIndex = (name, followerIndex) => ( +export const saveFollowerIndex = (name, followerIndex, isUpdating) => ( sendApiRequest({ label: t.FOLLOWER_INDEX_CREATE, status: API_STATUS.SAVING, - scope, - handler: async () => ( - await createFollowerIndexRequest({ name, ...followerIndex }) - ), + scope: `${scope}-save`, + handler: async () => { + if (isUpdating) { + return await updateFollowerIndexRequest(name, followerIndex); + } + return await createFollowerIndexRequest({ name, ...followerIndex }); + }, onSuccess() { - const successMessage = i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { - defaultMessage: `Added follower index '{name}'`, - values: { name }, - }); + const successMessage = isUpdating + ? i18n.translate('xpack.crossClusterReplication.followerIndex.updateAction.successNotificationTitle', { + defaultMessage: `Follower index '{name}' updated successfully`, + values: { name }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { + defaultMessage: `Added follower index '{name}'`, + values: { name }, + }); toastNotifications.addSuccess(successMessage); routing.navigate(`/follower_indices`, undefined, { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js index 07905dbfef0250..a7a9fea1e8eb50 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/reducers/follower_index.js @@ -32,6 +32,9 @@ export const reducer = (state = initialState, action) => { case t.FOLLOWER_INDEX_SELECT_DETAIL: { return { ...state, selectedDetailId: action.payload }; } + case t.FOLLOWER_INDEX_SELECT_EDIT: { + return { ...state, selectedEditId: action.payload }; + } case success(t.FOLLOWER_INDEX_UNFOLLOW): { const byId = { ...state.byId }; const { itemsUnfollowed } = action.payload; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js index b08eda8bc5a8b1..8b0d4f18b21cd3 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/selectors/index.js @@ -6,10 +6,11 @@ import { createSelector } from 'reselect'; import { objectToArray } from '../../services/utils'; +import { API_STATUS } from '../../constants'; // Api export const getApiState = (state) => state.api; -export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope]); +export const getApiStatus = (scope) => createSelector(getApiState, (apiState) => apiState.status[scope] || API_STATUS.IDLE); export const getApiError = (scope) => createSelector(getApiState, (apiState) => apiState.error[scope]); export const isApiAuthorized = (scope) => createSelector(getApiError(scope), (error) => { if (!error) { 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 93c4aa0f486492..4f6cc0c3c4a857 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 @@ -11,6 +11,7 @@ import { deserializeFollowerIndex, deserializeListFollowerIndices, serializeFollowerIndex, + // serializeAdvancedSettings, } from '../../lib/follower_index_serialization'; import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; import { API_BASE_PATH } from '../../../common/constants'; @@ -100,6 +101,34 @@ export const registerFollowerIndexRoutes = (server) => { }, }); + /** + * Edit a follower index + */ + server.route({ + path: `${API_BASE_PATH}/follower_indices/{id}`, + method: 'PUT', + config: { + pre: [ licensePreRouting ] + }, + handler: async (/*request*/) => { + // const callWithRequest = callWithRequestFactory(server, request); + // const { id: name } = request.params; + // const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); + + try { + /** + * We need to first pause the follower and then resume it passing the advanced settings + */ + // TODO: Add this when pause/resume PR will be merged + return 'NOT_IMPLEMENTED'; + } catch(err) { + if (isEsError(err)) { + throw wrapEsError(err); + } + throw wrapUnknownError(err); + } + }, + }); /** * Pauses a follower index diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js index cbf51074fed26e..4a514207d55134 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js @@ -34,6 +34,7 @@ const registerHandlers = () => { 0: 'list', 1: 'get', 2: 'create', + 2: 'edit', 3: 'pause', 4: 'resume', 5: 'unfollow', From 7c7eb7555157cd12d960dcc9873fe0aebd536e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Fri, 11 Jan 2019 15:09:42 +0100 Subject: [PATCH 12/23] Show confirm modal before updating follower index --- .../follower_index_edit.js | 112 ++++++++---------- 1 file changed, 48 insertions(+), 64 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index ac51d212c1d6cf..e89a3d3f0ddc5f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -15,15 +15,16 @@ import { EuiPageBody, EuiPageContent, EuiButton, - EuiCallOut, EuiSpacer, EuiFlexGroup, EuiFlexItem, + EuiOverlayMask, + EuiConfirmModal, + } from '@elastic/eui'; import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { FollowerIndexForm, FollowerIndexPageTitle, @@ -52,7 +53,10 @@ export const FollowerIndexEdit = injectI18n( return null; } - state = { lastFollowerIndexId: undefined } + state = { + lastFollowerIndexId: undefined, + showConfirmModal: false, + } componentDidMount() { const { match: { params: { id } }, selectFollowerIndex } = this.props; @@ -75,6 +79,15 @@ export const FollowerIndexEdit = injectI18n( this.props.clearApiError(); } + saveFollowerIndex = (name, followerIndex) => { + this.inflightPayload = { name, followerIndex }; + this.showConfirmModal(); + } + + showConfirmModal = () => this.setState({ showConfirmModal: true }); + + closeConfirmModal = () => this.setState({ showConfirmModal: false }); + renderLoadingFollowerIndex() { return ( @@ -115,87 +128,56 @@ export const FollowerIndexEdit = injectI18n( ); } - renderEmptyClusters() { - const { intl, match: { url: currentUrl } } = this.props; + renderConfirmModal = () => { + const { followerIndexId, intl } = this.props; const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexEditForm.emptyRemoteClustersCallOutTitle', - defaultMessage: 'No remote cluster found' - }); + id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.title', + defaultMessage: 'Confirm update follower index \'{id}\'', + }, { id: followerIndexId }); - return ( - - -

- -

- - - - -
-
- ); - } - - renderNoConnectedCluster() { - const { intl } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexEditForm.noRemoteClustersConnectedCallOutTitle', - defaultMessage: 'Remote cluster connection error' - }); + const { name, followerIndex } = this.inflightPayload; return ( - - + this.props.saveFollowerIndex(name, followerIndex)} + cancelButtonText={ + intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.cancelButtonText', + defaultMessage: 'Cancel', + }) + } + buttonColor="danger" + confirmButtonText={ + intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.confirmButtonText', + defaultMessage: 'Update', + }) + } >

- - - -
-
+ + ); } render() { const { - saveFollowerIndex, clearApiError, apiStatus, apiError, followerIndex, } = this.props; + const { showConfirmModal } = this.state; + /* remove non-editable properties */ const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars @@ -224,10 +206,12 @@ export const FollowerIndexEdit = injectI18n( followerIndex={rest} apiStatus={apiStatus.save} apiError={apiError.save} - saveFollowerIndex={saveFollowerIndex} + saveFollowerIndex={this.saveFollowerIndex} clearApiError={clearApiError} /> ) } + + { showConfirmModal && this.renderConfirmModal() } From 958b77f038f30cd1f80d71a197a88d2a055a43fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 14 Jan 2019 15:09:41 +0100 Subject: [PATCH 13/23] Add edit icon in table + update server endpoint to pause / resume --- .../follower_index_edit.container.js | 5 +- .../follower_index_edit.js | 15 +- .../components/context_menu/context_menu.js | 35 +++- .../follower_indices_table.js | 184 ++++++++++-------- .../public/app/services/routing.js | 6 +- .../public/register_routes.js | 2 +- .../server/client/elasticsearch_ccr.js | 1 + .../server/routes/api/follower_index.js | 23 ++- 8 files changed, 167 insertions(+), 104 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js index 96f89e2220c82d..84e03cf4a8043b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js @@ -40,7 +40,10 @@ const mapDispatchToProps = dispatch => ({ getFollowerIndex: (id) => dispatch(getFollowerIndex(id)), selectFollowerIndex: (id) => dispatch(selectEditFollowerIndex(id)), saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex, true)), - clearApiError: () => dispatch(clearApiError(scope)), + clearApiError: () => { + dispatch(clearApiError(`${scope}-get`)); + dispatch(clearApiError(`${scope}-save`)); + }, }); export const FollowerIndexEdit = connect( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index e89a3d3f0ddc5f..337b514a62849e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -20,6 +20,7 @@ import { EuiFlexItem, EuiOverlayMask, EuiConfirmModal, + EuiIcon, } from '@elastic/eui'; @@ -60,7 +61,16 @@ export const FollowerIndexEdit = injectI18n( componentDidMount() { const { match: { params: { id } }, selectFollowerIndex } = this.props; - const decodedId = decodeURIComponent(id); + let decodedId; + try { + // When we navigate through the router (history.push) we need to decode both the uri and the id + decodedId = decodeURI(id); + decodedId = decodeURIComponent(decodedId); + } catch (e) { + // This is a page load. I guess that AngularJS router does already a decodeURI so it is not + // necessary in this case. + decodedId = decodeURIComponent(id); + } selectFollowerIndex(decodedId); @@ -132,7 +142,7 @@ export const FollowerIndexEdit = injectI18n( const { followerIndexId, intl } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.title', - defaultMessage: 'Confirm update follower index \'{id}\'', + defaultMessage: 'Update follower index \'{id}\'', }, { id: followerIndexId }); const { name, followerIndex } = this.inflightPayload; @@ -158,6 +168,7 @@ export const FollowerIndexEdit = injectI18n( } >

+ {' '} { + const uri = routing.getFollowerIndexPath(id, '/edit', false); + routing.navigate(uri); + } + render() { const { followerIndices } = this.props; const followerIndicesLength = followerIndices.length; @@ -154,6 +159,18 @@ export class ContextMenuUi extends Component { )} + + { followerIndexNames.length === 1 && ( + this.editFollowerIndex(followerIndexNames[0])} + > + + + ) } ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js index d4365761d30b95..8485aadc1f8721 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js @@ -8,11 +8,10 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { - EuiButtonIcon, + EuiIcon, EuiInMemoryTable, EuiLink, EuiLoadingKibana, - EuiToolTip, EuiOverlayMask, } from '@elastic/eui'; import { API_STATUS } from '../../../../../constants'; @@ -21,6 +20,7 @@ import { FollowerIndexResumeProvider, FollowerIndexUnfollowProvider } from '../../../../../components'; +import routing from '../../../../../services/routing'; import { ContextMenu } from '../context_menu'; export const FollowerIndicesTable = injectI18n( @@ -42,6 +42,11 @@ export const FollowerIndicesTable = injectI18n( }); }; + editFollowerIndex = (id) => { + const uri = routing.getFollowerIndexPath(id, '/edit', false); + routing.navigate(uri); + } + getFilteredIndices = () => { const { followerIndices } = this.props; const { queryText } = this.state; @@ -64,6 +69,100 @@ export const FollowerIndicesTable = injectI18n( getTableColumns() { const { intl, selectFollowerIndex } = this.props; + const actions = [ + /* Resume follower index */ + { + render: ({ name, shards }) => { + const isPaused = !shards || !shards.length; + const label = isPaused + ? intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionResumeDescription', + defaultMessage: 'Resume follower index', + }) + : intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionPauseDescription', + defaultMessage: 'Pause follower index', + }); + + return isPaused ? ( + + {(resumeFollowerIndex) => ( + resumeFollowerIndex(name)}> + + {label} + + )} + + ) : ( + + {(pauseFollowerIndex) => ( + pauseFollowerIndex(name)}> + + {label} + + )} + + ); + }, + }, + /* Unfollow leader index */ + { + render: ({ name }) => { + const label = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionUnfollowDescription', + defaultMessage: 'Unfollow leader index', + }); + + return ( + + {(unfollowLeaderIndex) => ( + unfollowLeaderIndex(name)}> + + {label} + + )} + + ); + }, + }, + /* Edit follower index */ + { + render: ({ name }) => { + const label = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', + defaultMessage: 'Edit follower index', + }); + + return ( + this.editFollowerIndex(name)}> + + {label} + + ); + }, + }, + ]; + return [{ field: 'name', name: intl.formatMessage({ @@ -121,86 +220,7 @@ export const FollowerIndicesTable = injectI18n( id: 'xpack.crossClusterReplication.followerIndexList.table.actionsColumnTitle', defaultMessage: 'Actions', }), - actions: [ - { - render: ({ name, shards }) => { - const isPaused = !shards || !shards.length; - const label = isPaused ? ( - - ) : ( - - ); - - return isPaused ? ( - - - {(resumeFollowerIndex) => ( - resumeFollowerIndex(name)} - /> - )} - - - ) : ( - - - {(pauseFollowerIndex) => ( - pauseFollowerIndex(name)} - /> - )} - - - ); - }, - }, - { - render: ({ name }) => { - const label = ( - - ); - - return ( - - - {(unfollowLeaderIndex) => ( - unfollowLeaderIndex(name)} - /> - )} - - - ); - }, - }, - ], + actions, width: '100px', }]; } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js index fee74529f3fe0f..c3c91e589e5558 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/routing.js @@ -84,8 +84,10 @@ class Routing { return encodeURI(`#${BASE_PATH}/auto_follow_patterns${section}/${encodeURIComponent(name)}`); }; - getFollowerIndexPath = (name, section = '/edit') => { - return encodeURI(`#${BASE_PATH}/follower_indices${section}/${encodeURIComponent(name)}`); + getFollowerIndexPath = (name, section = '/edit', withBase = true) => { + return withBase + ? encodeURI(`#${BASE_PATH}/follower_indices${section}/${encodeURIComponent(name)}`) + : encodeURI(`/follower_indices${section}/${encodeURIComponent(name)}`); }; get reactRouter() { diff --git a/x-pack/plugins/cross_cluster_replication/public/register_routes.js b/x-pack/plugins/cross_cluster_replication/public/register_routes.js index 5030cae7e94c7e..6ab2229f0e6eb8 100644 --- a/x-pack/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/plugins/cross_cluster_replication/public/register_routes.js @@ -20,7 +20,7 @@ if (chrome.getInjected('ccrUiEnabled')) { const unmountReactApp = () => elem && unmountComponentAtNode(elem); - routes.when(`${BASE_PATH}/:section?/:view?/:id?`, { + routes.when(`${BASE_PATH}/:section?/:subsection?/:view?/:id?`, { template: template, controllerAs: 'ccr', controller: class CrossClusterReplicationController { diff --git a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js index f166b12ed60f44..716e4954c69b1f 100644 --- a/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js +++ b/x-pack/plugins/cross_cluster_replication/server/client/elasticsearch_ccr.js @@ -126,6 +126,7 @@ export const elasticsearchJsPlugin = (Client, config, components) => { } } ], + needBody: true, method: 'POST' }); 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 4f6cc0c3c4a857..e2992a42341568 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { callWithRequestFactory } from '../../lib/call_with_request_factory'; import { isEsErrorFactory } from '../../lib/is_es_error_factory'; import { wrapEsError, wrapUnknownError } from '../../lib/error_wrappers'; @@ -11,7 +12,7 @@ import { deserializeFollowerIndex, deserializeListFollowerIndices, serializeFollowerIndex, - // serializeAdvancedSettings, + serializeAdvancedSettings, } from '../../lib/follower_index_serialization'; import { licensePreRoutingFactory } from'../../lib/license_pre_routing_factory'; import { API_BASE_PATH } from '../../../common/constants'; @@ -65,6 +66,11 @@ export const registerFollowerIndexRoutes = (server) => { const response = await callWithRequest('ccr.followerIndexStats', { id }); const followerIndex = response.indices[0]; + if (!followerIndex) { + const error = Boom.notFound(`The follower index "${id}" does not exist.`); + throw(error); + } + return deserializeFollowerIndex(followerIndex); } catch(err) { if (isEsError(err)) { @@ -110,17 +116,20 @@ export const registerFollowerIndexRoutes = (server) => { config: { pre: [ licensePreRouting ] }, - handler: async (/*request*/) => { - // const callWithRequest = callWithRequestFactory(server, request); - // const { id: name } = request.params; - // const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); + handler: async (request) => { + const callWithRequest = callWithRequestFactory(server, request); + const { id: _id } = request.params; + const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); try { /** * We need to first pause the follower and then resume it passing the advanced settings */ - // TODO: Add this when pause/resume PR will be merged - return 'NOT_IMPLEMENTED'; + // Pause follower + await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); + + // Resume follower + return await callWithRequest('ccr.resumeFollowerIndex', { id: _id, body }); } catch(err) { if (isEsError(err)) { throw wrapEsError(err); From 9d9a9593c6cbd87de6ecea728acb320d94ef1f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Tue, 15 Jan 2019 20:12:35 +0100 Subject: [PATCH 14/23] [CCR] Show remote cluster validation in follower index form & auto-follow pattern form --- .../components/auto_follow_pattern_form.js | 100 ++++--- .../app/components/follower_index_form.js | 94 ++++--- .../components/remote_clusters_form_field.js | 251 ++++++++++++++++++ .../auto_follow_pattern_add.js | 89 +------ .../auto_follow_pattern_edit.js | 9 +- .../follower_index_add/follower_index_add.js | 89 +------ .../follower_index_edit.js | 48 +++- .../public/store/actions/add_cluster.js | 2 +- 8 files changed, 404 insertions(+), 278 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js index f9f91694821068..06078db7a12f41 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js @@ -26,15 +26,17 @@ import { EuiSpacer, EuiText, EuiTitle, - EuiSuperSelect, } from '@elastic/eui'; import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/index_patterns'; import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import routing from '../services/routing'; +import { extractQueryParams } from '../services/query_params'; import { API_STATUS } from '../constants'; -import { SectionError, AutoFollowPatternIndicesPreview } from './'; +import { SectionError } from './section_error'; +import { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; +import { RemoteClustersFormField } from './remote_clusters_form_field'; import { validateAutoFollowPattern, validateLeaderIndexPattern } from '../services/auto_follow_pattern_validators'; const indexPatternIllegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.join(' '); @@ -46,12 +48,22 @@ const getFirstConnectedCluster = (clusters) => { return clusters[i]; } } - return {}; + + /** + * No cluster connected, we return the first one in the list + */ + return clusters.length ? clusters[0] : {}; }; -const getEmptyAutoFollowPattern = (remoteClusters) => ({ +const getRemoteClusterName = (remoteClusters, selected) => { + return selected && remoteClusters.some(c => c.name === selected) + ? selected + : getFirstConnectedCluster(remoteClusters).name; +}; + +const getEmptyAutoFollowPattern = (remoteClusters, remoteClusterSelected) => ({ name: '', - remoteCluster: getFirstConnectedCluster(remoteClusters).name, + remoteCluster: getRemoteClusterName(remoteClusters, remoteClusterSelected), leaderIndexPatterns: [], followIndexPatternPrefix: '', followIndexPatternSuffix: '', @@ -70,16 +82,18 @@ export class AutoFollowPatternFormUI extends PureComponent { autoFollowPattern: PropTypes.object, apiError: PropTypes.object, apiStatus: PropTypes.string.isRequired, - remoteClusters: PropTypes.array.isRequired, + currentUrl: PropTypes.string.isRequired, + remoteClusters: PropTypes.array, } constructor(props) { super(props); const isNew = this.props.autoFollowPattern === undefined; - + const { route: { location: { search } } } = routing.reactRouter; + const queryParams = extractQueryParams(search); const autoFollowPattern = isNew - ? getEmptyAutoFollowPattern(this.props.remoteClusters) + ? getEmptyAutoFollowPattern(this.props.remoteClusters, queryParams.cluster) : { ...this.props.autoFollowPattern, }; @@ -101,9 +115,11 @@ export class AutoFollowPatternFormUI extends PureComponent { })); const errors = validateAutoFollowPattern(fields); - this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + this.onFieldsErrorChange(errors); }; + onFieldsErrorChange = (errors) => this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + onClusterChange = (remoteCluster) => { this.onFieldsChange({ remoteCluster }); }; @@ -169,8 +185,8 @@ export class AutoFollowPatternFormUI extends PureComponent { this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); } else { - this.setState(({ fieldsErrors, autoFollowPattern }) => { - const errors = validateAutoFollowPattern(autoFollowPattern); + this.setState(({ fieldsErrors, autoFollowPattern: { leaderIndexPatterns } }) => { + const errors = validateAutoFollowPattern({ leaderIndexPatterns }); return updateFormErrors(errors, fieldsErrors); }); } @@ -293,12 +309,26 @@ export class AutoFollowPatternFormUI extends PureComponent { * Remote Cluster */ const renderRemoteClusterField = () => { - const remoteClustersOptions = this.props.remoteClusters.map(({ name, isConnected }) => ({ - value: name, - inputDisplay: isConnected ? name : `${name} (not connected)`, - disabled: !isConnected, - 'data-test-subj': `option-${name}` - })); + const { remoteClusters, currentUrl } = this.props; + + const errorMessages = { + noClusterFound: () => (), + remoteClusterNotConnectedNotEditable: (name) => (), + remoteClusterDoesNotExist: (name) => () + }; return ( - - )} - fullWidth - > - - { isNew && ( - - )} - { !isNew && ( - - )} - - + this.onFieldsErrorChange({ remoteCluster: error })} + errorMessages={errorMessages} + /> ); }; 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 8248ef709c3f84..4c55b606266efc 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 @@ -14,25 +14,24 @@ import { EuiButtonEmpty, EuiCallOut, EuiDescribedFormGroup, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiForm, - EuiFormRow, EuiLoadingKibana, EuiLoadingSpinner, EuiOverlayMask, EuiSpacer, EuiText, EuiTitle, - EuiSuperSelect, } from '@elastic/eui'; import routing from '../services/routing'; +import { extractQueryParams } from '../services/query_params'; import { API_STATUS, followerIndexFormSchema } from '../constants'; import { SectionError } from './section_error'; import { loadIndices } from '../services/api'; import { FormEntryRow } from './form_entry_row'; +import { RemoteClustersFormField } from './remote_clusters_form_field'; const getFirstConnectedCluster = (clusters) => { for (let i = 0; i < clusters.length; i++) { @@ -40,12 +39,22 @@ const getFirstConnectedCluster = (clusters) => { return clusters[i]; } } - return {}; + + /** + * No cluster connected, we return the first one in the list + */ + return clusters.length ? clusters[0] : {}; +}; + +const getRemoteClusterName = (remoteClusters, selected) => { + return selected && remoteClusters.some(c => c.name === selected) + ? selected + : getFirstConnectedCluster(remoteClusters).name; }; -const getEmptyFollowerIndex = (remoteClusters) => ({ +const getEmptyFollowerIndex = (remoteClusters = [], remoteClusterSelected) => ({ name: '', - remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', + remoteCluster: getRemoteClusterName(remoteClusters, remoteClusterSelected), leaderIndex: '', ...Object.keys(followerIndexFormSchema.advanced).reduce((acc, field) => ({ ...acc, [field]: '' }), {}) }); @@ -85,9 +94,10 @@ export const FollowerIndexForm = injectI18n( super(props); const isNew = this.props.followerIndex === undefined; - + const { route: { location: { search } } } = routing.reactRouter; + const queryParams = extractQueryParams(search); const followerIndex = isNew - ? getEmptyFollowerIndex(this.props.remoteClusters) + ? getEmptyFollowerIndex(this.props.remoteClusters, queryParams.cluster) : { ...getEmptyFollowerIndex(), ...this.props.followerIndex, @@ -217,12 +227,12 @@ export const FollowerIndexForm = injectI18n( const toggleAdvancedSettingButtonLabel = areAdvancedSettingsVisible ? ( ) : ( ); @@ -247,14 +257,26 @@ export const FollowerIndexForm = injectI18n( * Remote Cluster */ const renderRemoteClusterField = () => { - const remoteClustersOptions = this.props.remoteClusters - ? this.props.remoteClusters.map(({ name, isConnected }) => ({ - value: name, - inputDisplay: isConnected ? name : `${name} (not connected)`, - disabled: !isConnected, - 'data-test-subj': `option-${name}` - })) - : {}; + const { remoteClusters, currentUrl } = this.props; + + const errorMessages = { + noClusterFound: () => (), + remoteClusterNotConnectedNotEditable: (name) => (), + remoteClusterDoesNotExist: (name) => () + }; return ( - - )} - fullWidth - > - - { isNew && ( - - )} - { !isNew && ( - - )} - - + this.onFieldsErrorChange({ remoteCluster: error })} + errorMessages={errorMessages} + /> ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js new file mode 100644 index 00000000000000..8ccca1acd37bdf --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -0,0 +1,251 @@ +/* + * 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 { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFormRow, + EuiSpacer, + EuiSuperSelect, + EuiFieldText, +} from '@elastic/eui'; + +import routing from '../services/routing'; +import { BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants'; + +const errorMessages = { + noClusterFound: () => (), + remoteClusterNotConnectedEditable: (name) => (), +}; + +export const RemoteClustersFormField = injectI18n( + class extends PureComponent { + errorMessages = { + ...errorMessages, + ...this.props.errorMessages + } + + componentDidMount() { + const { selected, onError } = this.props; + const { error } = this.validateRemoteCluster(selected); + + onError(error); + } + + validateRemoteCluster(clusterName) { + const { remoteClusters } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === clusterName); + + return remoteCluster && remoteCluster.isConnected + ? { error: null } + : { error: { message: 'Invalid remote cluster' } }; + } + + onRemoteClusterChange = (cluster) => { + const { onChange, onError } = this.props; + const { error } = this.validateRemoteCluster(cluster); + onChange(cluster); + onError(error); + } + + renderNotEditable = () => ( + + ); + + renderDropdown = () => { + const { remoteClusters, selected, currentUrl } = this.props; + const remoteClustersOptions = remoteClusters.map(({ name, isConnected }) => ({ + value: name, + inputDisplay: isConnected ? name : `${name} (not connected)`, + disabled: !isConnected, + 'data-test-subj': `option-${name}` + })); + + return ( + + + +

+ + + +
+
+ ); + }; + + renderNoClusterFound = () => { + const { intl, currentUrl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.emptyRemoteClustersCallOutTitle', + defaultMessage: 'No remote cluster found' + }); + + return ( + + +

+ { this.errorMessages.noClusterFound() } +

+ + + + +
+
+ ); + }; + + renderCurrentRemoteClusterNotConnected = (name) => { + const { intl, isEditable } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.remoteClusterConnectionErrorTitle', + defaultMessage: 'Remote cluster connection error' + }); + + return ( + +

+ { isEditable && this.errorMessages.remoteClusterNotConnectedEditable(name)} + { !isEditable && this.errorMessages.remoteClusterNotConnectedNotEditable(name)} +

+ + + +
+ ); + }; + + renderRemoteClusterDoesNotExist = (name) => { + const { intl } = this.props; + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.forms.remoteClusterErrorTitle', + defaultMessage: 'Remote cluster error' + }); + + return ( + +

+ { this.errorMessages.remoteClusterDoesNotExist(name) } +

+ + + +
+ ); + } + + renderErrorMessage = () => { + const { selected, remoteClusters, isEditable } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === selected); + const hasClusters = Boolean(remoteClusters.length); + const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; + + if (isEditable) { + /* Create */ + if (hasClusters && !isSelectedRemoteClusterConnected) { + return this.renderCurrentRemoteClusterNotConnected(selected); + } else if (!hasClusters) { + return this.renderNoClusterFound(); + } + } else { + /* Edit */ + const doesExists = !!remoteCluster; + if (!doesExists) { + return this.renderRemoteClusterDoesNotExist(selected); + } else if (!isSelectedRemoteClusterConnected) { + return this.renderCurrentRemoteClusterNotConnected(selected); + } + } + + return null; + } + + render() { + const { remoteClusters, selected, isEditable, areErrorsVisible } = this.props; + const remoteCluster = remoteClusters.find(c => c.name === selected); + const hasClusters = Boolean(remoteClusters.length); + const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; + const isInvalid = areErrorsVisible && (!hasClusters || !isSelectedRemoteClusterConnected); + + return ( + + )} + isInvalid={isInvalid} + fullWidth + > + + { !isEditable && this.renderNotEditable() } + { isEditable && hasClusters && this.renderDropdown() } + + { this.renderErrorMessage() } + + + ); + } + } +); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js index 74090862a007f0..668546950d1f96 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; @@ -14,13 +14,9 @@ import { EuiPage, EuiPageBody, EuiPageContent, - EuiButton, - EuiCallOut, } from '@elastic/eui'; import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; -import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -46,80 +42,8 @@ export const AutoFollowPatternAdd = injectI18n( this.props.clearApiError(); } - renderEmptyClusters() { - const { intl, match: { url: currentUrl } } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutTitle', - defaultMessage: 'No remote cluster found' - }); - - return ( - - -

- -

- - - - -
-
- ); - } - - renderNoConnectedCluster() { - const { intl } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutTitle', - defaultMessage: 'Remote cluster connection error' - }); - - return ( - - -

- -

- - - -
-
- ); - } - render() { - const { saveAutoFollowPattern, apiStatus, apiError, intl } = this.props; + const { saveAutoFollowPattern, apiStatus, apiError, intl, match: { url: currentUrl } } = this.props; return ( @@ -158,18 +82,11 @@ export const AutoFollowPatternAdd = injectI18n( return ; } - if (!remoteClusters.length) { - return this.renderEmptyClusters(); - } - - if (remoteClusters.every(cluster => cluster.isConnected === false)) { - return this.renderNoConnectedCluster(); - } - return ( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index 696e76bc8b8c0a..0a4340743db814 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -155,7 +155,7 @@ export const AutoFollowPatternEdit = injectI18n( } render() { - const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl } = this.props; + const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl, match: { url: currentUrl } } = this.props; return ( @@ -199,16 +199,11 @@ export const AutoFollowPatternEdit = injectI18n( return ; } - const autoFollowPatternCluster = remoteClusters.find(cluster => cluster.name === autoFollowPattern.remoteCluster); - - if (!autoFollowPatternCluster || !autoFollowPatternCluster.isConnected) { - return this.renderMissingCluster(autoFollowPattern); - } - return ( - -

- -

- - - - -
-
- ); - } - - renderNoConnectedCluster() { - const { intl } = this.props; - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexCreateForm.noRemoteClustersConnectedCallOutTitle', - defaultMessage: 'Remote cluster connection error' - }); - - return ( - - -

- -

- - - -
-
- ); - } - render() { - const { saveFollowerIndex, clearApiError, apiStatus, apiError, intl } = this.props; + const { saveFollowerIndex, clearApiError, apiStatus, apiError, intl, match: { url: currentUrl } } = this.props; return ( @@ -158,18 +82,11 @@ export const FollowerIndexAdd = injectI18n( return ; } - if (!remoteClusters.length) { - return this.renderEmptyClusters(); - } - - if (remoteClusters.every(cluster => cluster.isConnected === false)) { - return this.renderNoConnectedCluster(); - } - return ( { + const { name, followerIndex } = this.inflightPayload; + this.props.saveFollowerIndex(name, followerIndex); + this.closeConfirmModal(); + } + showConfirmModal = () => this.setState({ showConfirmModal: true }); closeConfirmModal = () => this.setState({ showConfirmModal: false }); @@ -145,14 +152,12 @@ export const FollowerIndexEdit = injectI18n( defaultMessage: 'Update follower index \'{id}\'', }, { id: followerIndexId }); - const { name, followerIndex } = this.inflightPayload; - return ( this.props.saveFollowerIndex(name, followerIndex)} + onConfirm={this.confirmSaveFollowerIhdex} cancelButtonText={ intl.formatMessage({ id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.cancelButtonText', @@ -211,15 +216,36 @@ export const FollowerIndexEdit = injectI18n( {apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()} {apiError.get && this.renderGetFollowerIndexError(apiError.get)} - { followerIndex && ( - + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + + + ); + } + + if (error) { + remoteClusters = []; + } + + return ( + + ); + }} + ) } { showConfirmModal && this.renderConfirmModal() } diff --git a/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js b/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js index eace258fd083d2..5506bc75108c34 100644 --- a/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js +++ b/x-pack/plugins/remote_clusters/public/store/actions/add_cluster.js @@ -94,7 +94,7 @@ export const addCluster = (cluster) => async (dispatch) => { })); const decodedRedirect = decodeURIComponent(redirectUrl); - redirect(decodedRedirect); + redirect(`${decodedRedirect}?cluster=${cluster.name}`); } else { // This will open the new job in the detail panel. Note that we're *not* showing a success toast // here, because it would partially obscure the detail panel. From 3ae6be704c4011dee1be60c1651439a91f241b4d Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 17 Jan 2019 15:08:17 -0800 Subject: [PATCH 15/23] PR feedback, cleanup form sizes, add redirect to edit remote cluster --- .../components/auto_follow_pattern_form.js | 47 +++----- .../app/components/follower_index_form.js | 41 ++----- .../components/remote_clusters_form_field.js | 103 ++++++++++++------ .../follower_index_add/follower_index_add.js | 92 ++++++++-------- .../follower_index_edit.js | 100 ++++++++--------- .../app/services/get_remote_cluster_name.js | 22 ++++ .../plugins/remote_clusters/public/index.scss | 8 -- .../remote_cluster_add/remote_cluster_add.js | 61 +++++------ .../remote_cluster_edit.js | 16 ++- .../public/store/actions/edit_cluster.js | 31 ++++-- 10 files changed, 275 insertions(+), 246 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js index 06078db7a12f41..055acdd4fc5cde 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js @@ -33,6 +33,7 @@ import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices'; import routing from '../services/routing'; import { extractQueryParams } from '../services/query_params'; +import { getRemoteClusterName } from '../services/get_remote_cluster_name'; import { API_STATUS } from '../constants'; import { SectionError } from './section_error'; import { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; @@ -42,28 +43,9 @@ import { validateAutoFollowPattern, validateLeaderIndexPattern } from '../servic const indexPatternIllegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.join(' '); const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); -const getFirstConnectedCluster = (clusters) => { - for (let i = 0; i < clusters.length; i++) { - if (clusters[i].isConnected) { - return clusters[i]; - } - } - - /** - * No cluster connected, we return the first one in the list - */ - return clusters.length ? clusters[0] : {}; -}; - -const getRemoteClusterName = (remoteClusters, selected) => { - return selected && remoteClusters.some(c => c.name === selected) - ? selected - : getFirstConnectedCluster(remoteClusters).name; -}; - -const getEmptyAutoFollowPattern = (remoteClusters, remoteClusterSelected) => ({ +const getEmptyAutoFollowPattern = (remoteClusterName = '') => ({ name: '', - remoteCluster: getRemoteClusterName(remoteClusters, remoteClusterSelected), + remoteCluster: remoteClusterName, leaderIndexPatterns: [], followIndexPatternPrefix: '', followIndexPatternSuffix: '', @@ -92,8 +74,9 @@ export class AutoFollowPatternFormUI extends PureComponent { const isNew = this.props.autoFollowPattern === undefined; const { route: { location: { search } } } = routing.reactRouter; const queryParams = extractQueryParams(search); + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); const autoFollowPattern = isNew - ? getEmptyAutoFollowPattern(this.props.remoteClusters, queryParams.cluster) + ? getEmptyAutoFollowPattern(remoteClusterName) : { ...this.props.autoFollowPattern, }; @@ -316,17 +299,15 @@ export class AutoFollowPatternFormUI extends PureComponent { id="xpack.crossClusterReplication.autoFollowPatternForm.emptyRemoteClustersCallOutDescription" defaultMessage="Auto-follow patterns capture indices on remote clusters. You must add a remote cluster." />), - remoteClusterNotConnectedNotEditable: (name) => ( (), - remoteClusterDoesNotExist: (name) => ( () }; @@ -449,9 +430,9 @@ export class AutoFollowPatternFormUI extends PureComponent { }; /** - * Auto-follow pattern + * Auto-follow pattern prefix/suffix */ - const renderAutoFollowPattern = () => { + const renderAutoFollowPatternPrefixSuffix = () => { const isPrefixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternPrefix; const isSuffixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternSuffix; @@ -639,7 +620,7 @@ export class AutoFollowPatternFormUI extends PureComponent { {renderAutoFollowPatternName()} {renderRemoteClusterField()} {renderLeaderIndexPatterns()} - {renderAutoFollowPattern()} + {renderAutoFollowPatternPrefixSuffix()} {renderFormErrorWarning()} 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 4c55b606266efc..96ba97344d360c 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,34 +27,16 @@ import { import routing from '../services/routing'; import { extractQueryParams } from '../services/query_params'; +import { getRemoteClusterName } from '../services/get_remote_cluster_name'; import { API_STATUS, followerIndexFormSchema } from '../constants'; import { SectionError } from './section_error'; import { loadIndices } from '../services/api'; import { FormEntryRow } from './form_entry_row'; import { RemoteClustersFormField } from './remote_clusters_form_field'; -const getFirstConnectedCluster = (clusters) => { - for (let i = 0; i < clusters.length; i++) { - if (clusters[i].isConnected) { - return clusters[i]; - } - } - - /** - * No cluster connected, we return the first one in the list - */ - return clusters.length ? clusters[0] : {}; -}; - -const getRemoteClusterName = (remoteClusters, selected) => { - return selected && remoteClusters.some(c => c.name === selected) - ? selected - : getFirstConnectedCluster(remoteClusters).name; -}; - -const getEmptyFollowerIndex = (remoteClusters = [], remoteClusterSelected) => ({ +const getEmptyFollowerIndex = (remoteClusterName = '') => ({ name: '', - remoteCluster: getRemoteClusterName(remoteClusters, remoteClusterSelected), + remoteCluster: remoteClusterName, leaderIndex: '', ...Object.keys(followerIndexFormSchema.advanced).reduce((acc, field) => ({ ...acc, [field]: '' }), {}) }); @@ -96,8 +78,9 @@ export const FollowerIndexForm = injectI18n( const isNew = this.props.followerIndex === undefined; const { route: { location: { search } } } = routing.reactRouter; const queryParams = extractQueryParams(search); + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); const followerIndex = isNew - ? getEmptyFollowerIndex(this.props.remoteClusters, queryParams.cluster) + ? getEmptyFollowerIndex(remoteClusterName) : { ...getEmptyFollowerIndex(), ...this.props.followerIndex, @@ -264,17 +247,15 @@ export const FollowerIndexForm = injectI18n( id="xpack.crossClusterReplication.followerIndexForm.emptyRemoteClustersCallOutDescription" defaultMessage="Follower indices replicate indices on remote clusters. You must add a remote cluster." />), - remoteClusterNotConnectedNotEditable: (name) => ( (), - remoteClusterDoesNotExist: (name) => ( () }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js index 8ccca1acd37bdf..65cdce73116248 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -27,7 +27,7 @@ const errorMessages = { />), remoteClusterNotConnectedEditable: (name) => (), }; @@ -52,7 +52,12 @@ export const RemoteClustersFormField = injectI18n( return remoteCluster && remoteCluster.isConnected ? { error: null } - : { error: { message: 'Invalid remote cluster' } }; + : { error: { message: ( + + ) } }; } onRemoteClusterChange = (cluster) => { @@ -74,27 +79,36 @@ export const RemoteClustersFormField = injectI18n( const { remoteClusters, selected, currentUrl } = this.props; const remoteClustersOptions = remoteClusters.map(({ name, isConnected }) => ({ value: name, - inputDisplay: isConnected ? name : `${name} (not connected)`, - disabled: !isConnected, + inputDisplay: isConnected ? name : ( + + ), 'data-test-subj': `option-${name}` })); return ( + { this.renderErrorMessage() }
@@ -127,7 +141,7 @@ export const RemoteClustersFormField = injectI18n( > @@ -136,11 +150,11 @@ export const RemoteClustersFormField = injectI18n( }; renderCurrentRemoteClusterNotConnected = (name) => { - const { intl, isEditable } = this.props; + const { intl, isEditable, currentUrl } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.forms.remoteClusterConnectionErrorTitle', - defaultMessage: 'Remote cluster connection error' - }); + defaultMessage: `The remote cluster '{name}' is not connected` + }, { name }); return ( @@ -166,11 +181,11 @@ export const RemoteClustersFormField = injectI18n( }; renderRemoteClusterDoesNotExist = (name) => { - const { intl } = this.props; + const { intl, currentUrl } = this.props; const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.forms.remoteClusterErrorTitle', - defaultMessage: 'Remote cluster error' - }); + id: 'xpack.crossClusterReplication.forms.remoteClusterNotFoundTitle', + defaultMessage: `The remote cluster '{name}' was not found`, + }, { name }); return (

- { this.errorMessages.remoteClusterDoesNotExist(name) } + { this.errorMessages.remoteClusterDoesNotExist() }

@@ -197,27 +213,33 @@ export const RemoteClustersFormField = injectI18n( renderErrorMessage = () => { const { selected, remoteClusters, isEditable } = this.props; const remoteCluster = remoteClusters.find(c => c.name === selected); - const hasClusters = Boolean(remoteClusters.length); const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; + let error; if (isEditable) { /* Create */ + const hasClusters = Boolean(remoteClusters.length); if (hasClusters && !isSelectedRemoteClusterConnected) { - return this.renderCurrentRemoteClusterNotConnected(selected); + error = this.renderCurrentRemoteClusterNotConnected(selected); } else if (!hasClusters) { - return this.renderNoClusterFound(); + error = this.renderNoClusterFound(); } } else { /* Edit */ const doesExists = !!remoteCluster; if (!doesExists) { - return this.renderRemoteClusterDoesNotExist(selected); + error = this.renderRemoteClusterDoesNotExist(selected); } else if (!isSelectedRemoteClusterConnected) { - return this.renderCurrentRemoteClusterNotConnected(selected); + error = this.renderCurrentRemoteClusterNotConnected(selected); } } - return null; + return error ? ( + + + {error} + + ) : null; } render() { @@ -226,6 +248,30 @@ export const RemoteClustersFormField = injectI18n( const hasClusters = Boolean(remoteClusters.length); const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; const isInvalid = areErrorsVisible && (!hasClusters || !isSelectedRemoteClusterConnected); + let field; + + if(!isEditable) { + field = ( + + { this.renderNotEditable() } + { this.renderErrorMessage() } + + ); + } else { + if(hasClusters) { + field = ( + + { this.renderDropdown() } + + ); + } else { + field = ( + + { this.renderErrorMessage() } + + ); + } + } return ( - - { !isEditable && this.renderNotEditable() } - { isEditable && hasClusters && this.renderDropdown() } - - { this.renderErrorMessage() } - + {field} ); } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js index c97da081745021..2e12804d37f40e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js @@ -11,8 +11,6 @@ import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, } from '@elastic/eui'; @@ -46,57 +44,53 @@ export const FollowerIndexAdd = injectI18n( const { saveFollowerIndex, clearApiError, apiStatus, apiError, intl, match: { url: currentUrl } } = this.props; return ( - - - - - )} + + + )} + /> - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + + + ); + } - if (error) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexCreateForm.loadingRemoteClustersErrorTitle', - defaultMessage: 'Error loading remote clusters', - }); - return ; - } + if (error) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexCreateForm.loadingRemoteClustersErrorTitle', + defaultMessage: 'Error loading remote clusters', + }); + return ; + } - return ( - - ); - }} - - - - + return ( + + ); + }} + + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index 867b4485f2529d..87cc2ae9393ac8 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -11,8 +11,6 @@ import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, EuiButton, EuiSpacer, @@ -190,6 +188,7 @@ export const FollowerIndexEdit = injectI18n( apiStatus, apiError, followerIndex, + match: { url: currentUrl } } = this.props; const { showConfirmModal } = this.state; @@ -198,60 +197,57 @@ export const FollowerIndexEdit = injectI18n( const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars return ( - - - - - )} + + + )} + /> - {apiStatus.get === API_STATUS.LOADING && this.renderLoadingFollowerIndex()} - - {apiError.get && this.renderGetFollowerIndexError(apiError.get)} - { followerIndex && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } - - if (error) { - remoteClusters = []; - } - - return ( - + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - }} - - ) } + + ); + } + + if (error) { + remoteClusters = []; + } + + return ( + + ); + }} + + ) } - { showConfirmModal && this.renderConfirmModal() } - - - + { showConfirmModal && this.renderConfirmModal() } + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js new file mode 100644 index 00000000000000..942eaa9feb6f09 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +const getFirstConnectedCluster = (clusters) => { + for (let i = 0; i < clusters.length; i++) { + if (clusters[i].isConnected) { + return clusters[i]; + } + } + + // No cluster connected, we return the first one in the list + return clusters.length ? clusters[0] : {}; +}; + +export const getRemoteClusterName = (remoteClusters, selected) => { + return selected && remoteClusters.some(c => c.name === selected) + ? selected + : getFirstConnectedCluster(remoteClusters).name; +}; diff --git a/x-pack/plugins/remote_clusters/public/index.scss b/x-pack/plugins/remote_clusters/public/index.scss index b25832255cecec..f1bbc7941c6084 100644 --- a/x-pack/plugins/remote_clusters/public/index.scss +++ b/x-pack/plugins/remote_clusters/public/index.scss @@ -10,14 +10,6 @@ // remoteClustersChart__legend--small // remoteClustersChart__legend-isLoading -/** - * 1. Override EUI styles. - */ -.remoteClusterAddPage { - max-width: 1000px !important; /* 1 */ - width: 100% !important; /* 1 */ -} - /** * 1. Override EuiFormRow styles. Otherwise the switch will jump around when toggled on and off, * as the 'Reset to defaults' link is added to and removed from the DOM. diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js index 4187bfdd82fba4..69684ec234f1fb 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_add/remote_cluster_add.js @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../constants'; -import { listBreadcrumb, addBreadcrumb } from '../../services'; +import { listBreadcrumb, addBreadcrumb, getRouter, redirect, extractQueryParams } from '../../services'; import { RemoteClusterPageTitle, RemoteClusterForm } from '../components'; export const RemoteClusterAdd = injectI18n( @@ -44,40 +42,41 @@ export const RemoteClusterAdd = injectI18n( }; cancel = () => { - const { history } = this.props; - history.push(CRUD_APP_BASE_PATH); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(decodedRedirect); + } else { + history.push(CRUD_APP_BASE_PATH); + } }; render() { const { isAddingCluster, addClusterError } = this.props; return ( - - - - - - )} - /> + + + )} + /> - - - - - + + ); } } diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js index b0b3f65e5c99ad..fe15b71cc6ee33 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js @@ -25,7 +25,7 @@ import { } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../constants'; -import { buildListBreadcrumb, editBreadcrumb } from '../../services'; +import { buildListBreadcrumb, editBreadcrumb, extractQueryParams, getRouter, redirect } from '../../services'; import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; const disabledFields = { @@ -85,10 +85,18 @@ export const RemoteClusterEdit = injectI18n( }; cancel = () => { - const { history, openDetailPanel } = this.props; + const { openDetailPanel } = this.props; const { clusterName } = this.state; - history.push(CRUD_APP_BASE_PATH); - openDetailPanel(clusterName); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(decodedRedirect); + } else { + history.push(CRUD_APP_BASE_PATH); + openDetailPanel(clusterName); + } }; renderContent() { diff --git a/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js b/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js index 2d0b61e6dad01d..f209c575498f75 100644 --- a/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js +++ b/x-pack/plugins/remote_clusters/public/store/actions/edit_cluster.js @@ -5,13 +5,14 @@ */ import { i18n } from '@kbn/i18n'; -import { fatalError } from 'ui/notify'; +import { fatalError, toastNotifications } from 'ui/notify'; import { CRUD_APP_BASE_PATH } from '../../constants'; import { loadClusters } from './load_clusters'; import { - editCluster as sendEditClusterRequest, + editCluster as sendEditClusterRequest, extractQueryParams, getRouter, + redirect, } from '../../services'; import { @@ -66,12 +67,26 @@ export const editCluster = (cluster) => async (dispatch) => { type: EDIT_CLUSTER_SUCCESS, }); - // This will open the new job in the detail panel. Note that we're *not* showing a success toast - // here, because it would partially obscure the detail panel. - getRouter().history.push({ - pathname: `${CRUD_APP_BASE_PATH}/list`, - search: `?cluster=${cluster.name}`, - }); + const { history, route: { location: { search } } } = getRouter(); + const { redirect: redirectUrl } = extractQueryParams(search); + + if (redirectUrl) { + // A toast is only needed if we're leaving the app. + toastNotifications.addSuccess(i18n.translate('xpack.remoteClusters.editAction.successTitle', { + defaultMessage: `Edited remote cluster '{name}'`, + values: { name: cluster.name }, + })); + + const decodedRedirect = decodeURIComponent(redirectUrl); + redirect(`${decodedRedirect}?cluster=${cluster.name}`); + } else { + // This will open the edited cluster in the detail panel. Note that we're *not* showing a success toast + // here, because it would partially obscure the detail panel. + history.push({ + pathname: `${CRUD_APP_BASE_PATH}/list`, + search: `?cluster=${cluster.name}`, + }); + } }; export const startEditingCluster = ({ clusterName }) => (dispatch) => { From df3107f16840f49c26102e7125241ff0ff791eb4 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 17 Jan 2019 15:47:39 -0800 Subject: [PATCH 16/23] Fix routing, remove unused code, adjust auto follow pattern edit loading error page --- .../components/remote_clusters_form_field.js | 10 ++-- .../auto_follow_pattern_edit.js | 54 +++---------------- .../components/detail_panel/detail_panel.js | 4 +- .../public/app/services/routing.js | 12 ++--- 4 files changed, 18 insertions(+), 62 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js index 65cdce73116248..56447683c3ebdb 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -99,9 +99,9 @@ export const RemoteClustersFormField = injectI18n( /> { this.renderErrorMessage() } -
+
{/* Break out of EuiFormRow's flexbox layout */} @@ -167,7 +167,7 @@ export const RemoteClustersFormField = injectI18n( { !isEditable && this.errorMessages.remoteClusterNotConnectedNotEditable(name)}

diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index 0a4340743db814..3a34a7d56f14fe 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -14,16 +14,13 @@ import { EuiPage, EuiPageBody, EuiPageContent, - EuiSpacer, - EuiButton, - EuiCallOut, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import routing from '../../services/routing'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -86,19 +83,18 @@ export const AutoFollowPatternEdit = injectI18n( return ( - - + - - +
@@ -116,44 +112,6 @@ export const AutoFollowPatternEdit = injectI18n( ); } - renderMissingCluster({ name, remoteCluster }) { - const { intl } = this.props; - - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.emptyRemoteClustersTitle', - defaultMessage: 'Remote cluster missing' - }); - - return ( - - -

- - -

- - - -
-
- ); - } - render() { const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl, match: { url: currentUrl } } = this.props; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js index df0c569f1e085e..99e2d4083e96e3 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js @@ -335,9 +335,7 @@ export class DetailPanelUi extends Component { { - routing.navigate(encodeURI(`/auto_follow_patterns/edit/${encodeURIComponent(autoFollowPattern.name)}`)); - }} + href={routing.getAutoFollowPatternPath(autoFollowPattern.name)} > !!(event.metaKey || event.altKey || event.ctrlK const isLeftClickEvent = event => event.button === 0; -const queryParamsFromObject = params => { +const queryParamsFromObject = (params, encodeParams = false) => { if (!params) { return; } - const paramsStr = stringify(params, '&', '=', { + const paramsStr = stringify(params, '&', '=', encodeParams ? {} : { encodeURIComponent: (val) => val, // Don't encode special chars }); return `?${paramsStr}`; @@ -42,8 +42,8 @@ class Routing { * * @param {*} to URL to navigate to */ - getRouterLinkProps(to, base = BASE_PATH, params = {}) { - const search = queryParamsFromObject(params) || ''; + getRouterLinkProps(to, base = BASE_PATH, params = {}, encodeParams = false) { + const search = queryParamsFromObject(params, encodeParams) || ''; const location = typeof to === "string" ? createLocation(base + to + search, null, null, this._reactRouter.history.location) : to; @@ -71,8 +71,8 @@ class Routing { return { href, onClick }; } - navigate(route = '/home', app = APPS.CCR_APP, params) { - const search = queryParamsFromObject(params); + navigate(route = '/home', app = APPS.CCR_APP, params, encodeParams = false) { + const search = queryParamsFromObject(params, encodeParams); this._reactRouter.history.push({ pathname: encodeURI(appToBasePathMap[app] + route), From da359c827756257fcd00b8d04cdd370d7220b3fe Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Fri, 18 Jan 2019 10:51:44 -0800 Subject: [PATCH 17/23] Adjust error messages and make remote cluster not found edit page the same --- .../components/remote_clusters_form_field.js | 7 +- .../auto_follow_pattern_edit.container.js | 15 +- .../auto_follow_pattern_edit.js | 126 +++++++++-------- .../follower_index_edit.js | 26 ++-- .../app/store/actions/auto_follow_pattern.js | 4 +- .../server/routes/api/auto_follow_pattern.js | 6 +- .../remote_cluster_edit.js | 98 ++++++------- .../remote_cluster_list.test.js.snap | 132 +++++++++--------- 8 files changed, 216 insertions(+), 198 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js index 56447683c3ebdb..01f02e9fe89a50 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -25,10 +25,9 @@ const errorMessages = { id="xpack.crossClusterReplication.forms.emptyRemoteClustersCallOutDescription" defaultMessage="No cluster found. You must add a remote cluster." />), - remoteClusterNotConnectedEditable: (name) => ( (), }; @@ -163,8 +162,8 @@ export const RemoteClustersFormField = injectI18n( iconType="cross" >

- { isEditable && this.errorMessages.remoteClusterNotConnectedEditable(name)} - { !isEditable && this.errorMessages.remoteClusterNotConnectedNotEditable(name)} + { isEditable && this.errorMessages.remoteClusterNotConnectedEditable()} + { !isEditable && this.errorMessages.remoteClusterNotConnectedNotEditable()}

({ - apiStatus: getApiStatus(scope)(state), - apiError: getApiError(scope)(state), + apiStatus: { + get: getApiStatus(`${scope}-get`)(state), + save: getApiStatus(`${scope}-save`)(state), + }, + apiError: { + get: getApiError(`${scope}-get`)(state), + save: getApiError(`${scope}-save`)(state), + }, autoFollowPatternId: getSelectedAutoFollowPatternId('edit')(state), autoFollowPattern: getSelectedAutoFollowPattern('edit')(state), }); @@ -29,7 +35,10 @@ const mapDispatchToProps = dispatch => ({ getAutoFollowPattern: (id) => dispatch(getAutoFollowPattern(id)), selectAutoFollowPattern: (id) => dispatch(selectEditAutoFollowPattern(id)), saveAutoFollowPattern: (id, autoFollowPattern) => dispatch(saveAutoFollowPattern(id, autoFollowPattern, true)), - clearApiError: () => dispatch(clearApiError(scope)), + clearApiError: () => { + dispatch(clearApiError(`${scope}-get`)); + dispatch(clearApiError(`${scope}-save`)); + }, }); export const AutoFollowPatternEdit = connect( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index 3a34a7d56f14fe..20e1f515f2b8b7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -11,8 +11,6 @@ import chrome from 'ui/chrome'; import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { - EuiPage, - EuiPageBody, EuiPageContent, EuiButtonEmpty, EuiFlexGroup, @@ -37,8 +35,8 @@ export const AutoFollowPatternEdit = injectI18n( selectAutoFollowPattern: PropTypes.func.isRequired, saveAutoFollowPattern: PropTypes.func.isRequired, clearApiError: PropTypes.func.isRequired, - apiError: PropTypes.object, - apiStatus: PropTypes.string.isRequired, + apiError: PropTypes.object.isRequired, + apiStatus: PropTypes.object.isRequired, autoFollowPattern: PropTypes.object, autoFollowPatternId: PropTypes.string, } @@ -73,16 +71,24 @@ export const AutoFollowPatternEdit = injectI18n( this.props.clearApiError(); } - renderApiError(error) { - const { intl } = this.props; + renderGetAutoFollowPatternError(error) { + const { intl, match: { params: { id: name } } } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorTitle', defaultMessage: 'Error loading auto-follow pattern', }); + const errorMessage = error.status === 404 ? { + data: { + error: intl.formatMessage({ + id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorMessage', + defaultMessage: `The auto-follow pattern '{name}' does not exist.`, + }, { name }) + } + } : error; return ( - + - - - - )} + + + )} + /> + + {apiStatus.get === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - {apiStatus === API_STATUS.LOADING && this.renderLoadingAutoFollowPattern()} - - {apiError && this.renderApiError(apiError)} - - {autoFollowPattern && ( - - {({ isLoading, error, remoteClusters }) => { - if (isLoading) { - return ( - - - - ); - } - - if (error) { - const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersErrorTitle', - defaultMessage: 'Error loading remote clusters', - }); - return ; - } - - return ( - + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + - ); - }} - - )} - - - + + ); + } + + if (error) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingRemoteClustersErrorTitle', + defaultMessage: 'Error loading remote clusters', + }); + return ; + } + + return ( + + ); + }} + + )} + ); } } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index 87cc2ae9393ac8..e2fc21162e8bee 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -12,8 +12,7 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiPageContent, - EuiButton, - EuiSpacer, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiOverlayMask, @@ -115,28 +114,35 @@ export const FollowerIndexEdit = injectI18n( } renderGetFollowerIndexError(error) { - const { intl } = this.props; + const { intl, match: { params: { id: name } } } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorTitle', defaultMessage: 'Error loading follower index', }); + const errorMessage = error.status === 404 ? { + data: { + error: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorMessage', + defaultMessage: `The follower index '{name}' does not exist.`, + }, { name }) + } + } : error; return ( - - - + + - - + diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js index 2bd3141f11155d..3636befdc9bf79 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js @@ -43,7 +43,7 @@ export const loadAutoFollowPatterns = (isUpdating = false) => export const getAutoFollowPattern = (id) => sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_GET, - scope, + scope: `${scope}-get`, handler: async () => ( await getAutoFollowPatternRequest(id) ) @@ -53,7 +53,7 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) sendApiRequest({ label: isUpdating ? t.AUTO_FOLLOW_PATTERN_UPDATE : t.AUTO_FOLLOW_PATTERN_CREATE, status: API_STATUS.SAVING, - scope, + scope: `${scope}-save`, handler: async () => { if (isUpdating) { return await updateAutoFollowPatternRequest(id, autoFollowPattern); diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js index 4c6c34ce684790..3f8e149659ae6f 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/auto_follow_pattern.js @@ -20,7 +20,7 @@ export const registerAutoFollowPatternRoutes = (server) => { const licensePreRouting = licensePreRoutingFactory(server); /** - * Returns a list of all Auto follow patterns + * Returns a list of all auto-follow patterns */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns`, @@ -114,7 +114,7 @@ export const registerAutoFollowPatternRoutes = (server) => { }); /** - * Returns a single Auto follow pattern + * Returns a single auto-follow pattern */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, @@ -141,7 +141,7 @@ export const registerAutoFollowPatternRoutes = (server) => { }); /** - * Delete an auto follow pattern + * Delete an auto-follow pattern */ server.route({ path: `${API_BASE_PATH}/auto_follow_patterns/{id}`, diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js index fe15b71cc6ee33..c86d4946cdea1c 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js @@ -12,12 +12,10 @@ import { MANAGEMENT_BREADCRUMB } from 'ui/management'; import { EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiLoadingSpinner, - EuiPage, - EuiPageBody, EuiPageContent, EuiSpacer, EuiText, @@ -25,7 +23,7 @@ import { } from '@elastic/eui'; import { CRUD_APP_BASE_PATH } from '../../constants'; -import { buildListBreadcrumb, editBreadcrumb, extractQueryParams, getRouter, redirect } from '../../services'; +import { buildListBreadcrumb, editBreadcrumb, extractQueryParams, getRouter, getRouterLinkProps, redirect } from '../../services'; import { RemoteClusterPageTitle, RemoteClusterForm, ConfiguredByNodeWarning } from '../components'; const disabledFields = { @@ -100,6 +98,10 @@ export const RemoteClusterEdit = injectI18n( }; renderContent() { + const { + clusterName, + } = this.state; + const { isLoading, cluster, @@ -134,26 +136,39 @@ export const RemoteClusterEdit = injectI18n( if (!cluster) { return ( - - - - - - - - + + + )} + color="danger" + iconType="alert" + > + + + + + + - - - - + + + + ); } @@ -192,33 +207,22 @@ export const RemoteClusterEdit = injectI18n( } render() { - const { - clusterName, - } = this.state; - return ( - - - - - - )} - /> + + + )} + /> - {this.renderContent()} - - - - + {this.renderContent()} + ); } } 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 cc7ca735d17673..0f8537289951f3 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 91e9978b20bf052ffa45b303bc97cb9e96f9a5cc Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Fri, 18 Jan 2019 12:56:16 -0800 Subject: [PATCH 18/23] Fix functionality as result of merge --- .../follower_index_form.js | 130 +++++++++--------- .../app/services/get_remote_cluster_name.js | 2 +- 2 files changed, 68 insertions(+), 64 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 c1f7dba427dcdd..3d75a5ae793b73 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 @@ -404,7 +404,9 @@ export const FollowerIndexForm = injectI18n( isEditable={isNew} areErrorsVisible={areErrorsVisible} onChange={this.onClusterChange} - onError={(error) => this.onFieldsErrorChange({ remoteCluster: error })} + onError={(error) => { + this.setState(updateFormErrors({ remoteCluster: error })); + }} errorMessages={errorMessages} /> @@ -465,72 +467,74 @@ export const FollowerIndexForm = injectI18n( /> ); - const renderAdvancedSettings = () => ( - - + const renderAdvancedSettings = () => { + const { isNew } = this.state; - -

- -

- - )} - description={( + return ( + + + +

+ +

+ + )} + description={( + +

+ +

+ {isNew ? ( + + {toggleAdvancedSettingButtonLabel} + + ) : null} +
+ )} + fullWidth + /> + + {areAdvancedSettingsVisible && ( -

- -

- - - { toggleAdvancedSettingButtonLabel } - + + {advancedSettingsFields.map((advancedSetting) => { + const { field, title, description, label, helpText } = advancedSetting; + return ( + +

{title}

+ + )} + description={description} + label={label} + helpText={helpText} + areErrorsVisible={areErrorsVisible} + onValueUpdate={this.onFieldsChange} + /> + ); + })}
)} - fullWidth - /> - - {areAdvancedSettingsVisible && ( - - - - {advancedSettingsFields.map((advancedSetting) => { - const { field, title, description, label, helpText } = advancedSetting; - return ( - -

{title}

- - )} - description={description} - label={label} - helpText={helpText} - areErrorsVisible={areErrorsVisible} - onValueUpdate={this.onFieldsChange} - /> - ); - })} -
- )} - - -
- ); + +
+ ); + }; /** * Form Error warning message diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js index 942eaa9feb6f09..850a89a1dbbb59 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -const getFirstConnectedCluster = (clusters) => { +export const getFirstConnectedCluster = (clusters) => { for (let i = 0; i < clusters.length; i++) { if (clusters[i].isConnected) { return clusters[i]; From c95982b8c8d570cf833b251331987c616a947b6e Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Fri, 18 Jan 2019 13:43:21 -0800 Subject: [PATCH 19/23] Fix validation, reorder actions, fix tests, and address feedback --- .../follower_index_form.js | 7 +-- .../components/remote_clusters_form_field.js | 2 +- .../follower_index_edit.js | 15 +++---- .../components/context_menu/context_menu.js | 24 +++++----- .../follower_indices_table.js | 44 +++++++++---------- .../app/services/get_remote_cluster_name.js | 3 +- .../app/store/actions/follower_index.js | 2 +- .../server/routes/api/follower_index.js | 8 +--- .../server/routes/api/follower_index.test.js | 8 ++-- 9 files changed, 54 insertions(+), 59 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 3d75a5ae793b73..4fd13870306d9c 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 @@ -37,7 +37,7 @@ import { SectionError } from '../section_error'; import { FormEntryRow } from '../form_entry_row'; import { advancedSettingsFields, emptyAdvancedSettings } from './advanced_settings_fields'; import { extractQueryParams } from '../../services/query_params'; -import { getRemoteClusterName, getFirstConnectedCluster } from '../../services/get_remote_cluster_name'; +import { getRemoteClusterName } from '../../services/get_remote_cluster_name'; import { RemoteClustersFormField } from '../remote_clusters_form_field'; const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); @@ -51,9 +51,9 @@ const fieldToValidatorMap = advancedSettingsFields.reduce((map, advancedSetting) 'leaderIndex': leaderIndexValidator, }); -const getEmptyFollowerIndex = (remoteClusters) => ({ +const getEmptyFollowerIndex = (remoteClusterName = '') => ({ name: '', - remoteCluster: remoteClusters ? getFirstConnectedCluster(remoteClusters).name : '', + remoteCluster: remoteClusterName, leaderIndex: '', ...emptyAdvancedSettings, }); @@ -102,6 +102,7 @@ export const FollowerIndexForm = injectI18n( ...getEmptyFollowerIndex(), ...this.props.followerIndex, }; + console.log(isNew, remoteClusterName, followerIndex); const fieldsErrors = this.getFieldsErrors(followerIndex); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js index 01f02e9fe89a50..fb90a6afed647b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -50,7 +50,7 @@ export const RemoteClustersFormField = injectI18n( const remoteCluster = remoteClusters.find(c => c.name === clusterName); return remoteCluster && remoteCluster.isConnected - ? { error: null } + ? { error: undefined } : { error: { message: ( { - this.inflightPayload = { name, followerIndex }; + this.editedFollowerIndexPayload = { name, followerIndex }; this.showConfirmModal(); } confirmSaveFollowerIhdex = () => { - const { name, followerIndex } = this.inflightPayload; + const { name, followerIndex } = this.editedFollowerIndexPayload; this.props.saveFollowerIndex(name, followerIndex); this.closeConfirmModal(); } @@ -153,7 +151,7 @@ export const FollowerIndexEdit = injectI18n( const { followerIndexId, intl } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.title', - defaultMessage: 'Update follower index \'{id}\'', + defaultMessage: 'Update follower index \'{id}\'?', }, { id: followerIndexId }); return ( @@ -168,7 +166,6 @@ export const FollowerIndexEdit = injectI18n( defaultMessage: 'Cancel', }) } - buttonColor="danger" confirmButtonText={ intl.formatMessage({ id: 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.confirmButtonText', @@ -177,10 +174,10 @@ export const FollowerIndexEdit = injectI18n( } >

- {' '}

diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js index 023d3c71a57268..fb58a451da5ff9 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/context_menu.js @@ -145,6 +145,18 @@ export class ContextMenuUi extends PureComponent { ) : null } + { followerIndexNames.length === 1 && ( + this.editFollowerIndex(followerIndexNames[0])} + > + + + ) } + {(unfollowLeaderIndex) => ( )} - - { followerIndexNames.length === 1 && ( - this.editFollowerIndex(followerIndexNames[0])} - > - - - ) } ); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js index 8485aadc1f8721..7455866a070876 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js @@ -70,7 +70,7 @@ export const FollowerIndicesTable = injectI18n( const { intl, selectFollowerIndex } = this.props; const actions = [ - /* Resume follower index */ + /* Pause or resume follower index */ { render: ({ name, shards }) => { const isPaused = !shards || !shards.length; @@ -115,6 +115,27 @@ export const FollowerIndicesTable = injectI18n( ); }, }, + /* Edit follower index */ + { + render: ({ name }) => { + const label = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', + defaultMessage: 'Edit follower index', + }); + + return ( + this.editFollowerIndex(name)}> + + {label} + + ); + }, + }, /* Unfollow leader index */ { render: ({ name }) => { @@ -140,27 +161,6 @@ export const FollowerIndicesTable = injectI18n( ); }, }, - /* Edit follower index */ - { - render: ({ name }) => { - const label = intl.formatMessage({ - id: 'xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', - defaultMessage: 'Edit follower index', - }); - - return ( - this.editFollowerIndex(name)}> - - {label} - - ); - }, - }, ]; return [{ diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js index 850a89a1dbbb59..9070d531d407af 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/get_remote_cluster_name.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export const getFirstConnectedCluster = (clusters) => { +const getFirstConnectedCluster = (clusters) => { for (let i = 0; i < clusters.length; i++) { if (clusters[i].isConnected) { return clusters[i]; @@ -16,6 +16,7 @@ export const getFirstConnectedCluster = (clusters) => { }; export const getRemoteClusterName = (remoteClusters, selected) => { + console.log(remoteClusters, selected); return selected && remoteClusters.some(c => c.name === selected) ? selected : getFirstConnectedCluster(remoteClusters).name; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js index b3810d5dce39d1..78233e9305e4fb 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js @@ -51,7 +51,7 @@ export const getFollowerIndex = (id) => ) }); -export const saveFollowerIndex = (name, followerIndex, isUpdating) => ( +export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => ( sendApiRequest({ label: t.FOLLOWER_INDEX_CREATE, status: API_STATUS.SAVING, 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 e2992a42341568..6cd70dddbc6eea 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 @@ -23,7 +23,7 @@ export const registerFollowerIndexRoutes = (server) => { const licensePreRouting = licensePreRoutingFactory(server); /** - * Returns a list of all Follower indices + * Returns a list of all follower indices */ server.route({ path: `${API_BASE_PATH}/follower_indices`, @@ -48,7 +48,6 @@ export const registerFollowerIndexRoutes = (server) => { }, }); - /** * Returns a single follower index pattern */ @@ -81,7 +80,6 @@ export const registerFollowerIndexRoutes = (server) => { }, }); - /** * Create a follower index */ @@ -121,10 +119,8 @@ export const registerFollowerIndexRoutes = (server) => { const { id: _id } = request.params; const body = removeEmptyFields(serializeAdvancedSettings(request.payload)); + // We need to first pause the follower and then resume it passing the advanced settings try { - /** - * We need to first pause the follower and then resume it passing the advanced settings - */ // Pause follower await callWithRequest('ccr.pauseFollowerIndex', { id: _id }); diff --git a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js index 4a514207d55134..f4b34c6c306375 100644 --- a/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js +++ b/x-pack/plugins/cross_cluster_replication/server/routes/api/follower_index.test.js @@ -34,10 +34,10 @@ const registerHandlers = () => { 0: 'list', 1: 'get', 2: 'create', - 2: 'edit', - 3: 'pause', - 4: 'resume', - 5: 'unfollow', + 3: 'edit', + 4: 'pause', + 5: 'resume', + 6: 'unfollow', }; const server = { From 844df586df741c2d676e85e78be4d8e139bb6087 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Fri, 18 Jan 2019 15:43:01 -0800 Subject: [PATCH 20/23] PR feedback and fix validation pt 2 --- .../components/auto_follow_pattern_form.js | 2 +- .../follower_index_form.js | 7 +- .../components/remote_clusters_form_field.js | 79 +++++----- .../public/app/constants/form_schemas.js | 144 ------------------ .../public/app/constants/index.js | 1 - .../auto_follow_pattern_edit.js | 3 +- .../follower_index_edit.js | 2 +- .../app/services/get_remote_cluster_name.js | 1 - 8 files changed, 43 insertions(+), 196 deletions(-) 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/auto_follow_pattern_form.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js index 055acdd4fc5cde..4d8c913a0b06a0 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js @@ -186,7 +186,7 @@ export class AutoFollowPatternFormUI extends PureComponent { }; isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === null); + return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null); } sendForm = () => { 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 4fd13870306d9c..0017e30e3cb424 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 @@ -102,7 +102,6 @@ export const FollowerIndexForm = injectI18n( ...getEmptyFollowerIndex(), ...this.props.followerIndex, }; - console.log(isNew, remoteClusterName, followerIndex); const fieldsErrors = this.getFieldsErrors(followerIndex); @@ -244,7 +243,7 @@ export const FollowerIndexForm = injectI18n( } isFormValid() { - return Object.values(this.state.fieldsErrors).every(error => error === undefined); + return Object.values(this.state.fieldsErrors).every(error => error === undefined || error === null); } sendForm = () => { @@ -504,7 +503,9 @@ export const FollowerIndexForm = injectI18n( )} fullWidth - /> + > + {/* Avoid missing `children` warning */} + {areAdvancedSettingsVisible && ( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js index fb90a6afed647b..8adc6047f532e7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -50,7 +50,7 @@ export const RemoteClustersFormField = injectI18n( const remoteCluster = remoteClusters.find(c => c.name === clusterName); return remoteCluster && remoteCluster.isConnected - ? { error: undefined } + ? { error: null } : { error: { message: ( { const { remoteClusters, selected, currentUrl } = this.props; - const remoteClustersOptions = remoteClusters.map(({ name, isConnected }) => ({ + const hasClusters = Boolean(remoteClusters.length); + const remoteClustersOptions = hasClusters ? remoteClusters.map(({ name, isConnected }) => ({ value: name, inputDisplay: isConnected ? name : ( ), 'data-test-subj': `option-${name}` - })); + })) : []; return ( { this.renderErrorMessage() } - -
{/* Break out of EuiFormRow's flexbox layout */} - - - -
+ + { /* Only render add link if we have clusters, error message will handle add link if no clusters */ } + { hasClusters ? ( + + +
{/* Break out of EuiFormRow's flexbox layout */} + + + +
+
+ ) : null }
); }; @@ -247,30 +255,6 @@ export const RemoteClustersFormField = injectI18n( const hasClusters = Boolean(remoteClusters.length); const isSelectedRemoteClusterConnected = remoteCluster && remoteCluster.isConnected; const isInvalid = areErrorsVisible && (!hasClusters || !isSelectedRemoteClusterConnected); - let field; - - if(!isEditable) { - field = ( - - { this.renderNotEditable() } - { this.renderErrorMessage() } - - ); - } else { - if(hasClusters) { - field = ( - - { this.renderDropdown() } - - ); - } else { - field = ( - - { this.renderErrorMessage() } - - ); - } - } return ( - {field} + {isEditable ? ( + + { this.renderDropdown() } + + ) : ( + + { this.renderNotEditable() } + { this.renderErrorMessage() } + + ) } ); } 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 deleted file mode 100644 index 02874861ced5ec..00000000000000 --- a/x-pack/plugins/cross_cluster_replication/public/app/constants/form_schemas.js +++ /dev/null @@ -1,144 +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 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 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, - }, - 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 1e2eb368ee5ede..11fd188374b53a 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'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js index 20e1f515f2b8b7..64b6cd56a2495d 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js @@ -61,8 +61,8 @@ export const AutoFollowPatternEdit = injectI18n( componentDidUpdate(prevProps, prevState) { const { autoFollowPattern, getAutoFollowPattern } = this.props; + // Fetch the auto-follow pattern on the server if we don't have it (i.e. page reload) if (!autoFollowPattern && prevState.lastAutoFollowPatternId !== this.state.lastAutoFollowPatternId) { - // Fetch the auto-follow pattern on the server getAutoFollowPattern(this.state.lastAutoFollowPatternId); } } @@ -120,7 +120,6 @@ export const AutoFollowPatternEdit = injectI18n( render() { const { saveAutoFollowPattern, apiStatus, apiError, autoFollowPattern, intl, match: { url: currentUrl } } = this.props; - console.log(apiStatus, apiError); return ( { }; export const getRemoteClusterName = (remoteClusters, selected) => { - console.log(remoteClusters, selected); return selected && remoteClusters.some(c => c.name === selected) ? selected : getFirstConnectedCluster(remoteClusters).name; From 6082bf52492879c97db24be16392ff8002db5dff Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Fri, 18 Jan 2019 17:34:59 -0800 Subject: [PATCH 21/23] Adjust remote cluster validation --- .../components/remote_clusters_form_field.js | 128 +++++++++++------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js index 8adc6047f532e7..33f9072068a089 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_form_field.js @@ -11,9 +11,10 @@ import { EuiButton, EuiButtonEmpty, EuiCallOut, + EuiFormErrorText, EuiFormRow, EuiSpacer, - EuiSuperSelect, + EuiSelect, EuiFieldText, } from '@elastic/eui'; @@ -64,22 +65,41 @@ export const RemoteClustersFormField = injectI18n( const { error } = this.validateRemoteCluster(cluster); onChange(cluster); onError(error); - } + }; + + renderNotEditable = () => { + const { areErrorsVisible } = this.props; + const errorMessage = this.renderErrorMessage(); + + return ( + + + { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } + { errorMessage } + + ); + }; - renderNotEditable = () => ( - + renderValidRemoteClusterRequired = () => ( + + + ); renderDropdown = () => { - const { remoteClusters, selected, currentUrl } = this.props; + const { remoteClusters, selected, currentUrl, areErrorsVisible } = this.props; const hasClusters = Boolean(remoteClusters.length); const remoteClustersOptions = hasClusters ? remoteClusters.map(({ name, isConnected }) => ({ value: name, - inputDisplay: isConnected ? name : ( + text: isConnected ? name : ( - { this.onRemoteClusterChange(e.target.value); }} + hasNoInitialSelection={!hasClusters} + isInvalid={areErrorsVisible && Boolean(errorMessage)} /> - { this.renderErrorMessage() } + { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } + { errorMessage } - { /* Only render add link if we have clusters, error message will handle add link if no clusters */ } - { hasClusters ? ( - - -
{/* Break out of EuiFormRow's flexbox layout */} - - - -
-
- ) : null } + + +
{/* Break out of EuiFormRow's flexbox layout */} + + + +
+
); }; @@ -134,7 +154,7 @@ export const RemoteClustersFormField = injectI18n(

@@ -144,7 +164,7 @@ export const RemoteClustersFormField = injectI18n( { + renderCurrentRemoteClusterNotConnected = (name, fatal) => { const { intl, isEditable, currentUrl } = this.props; const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.forms.remoteClusterConnectionErrorTitle', @@ -166,7 +186,7 @@ export const RemoteClustersFormField = injectI18n( return (

@@ -175,7 +195,7 @@ export const RemoteClustersFormField = injectI18n(

@@ -206,7 +226,7 @@ export const RemoteClustersFormField = injectI18n( - {isEditable ? ( - - { this.renderDropdown() } - - ) : ( - - { this.renderNotEditable() } - { this.renderErrorMessage() } - - ) } + + {field} + ); } From 8189efe56903968fc6dab5f9d0b2689b7c2a6dfa Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Tue, 22 Jan 2019 08:27:04 -0800 Subject: [PATCH 22/23] Fix i18n --- .../public/sections/remote_cluster_edit/remote_cluster_edit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js index c86d4946cdea1c..c4c40374b42b21 100644 --- a/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js +++ b/x-pack/plugins/remote_clusters/public/sections/remote_cluster_edit/remote_cluster_edit.js @@ -149,7 +149,7 @@ export const RemoteClusterEdit = injectI18n( > From 10c0a190d01356ce115896f3f7317d802e12d405 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Tue, 22 Jan 2019 11:09:21 -0800 Subject: [PATCH 23/23] Fix api error not showing on add follower form --- .../follower_index_add/follower_index_add.container.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js index cf2efcb98432a1..d63ae84b0bf6bc 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js @@ -14,13 +14,13 @@ import { FollowerIndexAdd as FollowerIndexAddView } from './follower_index_add'; const scope = SECTIONS.FOLLOWER_INDEX; const mapStateToProps = (state) => ({ - apiStatus: getApiStatus(scope)(state), - apiError: getApiError(scope)(state), + apiStatus: getApiStatus(`${scope}-save`)(state), + apiError: getApiError(`${scope}-save`)(state), }); const mapDispatchToProps = dispatch => ({ saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex)), - clearApiError: () => dispatch(clearApiError(scope)), + clearApiError: () => dispatch(clearApiError(`${scope}-save`)), }); export const FollowerIndexAdd = connect(