From 687a87471fa3d1b555a7cbbf170bb8bf4b32ab3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Thu, 27 Dec 2018 17:07:30 +0100 Subject: [PATCH 1/8] Initial setup Follower Index form --- .../public/app/app.js | 1 + .../app/components/follower_index_form.js | 584 ++++++++++++++++++ .../public/app/components/index.js | 2 +- .../follower_index_add.container.js | 29 + .../follower_index_add/follower_index_add.js | 185 ++++++ .../app/sections/follower_index_add/index.js | 7 + .../public/app/services/api.js | 11 + .../app/services/follower_index_validators.js | 11 + .../public/app/store/action_types.js | 2 + .../app/store/actions/follower_index.js | 35 ++ 10 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js create 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/app.js b/x-pack/plugins/cross_cluster_replication/public/app/app.js index 8ae8dd60c2fa6..8e07cc116b515 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js @@ -52,6 +52,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 new file mode 100644 index 0000000000000..509eacc01bbe6 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.js @@ -0,0 +1,584 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiComboBox, + EuiDescribedFormGroup, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormHelpText, + EuiFormRow, + EuiLoadingKibana, + EuiLoadingSpinner, + EuiOverlayMask, + 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 { API_STATUS } from '../constants'; +import { SectionError } from './'; +import { validateFollowerIndex } from '../services/follower_index_validators'; + +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]; + } + } + return {}; +}; + +const getEmptyFollowerIndex = (remoteClusters) => ({ + name: '', + remoteCluster: getFirstConnectedCluster(remoteClusters).name, + leaderIndexPatterns: [], + followIndexPatternPrefix: '', + followIndexPatternSuffix: '', +}); + +export const updateFormErrors = (errors, existingErrors) => ({ + fieldsErrors: { + ...existingErrors, + ...errors, + } +}); + +export const FollowerIndexForm = injectI18n( + class extends PureComponent { + static propTypes = { + saveFollowerIndex: PropTypes.func.isRequired, + followerIndex: PropTypes.object, + apiError: PropTypes.object, + apiStatus: PropTypes.string.isRequired, + remoteClusters: PropTypes.array.isRequired, + } + + constructor(props) { + super(props); + + const isNew = this.props.followerIndex === undefined; + + const followerIndex = isNew + ? getEmptyFollowerIndex(this.props.remoteClusters) + : { + ...this.props.followerIndex, + }; + + this.state = { + followerIndex, + fieldsErrors: validateFollowerIndex(followerIndex), + areErrorsVisible: false, + isNew, + }; + } + + onFieldsChange = (fields) => { + this.setState(({ followerIndex }) => ({ + followerIndex: { + ...followerIndex, + ...fields, + }, + })); + + const errors = validateFollowerIndex(fields); + this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + }; + + onClusterChange = (remoteCluster) => { + this.onFieldsChange({ remoteCluster }); + }; + + getFields = () => { + const { followerIndex: stateFields } = this.state; + const { followIndexPatternPrefix, followIndexPatternSuffix, ...rest } = stateFields; + + return { + ...rest, + followIndexPattern: `${followIndexPatternPrefix}{{leader_index}}${followIndexPatternSuffix}` + }; + }; + + isFormValid() { + return Object.values(this.state.fieldsErrors).every(error => error === null); + } + + sendForm = () => { + const isFormValid = this.isFormValid(); + + if (!isFormValid) { + this.setState({ areErrorsVisible: true }); + return; + } + + this.setState({ areErrorsVisible: false }); + + const { name, ...followerIndex } = this.getFields(); + this.props.saveFollowerIndex(name, followerIndex); + }; + + cancelForm = () => { + routing.navigate('/follower_indices'); + }; + + /** + * Secctions Renders + */ + renderApiErrors() { + const { apiError, intl } = this.props; + + if (apiError) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', + defaultMessage: 'Error creating auto-follow pattern', + }); + return ; + } + + return null; + } + + renderForm = () => { + const { intl } = this.props; + const { + followerIndex: { + name, + remoteCluster, + leaderIndexPatterns, + followIndexPatternPrefix, + followIndexPatternSuffix, + }, + isNew, + areErrorsVisible, + fieldsErrors, + } = this.state; + + /** + * Auto-follow pattern Name + */ + const renderFollowerIndexName = () => { + const isInvalid = areErrorsVisible && !!fieldsErrors.name; + + return ( + +

+ +

+ + )} + description={( + + )} + fullWidth + > + + )} + error={fieldsErrors.name} + isInvalid={isInvalid} + fullWidth + > + this.onFieldsChange({ name: e.target.value })} + fullWidth + disabled={!isNew} + /> + +
+ ); + }; + + /** + * 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}` + })); + + return ( + +

+ +

+ + )} + description={( + + )} + fullWidth + > + + )} + fullWidth + > + + { isNew && ( + + )} + { !isNew && ( + + )} + + +
+ ); + }; + + /** + * Leader index pattern(s) + */ + const renderLeaderIndexPatterns = () => { + const hasError = !!fieldsErrors.leaderIndexPatterns; + const isInvalid = hasError && (fieldsErrors.leaderIndexPatterns.alwaysVisible || areErrorsVisible); + const formattedLeaderIndexPatterns = leaderIndexPatterns.map(pattern => ({ label: pattern })); + + return ( + +

+ +

+ + )} + description={( + +

+ +

+ +

+ + + + ) }} + /> +

+
+ )} + fullWidth + > + + )} + helpText={( + {indexPatternIllegalCharacters} }} + /> + )} + isInvalid={isInvalid} + error={fieldsErrors.leaderIndexPatterns && fieldsErrors.leaderIndexPatterns.message} + fullWidth + > + + +
+ ); + }; + + /** + * Auto-follow pattern + */ + const renderFollowerIndex = () => { + const isPrefixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternPrefix; + const isSuffixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternSuffix; + + return ( + +

+ +

+ + )} + description={( + + )} + fullWidth + > + + + + )} + error={fieldsErrors.followIndexPatternPrefix} + isInvalid={isPrefixInvalid} + fullWidth + > + this.onFieldsChange({ followIndexPatternPrefix: e.target.value })} + fullWidth + /> + + + + + + )} + error={fieldsErrors.followIndexPatternSuffix} + isInvalid={isSuffixInvalid} + fullWidth + > + this.onFieldsChange({ followIndexPatternSuffix: e.target.value })} + fullWidth + /> + + + + + + {indexNameIllegalCharacters} }} + /> + +
+ ); + }; + + /** + * Form Error warning message + */ + const renderFormErrorWarning = () => { + const { areErrorsVisible } = this.state; + const isFormValid = this.isFormValid(); + + if (!areErrorsVisible || isFormValid) { + return null; + } + + return ( + + + + )} + color="danger" + iconType="cross" + /> + + ); + }; + + /** + * Form Actions + */ + const renderActions = () => { + const { apiStatus } = this.props; + const { areErrorsVisible } = this.state; + + if (apiStatus === API_STATUS.SAVING) { + return ( + + + + + + + + + + + + ); + } + + const isSaveDisabled = areErrorsVisible && !this.isFormValid(); + + return ( + + + + + + + + + + + + + + ); + }; + + return ( + + + {renderFollowerIndexName()} + {renderRemoteClusterField()} + {renderLeaderIndexPatterns()} + {renderFollowerIndex()} + + {renderFormErrorWarning()} + + {renderActions()} + + ); + } + + renderLoading = () => { + const { apiStatus } = this.props; + + if (apiStatus === API_STATUS.SAVING) { + return ( + + + + ); + } + return null; + } + + render() { + return ( + + {this.renderApiErrors()} + {this.renderForm()} + {this.renderLoading()} + + ); + } + } +); + + 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 1e717eb8ea22d..7bc630884fd7c 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 @@ -12,5 +12,5 @@ export { AutoFollowPatternForm } from './auto_follow_pattern_form'; export { AutoFollowPatternDeleteProvider } from './auto_follow_pattern_delete_provider'; export { AutoFollowPatternPageTitle } from './auto_follow_pattern_page_title'; export { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; - export { FollowerIndexDeleteProvider } from './follower_index_delete_provider'; +export { FollowerIndexForm } from './follower_index_form'; 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 new file mode 100644 index 0000000000000..cf2efcb98432a --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.container.js @@ -0,0 +1,29 @@ +/* + * 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 { connect } from 'react-redux'; + +import { SECTIONS } from '../../constants'; +import { getApiStatus, getApiError } from '../../store/selectors'; +import { saveFollowerIndex, clearApiError } from '../../store/actions'; +import { FollowerIndexAdd as FollowerIndexAddView } from './follower_index_add'; + +const scope = SECTIONS.FOLLOWER_INDEX; + +const mapStateToProps = (state) => ({ + apiStatus: getApiStatus(scope)(state), + apiError: getApiError(scope)(state), +}); + +const mapDispatchToProps = dispatch => ({ + saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex)), + clearApiError: () => dispatch(clearApiError(scope)), +}); + +export const FollowerIndexAdd = connect( + mapStateToProps, + mapDispatchToProps +)(FollowerIndexAddView); 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 new file mode 100644 index 0000000000000..90459be8dd571 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js @@ -0,0 +1,185 @@ +/* + * 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, +} from '@elastic/eui'; + +import { listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import routing from '../../services/routing'; +import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; +import { + FollowerIndexForm, + AutoFollowPatternPageTitle, + RemoteClustersProvider, + SectionLoading, + SectionError, +} from '../../components'; + +export const FollowerIndexAdd = injectI18n( + class extends PureComponent { + static propTypes = { + saveAutoFollowPattern: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, + apiError: PropTypes.object, + apiStatus: PropTypes.string.isRequired, + } + + componentDidMount() { + chrome.breadcrumbs.set([ MANAGEMENT_BREADCRUMB, listBreadcrumb, addBreadcrumb ]); + } + + componentWillUnmount() { + 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; + + return ( + + + + + )} + /> + + + {({ isLoading, error, remoteClusters }) => { + if (isLoading) { + return ( + + + + ); + } + + if (error) { + const title = intl.formatMessage({ + id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.loadingRemoteClustersErrorTitle', + defaultMessage: 'Error loading remote clusters', + }); + 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/follower_index_add/index.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/index.js new file mode 100644 index 0000000000000..f7433f828fbba --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_add/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 { FollowerIndexAdd } from './follower_index_add.container'; 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 428c5eac79ac6..d26c5ad9fd543 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 @@ -23,6 +23,7 @@ export function setHttpClient(client) { const extractData = (response) => response.data; +/* Auto Follow Pattern */ export const loadAutoFollowPatterns = () => ( httpClient.get(`${apiPrefix}/auto_follow_patterns`).then(extractData) ); @@ -49,6 +50,7 @@ export const deleteAutoFollowPattern = (id) => { return httpClient.delete(`${apiPrefix}/auto_follow_patterns/${ids}`).then(extractData); }; +/* Follower Index */ export const loadFollowerIndices = () => ( httpClient.get(`${apiPrefix}/follower_indices`).then(extractData) ); @@ -57,6 +59,15 @@ export const getFollowerIndex = (id) => ( httpClient.get(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`).then(extractData) ); +export const createFollowerIndex = (followerIndex) => ( + httpClient.post(`${apiPrefix}/follower_indices`, followerIndex).then(extractData) +); + +export const updateFollowerIndex = (id, followerIndex) => ( + httpClient.put(`${apiPrefix}/follower_indices/${encodeURIComponent(id)}`, followerIndex).then(extractData) +); + +/* Stats */ export const loadAutoFollowStats = () => ( httpClient.get(`${apiPrefix}/stats/auto-follow`).then(extractData) ); 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 new file mode 100644 index 0000000000000..456fd41dd0f25 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/follower_index_validators.js @@ -0,0 +1,11 @@ +/* + * 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 const validateFollowerIndex = () => { + const errors = {}; + + return errors; +}; 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 42e9e207cd68d..68e9ca9788eff 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 @@ -23,6 +23,8 @@ export const AUTO_FOLLOW_PATTERN_DELETE = 'AUTO_FOLLOW_PATTERN_DELETE'; export const FOLLOWER_INDEX_SELECT_DETAIL = 'FOLLOWER_INDEX_SELECT_DETAIL'; export const FOLLOWER_INDEX_LOAD = 'FOLLOWER_INDEX_LOAD'; export const FOLLOWER_INDEX_GET = 'AUTO_FOLLOW_PATTERN_GET'; +export const FOLLOWER_INDEX_CREATE = 'FOLLOWER_INDEX_CREATE'; +export const FOLLOWER_INDEX_UPDATE = 'FOLLOWER_INDEX_UPDATE'; // Stats export const AUTO_FOLLOW_STATS_LOAD = 'AUTO_FOLLOW_STATS_LOAD'; 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 1f6b3ffcb1a86..edbfcb2ee6fac 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 @@ -3,10 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; +import routing from '../../services/routing'; import { SECTIONS, API_STATUS } from '../../constants'; import { loadFollowerIndices as loadFollowerIndicesRequest, getFollowerIndex as getFollowerIndexRequest, + createFollowerIndex as createFollowerIndexRequest, + updateFollowerIndex as updateFollowerIndexRequest, } from '../../services/api'; import * as t from '../action_types'; import { sendApiRequest } from './api'; @@ -36,3 +41,33 @@ export const getFollowerIndex = (id) => await getFollowerIndexRequest(id) ) }); + +export const saveFollowerIndex = (id, followerIndex, isUpdating = false) => ( + sendApiRequest({ + label: isUpdating ? t.FOLLOWER_INDEX_UPDATE : t.FOLLOWER_INDEX_CREATE, + status: API_STATUS.SAVING, + scope, + handler: async () => { + if (isUpdating) { + return await updateFollowerIndexRequest(id, followerIndex); + } + return await createFollowerIndexRequest({ id, ...followerIndex }); + }, + onSuccess() { + const successMessage = isUpdating + ? i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successMultipleNotificationTitle', { + defaultMessage: `Auto-follow pattern '{name}' updated successfully`, + values: { name: id }, + }) + : i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successSingleNotificationTitle', { + defaultMessage: `Added auto-follow pattern '{name}'`, + values: { name: id }, + }); + + toastNotifications.addSuccess(successMessage); + routing.navigate(`/follower_indices`, undefined, { + pattern: encodeURIComponent(id), + }); + }, + }) +); From a6dc49640d72aa873218484efc8cb30038c89e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 31 Dec 2018 11:03:21 +0100 Subject: [PATCH 2/8] Working form without client validation --- .../public/app/app.js | 5 +- .../follower_index_form.test.js.snap | 19 ++ .../auto_follow_pattern_page_title.js | 2 +- .../app/components/follower_index_form.js | 218 +++++------------- .../components/follower_index_form.test.js | 32 +++ .../components/follower_index_page_title.js | 55 +++++ .../public/app/components/index.js | 1 + .../follower_index_add/follower_index_add.js | 12 +- .../components/detail_panel/detail_panel.js | 45 ++-- .../follower_indices_table.js | 25 +- .../follower_indices_list.js | 4 +- .../public/app/sections/home/home.js | 2 +- .../public/app/sections/index.js | 1 + .../app/services/documentation_links.js | 1 + .../public/app/services/routing.js | 4 + .../app/store/actions/auto_follow_pattern.js | 4 +- .../app/store/actions/follower_index.js | 20 +- .../public/app/store/selectors/index.js | 2 - 18 files changed, 205 insertions(+), 247 deletions(-) create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js create mode 100644 x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.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 8e07cc116b515..9f43d1788258f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/app.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/app.js @@ -14,7 +14,8 @@ import { BASE_PATH } from '../../common/constants'; import { CrossClusterReplicationHome, AutoFollowPatternAdd, - AutoFollowPatternEdit + AutoFollowPatternEdit, + FollowerIndexAdd, } from './sections'; export class App extends Component { @@ -52,7 +53,7 @@ export class App extends Component { - + diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap b/x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap new file mode 100644 index 0000000000000..017f565ff9f48 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/__snapshots__/follower_index_form.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` state transitions updateFields() should merge new fields value with existing followerIndex 1`] = ` +Object { + "followerIndex": Object { + "leaderIndex": "bar", + "name": "new-name", + }, +} +`; + +exports[` state transitions updateFormErrors() should merge errors with existing fieldsErrors 1`] = ` +Object { + "fieldsErrors": Object { + "leaderIndex": null, + "name": "Some error", + }, +} +`; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js index 10ddd036cb4ad..da0ebbf4b9c13 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js @@ -40,7 +40,7 @@ export const AutoFollowPatternPageTitle = ({ title }) => ( iconType="help" > 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 509eacc01bbe6..03ff04f54931a 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 @@ -12,13 +12,11 @@ import { EuiButton, EuiButtonEmpty, EuiCallOut, - EuiComboBox, EuiDescribedFormGroup, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiForm, - EuiFormHelpText, EuiFormRow, EuiLoadingKibana, EuiLoadingSpinner, @@ -29,15 +27,13 @@ import { 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 { API_STATUS } from '../constants'; -import { SectionError } from './'; +import { SectionError } from './section_error'; import { validateFollowerIndex } from '../services/follower_index_validators'; -const indexPatternIllegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.join(' '); const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); const getFirstConnectedCluster = (clusters) => { @@ -52,14 +48,25 @@ const getFirstConnectedCluster = (clusters) => { const getEmptyFollowerIndex = (remoteClusters) => ({ name: '', remoteCluster: getFirstConnectedCluster(remoteClusters).name, - leaderIndexPatterns: [], - followIndexPatternPrefix: '', - followIndexPatternSuffix: '', + leaderIndex: '', }); -export const updateFormErrors = (errors, existingErrors) => ({ +/** + * State transitions: fields update + */ +export const updateFields = (fields) => ({ followerIndex }) => ({ + followerIndex: { + ...followerIndex, + ...fields, + }, +}); + +/** + * State transitions: errors update + */ +export const updateFormErrors = (errors) => ({ fieldsErrors }) => ({ fieldsErrors: { - ...existingErrors, + ...fieldsErrors, ...errors, } }); @@ -94,15 +101,9 @@ export const FollowerIndexForm = injectI18n( } onFieldsChange = (fields) => { - this.setState(({ followerIndex }) => ({ - followerIndex: { - ...followerIndex, - ...fields, - }, - })); - const errors = validateFollowerIndex(fields); - this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); + this.setState(updateFields(fields)); + this.setState(updateFormErrors(errors)); }; onClusterChange = (remoteCluster) => { @@ -110,13 +111,7 @@ export const FollowerIndexForm = injectI18n( }; getFields = () => { - const { followerIndex: stateFields } = this.state; - const { followIndexPatternPrefix, followIndexPatternSuffix, ...rest } = stateFields; - - return { - ...rest, - followIndexPattern: `${followIndexPatternPrefix}{{leader_index}}${followIndexPatternSuffix}` - }; + return this.state.followerIndex; }; isFormValid() { @@ -126,14 +121,14 @@ export const FollowerIndexForm = injectI18n( sendForm = () => { const isFormValid = this.isFormValid(); + this.setState({ areErrorsVisible: !isFormValid }); + if (!isFormValid) { - this.setState({ areErrorsVisible: true }); return; } - this.setState({ areErrorsVisible: false }); - const { name, ...followerIndex } = this.getFields(); + this.props.saveFollowerIndex(name, followerIndex); }; @@ -150,7 +145,7 @@ export const FollowerIndexForm = injectI18n( if (apiError) { const title = intl.formatMessage({ id: 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', - defaultMessage: 'Error creating auto-follow pattern', + defaultMessage: 'Error creating follower index', }); return ; } @@ -159,14 +154,11 @@ export const FollowerIndexForm = injectI18n( } renderForm = () => { - const { intl } = this.props; const { followerIndex: { name, remoteCluster, - leaderIndexPatterns, - followIndexPatternPrefix, - followIndexPatternSuffix, + leaderIndex, }, isNew, areErrorsVisible, @@ -174,7 +166,7 @@ export const FollowerIndexForm = injectI18n( } = this.state; /** - * Auto-follow pattern Name + * Follower index name */ const renderFollowerIndexName = () => { const isInvalid = areErrorsVisible && !!fieldsErrors.name; @@ -194,7 +186,7 @@ export const FollowerIndexForm = injectI18n( description={( )} fullWidth @@ -206,6 +198,13 @@ export const FollowerIndexForm = injectI18n( defaultMessage="Name" /> )} + helpText={( + {indexNameIllegalCharacters} }} + /> + )} error={fieldsErrors.name} isInvalid={isInvalid} fullWidth @@ -248,7 +247,7 @@ export const FollowerIndexForm = injectI18n( description={( )} fullWidth @@ -284,12 +283,11 @@ export const FollowerIndexForm = injectI18n( }; /** - * Leader index pattern(s) + * Leader index */ - const renderLeaderIndexPatterns = () => { - const hasError = !!fieldsErrors.leaderIndexPatterns; - const isInvalid = hasError && (fieldsErrors.leaderIndexPatterns.alwaysVisible || areErrorsVisible); - const formattedLeaderIndexPatterns = leaderIndexPatterns.map(pattern => ({ label: pattern })); + const renderLeaderIndex = () => { + const hasError = !!fieldsErrors.leaderIndex; + const isInvalid = hasError && areErrorsVisible; return (

@@ -307,25 +305,8 @@ export const FollowerIndexForm = injectI18n(

-

- -

- - - - ) }} + id="xpack.crossClusterReplication.followerIndexForm.sectionLeaderIndexDescription1" + defaultMessage="The leader index you want to replicate from the remote cluster." />

@@ -335,31 +316,25 @@ export const FollowerIndexForm = injectI18n( )} helpText={( {indexPatternIllegalCharacters} }} + values={{ characterList: {indexNameIllegalCharacters} }} /> )} isInvalid={isInvalid} - error={fieldsErrors.leaderIndexPatterns && fieldsErrors.leaderIndexPatterns.message} + error={fieldsErrors.leaderIndex && fieldsErrors.leaderIndex.message} fullWidth > - this.onFieldsChange({ leaderIndex: e.target.value })} fullWidth /> @@ -367,92 +342,6 @@ export const FollowerIndexForm = injectI18n( ); }; - /** - * Auto-follow pattern - */ - const renderFollowerIndex = () => { - const isPrefixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternPrefix; - const isSuffixInvalid = areErrorsVisible && !!fieldsErrors.followIndexPatternSuffix; - - return ( - -

- -

- - )} - description={( - - )} - fullWidth - > - - - - )} - error={fieldsErrors.followIndexPatternPrefix} - isInvalid={isPrefixInvalid} - fullWidth - > - this.onFieldsChange({ followIndexPatternPrefix: e.target.value })} - fullWidth - /> - - - - - - )} - error={fieldsErrors.followIndexPatternSuffix} - isInvalid={isSuffixInvalid} - fullWidth - > - this.onFieldsChange({ followIndexPatternSuffix: e.target.value })} - fullWidth - /> - - - - - - {indexNameIllegalCharacters} }} - /> - -
- ); - }; - /** * Form Error warning message */ @@ -546,8 +435,7 @@ export const FollowerIndexForm = injectI18n( {renderFollowerIndexName()} {renderRemoteClusterField()} - {renderLeaderIndexPatterns()} - {renderFollowerIndex()} + {renderLeaderIndex()} {renderFormErrorWarning()} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js new file mode 100644 index 0000000000000..74fdf3301bcca --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_form.test.js @@ -0,0 +1,32 @@ +/* + * 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 { updateFields, updateFormErrors } from './follower_index_form'; + +jest.mock('ui/indices', () => ({ + INDEX_ILLEGAL_CHARACTERS_VISIBLE: [], +})); + +describe(' state transitions', () => { + it('updateFormErrors() should merge errors with existing fieldsErrors', () => { + const errors = { name: 'Some error' }; + const state = { + fieldsErrors: { leaderIndex: null } + }; + const output = updateFormErrors(errors)(state); + expect(output).toMatchSnapshot(); + }); + + it('updateFields() should merge new fields value with existing followerIndex', () => { + const fields = { name: 'new-name' }; + const state = { + followerIndex: { name: 'foo', leaderIndex: 'bar' } + }; + const output = updateFields(fields)(state); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js new file mode 100644 index 0000000000000..a1ec4a44e218a --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_page_title.js @@ -0,0 +1,55 @@ +/* + * 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 } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiPageContentHeader, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { followerIndexUrl } from '../services/documentation_links'; + +export const FollowerIndexPageTitle = ({ title }) => ( + + + + + + + +

{title}

+
+
+ + + + + + +
+
+
+); + +FollowerIndexPageTitle.propTypes = { + title: PropTypes.node.isRequired, +}; 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 7bc630884fd7c..5bf4ed3512a57 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 @@ -14,3 +14,4 @@ export { AutoFollowPatternPageTitle } from './auto_follow_pattern_page_title'; export { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; export { FollowerIndexDeleteProvider } from './follower_index_delete_provider'; export { FollowerIndexForm } from './follower_index_form'; +export { FollowerIndexPageTitle } from './follower_index_page_title'; 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 90459be8dd571..60d3c236731c4 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 @@ -23,7 +23,7 @@ import routing from '../../services/routing'; import { BASE_PATH_REMOTE_CLUSTERS } from '../../../../common/constants'; import { FollowerIndexForm, - AutoFollowPatternPageTitle, + FollowerIndexPageTitle, RemoteClustersProvider, SectionLoading, SectionError, @@ -32,7 +32,7 @@ import { export const FollowerIndexAdd = injectI18n( class extends PureComponent { static propTypes = { - saveAutoFollowPattern: PropTypes.func.isRequired, + saveFollowerIndex: PropTypes.func.isRequired, clearApiError: PropTypes.func.isRequired, apiError: PropTypes.object, apiStatus: PropTypes.string.isRequired, @@ -119,7 +119,7 @@ export const FollowerIndexAdd = injectI18n( } render() { - const { saveAutoFollowPattern, apiStatus, apiError, intl } = this.props; + const { saveFollowerIndex, apiStatus, apiError, intl } = this.props; return ( @@ -128,11 +128,11 @@ export const FollowerIndexAdd = injectI18n( horizontalPosition="center" className="ccrPageContent" > - )} /> @@ -171,7 +171,7 @@ export const FollowerIndexAdd = injectI18n( apiStatus={apiStatus} apiError={apiError} remoteClusters={remoteClusters} - saveAutoFollowPattern={saveAutoFollowPattern} + saveFollowerIndex={saveFollowerIndex} /> ); }} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js index 43c052fcd8094..a1fc606808eea 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.js @@ -10,7 +10,6 @@ import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { getIndexListUri } from '../../../../../../../../index_management/public/services/navigation'; import { - EuiButton, EuiButtonEmpty, EuiCodeEditor, EuiDescriptionList, @@ -95,8 +94,8 @@ export class DetailPanelUi extends Component { @@ -239,38 +238,20 @@ export class DetailPanelUi extends Component { {followerIndex && ( - - - - {(deleteFollowerIndex) => ( - deleteFollowerIndex(followerIndex.name)} - > - - - )} - - - - - { - // routing.navigate(encodeURI(`/follower_indices/edit/${encodeURIComponent(followerIndex.name)}`)); - }} + + {(deleteFollowerIndex) => ( + deleteFollowerIndex(followerIndex.name)} > - - - + + )} + + )} 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 d782211d9e3bb..5df1c36be4f0f 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 @@ -19,7 +19,6 @@ import { } from '@elastic/eui'; // import { API_STATUS } from '../../../../../constants'; import { FollowerIndexDeleteProvider } from '../../../../../components'; -// import routing from '../../../../../services/routing'; export const FollowerIndicesTable = injectI18n( class extends PureComponent { @@ -126,29 +125,7 @@ export const FollowerIndicesTable = injectI18n( ); }, - }, - { - render: ({ /*name*/ }) => { - const label = i18n.translate('xpack.crossClusterReplication.followerIndexList.table.actionEditDescription', { - defaultMessage: 'Edit follower index', - }); - - return ( - - - - ); - }, - }, - ], + }], width: '100px', }]; } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js index bd6fbdd3812ef..b20c375e9d4ac 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js @@ -9,7 +9,7 @@ import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; -// import routing from '../../../services/routing'; +import routing from '../../../services/routing'; import { extractQueryParams } from '../../../services/query_params'; import { API_STATUS } from '../../../constants'; import { SectionLoading, SectionError } from '../../../components'; @@ -109,7 +109,7 @@ export const FollowerIndicesList = injectI18n( } actions={ diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js index 51b34c1b2769f..51f862ab11dbc 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/home.js @@ -105,7 +105,7 @@ export const CrossClusterReplicationHome = injectI18n( {isFollowerIndexApiAuthorized && ( 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 1c15088c7b695..5a72353730d24 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 @@ -7,3 +7,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'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js index a63bb87921162..67e0a9fdc2054 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/documentation_links.js @@ -9,3 +9,4 @@ import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; export const autoFollowPatternUrl = `${esBase}/ccr-put-auto-follow-pattern.html`; +export const followerIndexUrl = `${esBase}/ccr-put-follow.html`; 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 fd24a8932f532..fee74529f3fe0 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,6 +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)}`); + }; + get reactRouter() { return this._reactRouter; } 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 b041be4e4bea5..2bd3141f11155 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 @@ -62,11 +62,11 @@ export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) }, onSuccess() { const successMessage = isUpdating - ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successMultipleNotificationTitle', { + ? i18n.translate('xpack.crossClusterReplication.autoFollowPattern.updateAction.successNotificationTitle', { defaultMessage: `Auto-follow pattern '{name}' updated successfully`, values: { name: id }, }) - : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successSingleNotificationTitle', { + : i18n.translate('xpack.crossClusterReplication.autoFollowPattern.addAction.successNotificationTitle', { defaultMessage: `Added auto-follow pattern '{name}'`, values: { name: id }, }); 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 edbfcb2ee6fac..74bc068adbed7 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 @@ -42,31 +42,31 @@ export const getFollowerIndex = (id) => ) }); -export const saveFollowerIndex = (id, followerIndex, isUpdating = false) => ( +export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => ( sendApiRequest({ label: isUpdating ? t.FOLLOWER_INDEX_UPDATE : t.FOLLOWER_INDEX_CREATE, status: API_STATUS.SAVING, scope, handler: async () => { if (isUpdating) { - return await updateFollowerIndexRequest(id, followerIndex); + return await updateFollowerIndexRequest(name, followerIndex); } - return await createFollowerIndexRequest({ id, ...followerIndex }); + return await createFollowerIndexRequest({ name, ...followerIndex }); }, onSuccess() { const successMessage = isUpdating - ? i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successMultipleNotificationTitle', { - defaultMessage: `Auto-follow pattern '{name}' updated successfully`, - values: { name: id }, + ? i18n.translate('xpack.crossClusterReplication.followerIndex.updateAction.successNotificationTitle', { + defaultMessage: `Follower index '{name}' updated successfully`, + values: { name }, }) - : i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successSingleNotificationTitle', { - defaultMessage: `Added auto-follow pattern '{name}'`, - values: { name: id }, + : i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { + defaultMessage: `Added follower index '{name}'`, + values: { name }, }); toastNotifications.addSuccess(successMessage); routing.navigate(`/follower_indices`, undefined, { - pattern: encodeURIComponent(id), + pattern: encodeURIComponent(name), }); }, }) 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 8c043046a149f..b08eda8bc5a8b 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 @@ -59,5 +59,3 @@ export const getSelectedFollowerIndex = (view = 'detail') => createSelector(getF return followerIndexState.byId[followerIndexState[propId]]; }); export const getListFollowerIndices = createSelector(getFollowerIndices, (followerIndices) => objectToArray(followerIndices)); - - From 3156a2b47e8eab80e04de23d9a5385fc4c0542aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 31 Dec 2018 13:12:41 +0100 Subject: [PATCH 3/8] Add client side validation for follower index --- .../app/components/follower_index_form.js | 2 +- .../app/services/follower_index_validators.js | 91 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 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 03ff04f54931a..e084e835021c7 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 @@ -205,7 +205,7 @@ export const FollowerIndexForm = injectI18n( values={{ characterList: {indexNameIllegalCharacters} }} /> )} - error={fieldsErrors.name} + error={fieldsErrors.name && fieldsErrors.name.message} isInvalid={isInvalid} fullWidth > 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 index 456fd41dd0f25..df31e2f3d1c23 100644 --- 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 @@ -4,8 +4,97 @@ * you may not use this file except in compliance with the Elastic License. */ -export const validateFollowerIndex = () => { +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; }; From 4eb5019420eb1bbc1ed7a45c6bd3d2c48561f6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 31 Dec 2018 14:50:33 +0100 Subject: [PATCH 4/8] Add client validation to check if index already exist --- .../common/constants/base_path.js | 1 + .../app/components/follower_index_form.js | 36 +++++++++++++++++-- .../public/app/services/api.js | 32 +++++++++++++++-- .../public/register_routes.js | 4 +-- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js b/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js index f87d5767fafc2..0a948793e07db 100644 --- a/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js +++ b/x-pack/plugins/cross_cluster_replication/common/constants/base_path.js @@ -8,3 +8,4 @@ export const BASE_PATH = '/management/elasticsearch/cross_cluster_replication'; export const BASE_PATH_REMOTE_CLUSTERS = '/management/elasticsearch/remote_clusters'; export const API_BASE_PATH = '/api/cross_cluster_replication'; export const API_REMOTE_CLUSTERS_BASE_PATH = '/api/remote_clusters'; +export const API_INDEX_MANAGEMENT_BASE_PATH = '/api/index_management'; 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 e084e835021c7..07cdeb5772af2 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 @@ -7,6 +7,7 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { debounce } from 'lodash'; import { EuiButton, @@ -33,6 +34,7 @@ import routing from '../services/routing'; import { API_STATUS } from '../constants'; import { SectionError } from './section_error'; import { validateFollowerIndex } from '../services/follower_index_validators'; +import { loadIndices } from '../services/api'; const indexNameIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); @@ -98,6 +100,8 @@ export const FollowerIndexForm = injectI18n( areErrorsVisible: false, isNew, }; + + this.validateIndexName = debounce(this.validateIndexName, 500); } onFieldsChange = (fields) => { @@ -136,6 +140,33 @@ 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 */ @@ -169,7 +200,8 @@ export const FollowerIndexForm = injectI18n( * Follower index name */ const renderFollowerIndexName = () => { - const isInvalid = areErrorsVisible && !!fieldsErrors.name; + const hasError = !!fieldsErrors.name; + const isInvalid = hasError && (fieldsErrors.name.alwaysVisible || areErrorsVisible); return ( this.onFieldsChange({ name: e.target.value })} + onChange={e => this.onIndexNameChange(e.target.value)} fullWidth disabled={!isNew} /> 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 d26c5ad9fd543..958ab45966900 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 @@ -5,18 +5,29 @@ */ import chrome from 'ui/chrome'; -import { API_BASE_PATH, API_REMOTE_CLUSTERS_BASE_PATH } from '../../../common/constants'; +import { + API_BASE_PATH, + API_REMOTE_CLUSTERS_BASE_PATH, + API_INDEX_MANAGEMENT_BASE_PATH, +} from '../../../common/constants'; import { arrify } from '../../../common/services/utils'; const apiPrefix = chrome.addBasePath(API_BASE_PATH); const apiPrefixRemoteClusters = chrome.addBasePath(API_REMOTE_CLUSTERS_BASE_PATH); +const apiPrefixIndexManagement = chrome.addBasePath(API_INDEX_MANAGEMENT_BASE_PATH); // This is an Angular service, which is why we use this provider pattern // to access it within our React app. let httpClient; -export function setHttpClient(client) { +// The deffered AngularJS api allows us to create deferred promise +// to be resolved later. This allows us to cancel in flight Http Requests +// https://docs.angularjs.org/api/ng/service/$q#the-deferred-api +let $q; + +export function setHttpClient(client, $deffered) { httpClient = client; + $q = $deffered; } // --- @@ -69,5 +80,20 @@ export const updateFollowerIndex = (id, followerIndex) => ( /* Stats */ export const loadAutoFollowStats = () => ( - httpClient.get(`${apiPrefix}/stats/auto-follow`).then(extractData) + httpClient.get(`${apiPrefixIndexManagement}/stats/auto-follow`).then(extractData) ); + +/* Indices */ +let canceler = null; +export const loadIndices = () => { + if (canceler) { + // If there is a previous request in flight we cancel it by resolving the canceler + canceler.resolve(); + } + canceler = $q.defer(); + return httpClient.get(`${apiPrefixIndexManagement}/indices`, { timeout: canceler.promise }) + .then((response) => { + canceler = null; + return extractData(response); + }); +}; 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 d8084f1d3415c..5030cae7e94c7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/register_routes.js +++ b/x-pack/plugins/cross_cluster_replication/public/register_routes.js @@ -24,7 +24,7 @@ if (chrome.getInjected('ccrUiEnabled')) { template: template, controllerAs: 'ccr', controller: class CrossClusterReplicationController { - constructor($scope, $route, $http) { + constructor($scope, $route, $http, $q) { /** * React-router's does not play well with the angular router. It will cause this controller * to re-execute without the $destroy handler being called. This means that the app will be mounted twice @@ -35,7 +35,7 @@ if (chrome.getInjected('ccrUiEnabled')) { // NOTE: We depend upon Angular's $http service because it's decorated with interceptors, // e.g. to check license status per request. - setHttpClient($http); + setHttpClient($http, $q); $scope.$$postDigest(() => { elem = document.getElementById(CCR_REACT_ROOT); From eee029fa7cf4b5beca7d6a5ad93e123d6f3be5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 31 Dec 2018 16:54:33 +0100 Subject: [PATCH 5/8] Improve error message when leader index does not exist --- .../public/app/components/follower_index_form.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 07cdeb5772af2..62932566085d3 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 @@ -178,7 +178,17 @@ export const FollowerIndexForm = injectI18n( id: 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', defaultMessage: 'Error creating follower index', }); - return ; + const error = apiError.status === 404 + ? { + data: { + message: intl.formatMessage({ + id: 'xpack.crossClusterReplication.followerIndexForm.leaderIndexNotFoundError', + defaultMessage: `The leader index '{leaderIndex}' you want to replicate from does not exist.`, + }, { leaderIndex: this.state.followerIndex.leaderIndex }) + } + } + : apiError; + return ; } return null; From f015b218a499bd9a4aa65306cb7c70630258efd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 31 Dec 2018 17:01:58 +0100 Subject: [PATCH 6/8] Remove update method for follower index --- .../public/app/services/api.js | 4 --- .../app/store/actions/follower_index.js | 27 +++++++------------ 2 files changed, 9 insertions(+), 22 deletions(-) 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 958ab45966900..be872d1df3d00 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 @@ -74,10 +74,6 @@ export const createFollowerIndex = (followerIndex) => ( httpClient.post(`${apiPrefix}/follower_indices`, followerIndex).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/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js index 74bc068adbed7..7f67849ad6d91 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 @@ -11,7 +11,6 @@ import { loadFollowerIndices as loadFollowerIndicesRequest, getFollowerIndex as getFollowerIndexRequest, createFollowerIndex as createFollowerIndexRequest, - updateFollowerIndex as updateFollowerIndexRequest, } from '../../services/api'; import * as t from '../action_types'; import { sendApiRequest } from './api'; @@ -42,27 +41,19 @@ export const getFollowerIndex = (id) => ) }); -export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => ( +export const saveFollowerIndex = (name, followerIndex) => ( sendApiRequest({ - label: isUpdating ? t.FOLLOWER_INDEX_UPDATE : t.FOLLOWER_INDEX_CREATE, + label: t.FOLLOWER_INDEX_CREATE, status: API_STATUS.SAVING, scope, - handler: async () => { - if (isUpdating) { - return await updateFollowerIndexRequest(name, followerIndex); - } - return await createFollowerIndexRequest({ name, ...followerIndex }); - }, + handler: async () => ( + await createFollowerIndexRequest({ name, ...followerIndex }) + ), onSuccess() { - 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 }, - }); + const successMessage = i18n.translate('xpack.crossClusterReplication.followerIndex.addAction.successNotificationTitle', { + defaultMessage: `Added follower index '{name}'`, + values: { name }, + }); toastNotifications.addSuccess(successMessage); routing.navigate(`/follower_indices`, undefined, { From e4d828fe09b2f6e2069e45098dcc73eba17babc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 31 Dec 2018 17:26:45 +0100 Subject: [PATCH 7/8] Clear api error on field change --- .../public/app/components/follower_index_form.js | 8 +++++++- .../app/sections/follower_index_add/follower_index_add.js | 3 ++- 2 files changed, 9 insertions(+), 2 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 62932566085d3..c06856f730695 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 @@ -77,6 +77,7 @@ export const FollowerIndexForm = injectI18n( class extends PureComponent { static propTypes = { saveFollowerIndex: PropTypes.func.isRequired, + clearApiError: PropTypes.func.isRequired, followerIndex: PropTypes.object, apiError: PropTypes.object, apiStatus: PropTypes.string.isRequired, @@ -108,6 +109,10 @@ export const FollowerIndexForm = injectI18n( const errors = validateFollowerIndex(fields); this.setState(updateFields(fields)); this.setState(updateFormErrors(errors)); + + if (this.props.apiError) { + this.props.clearApiError(); + } }; onClusterChange = (remoteCluster) => { @@ -178,13 +183,14 @@ export const FollowerIndexForm = injectI18n( id: 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', defaultMessage: 'Error creating follower index', }); + const { leaderIndex } = this.state.followerIndex; const error = apiError.status === 404 ? { data: { message: intl.formatMessage({ id: 'xpack.crossClusterReplication.followerIndexForm.leaderIndexNotFoundError', defaultMessage: `The leader index '{leaderIndex}' you want to replicate from does not exist.`, - }, { leaderIndex: this.state.followerIndex.leaderIndex }) + }, { leaderIndex }) } } : apiError; 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 60d3c236731c4..b14dc52a6f46b 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 @@ -119,7 +119,7 @@ export const FollowerIndexAdd = injectI18n( } render() { - const { saveFollowerIndex, apiStatus, apiError, intl } = this.props; + const { saveFollowerIndex, clearApiError, apiStatus, apiError, intl } = this.props; return ( @@ -172,6 +172,7 @@ export const FollowerIndexAdd = injectI18n( apiError={apiError} remoteClusters={remoteClusters} saveFollowerIndex={saveFollowerIndex} + clearApiError={clearApiError} /> ); }} From 5d8fb794b9f5630f6ee3dd749bb5d22d0c27b221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Se=CC=81bastien?= Date: Mon, 31 Dec 2018 18:30:10 +0100 Subject: [PATCH 8/8] Fix i18n error --- .../follower_index_add/follower_index_add.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 b14dc52a6f46b..44bd15b8cbf96 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 @@ -49,7 +49,7 @@ export const FollowerIndexAdd = injectI18n( renderEmptyClusters() { const { intl, match: { url: currentUrl } } = this.props; const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.emptyRemoteClustersCallOutTitle', + id: 'xpack.crossClusterReplication.followerIndexCreateForm.emptyRemoteClustersCallOutTitle', defaultMessage: 'No remote cluster found' }); @@ -62,7 +62,7 @@ export const FollowerIndexAdd = injectI18n( >

@@ -74,7 +74,7 @@ export const FollowerIndexAdd = injectI18n( color="warning" > @@ -86,7 +86,7 @@ export const FollowerIndexAdd = injectI18n( renderNoConnectedCluster() { const { intl } = this.props; const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.noRemoteClustersConnectedCallOutTitle', + id: 'xpack.crossClusterReplication.followerIndexCreateForm.noRemoteClustersConnectedCallOutTitle', defaultMessage: 'Remote cluster connection error' }); @@ -99,7 +99,7 @@ export const FollowerIndexAdd = injectI18n( >

@@ -109,7 +109,7 @@ export const FollowerIndexAdd = injectI18n( color="warning" > @@ -131,7 +131,7 @@ export const FollowerIndexAdd = injectI18n( )} @@ -143,7 +143,7 @@ export const FollowerIndexAdd = injectI18n( return ( @@ -152,7 +152,7 @@ export const FollowerIndexAdd = injectI18n( if (error) { const title = intl.formatMessage({ - id: 'xpack.crossClusterReplication.autoFollowPatternCreateForm.loadingRemoteClustersErrorTitle', + id: 'xpack.crossClusterReplication.followerIndexCreateForm.loadingRemoteClustersErrorTitle', defaultMessage: 'Error loading remote clusters', }); return ;