diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js new file mode 100644 index 0000000000000..90664f07be621 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_add.test.js @@ -0,0 +1,275 @@ +/* + * 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 sinon from 'sinon'; + +import { initTestBed, registerHttpRequestMockHelpers, nextTick, findTestSubject, getRandomString } from './test_helpers'; +import { AutoFollowPatternAdd } from '../../public/app/sections/auto_follow_pattern_add'; +import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from '../../../../../src/legacy/ui/public/index_patterns'; +import routing from '../../public/app/services/routing'; + +jest.mock('ui/chrome', () => ({ + addBasePath: (path) => path || 'api/cross_cluster_replication', + breadcrumbs: { set: () => {} }, +})); + +jest.mock('ui/index_patterns', () => { + const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); + const { validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/validate/validate_index_pattern'); + return { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE, validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES }; +}); + +const testBedOptions = { + memoryRouter: { + onRouter: (router) => routing.reactRouter = router + } +}; + +describe('Create Auto-follow pattern', () => { + let server; + let find; + let exists; + let component; + let getUserActions; + let form; + let getFormErrorsMessages; + let clickSaveForm; + let setLoadRemoteClustersResponse; + + beforeEach(() => { + server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Register helpers to mock Http Requests + ({ + setLoadRemoteClustersResponse + } = registerHttpRequestMockHelpers(server)); + + // Set "default" mock responses by not providing any arguments + setLoadRemoteClustersResponse(); + + // Mock all HTTP Requests that have not been handled previously + server.respondWith([200, {}, '']); + }); + + describe('on component mount', () => { + beforeEach(() => { + ({ find, exists } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + }); + + test('should display a "loading remote clusters" indicator', () => { + expect(exists('remoteClustersLoading')).toBe(true); + expect(find('remoteClustersLoading').text()).toBe('Loading remote clusters…'); + }); + + test('should have a link to the documentation', () => { + expect(exists('autoFollowPatternDocsButton')).toBe(true); + }); + }); + + describe('when remote clusters are loaded', () => { + beforeEach(async () => { + ({ find, exists, component, getUserActions, getFormErrorsMessages } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + + ({ clickSaveForm } = getUserActions('autoFollowPatternForm')); + + await nextTick(); // We need to wait next tick for the mock server response to comes in + component.update(); + }); + + test('should display the Auto-follow pattern form', async () => { + expect(exists('ccrAutoFollowPatternForm')).toBe(true); + }); + + test('should display errors and disable the save button when clicking "save" without filling the form', () => { + expect(exists('autoFollowPatternFormError')).toBe(false); + expect(find('ccrAutoFollowPatternFormSubmitButton').props().disabled).toBe(false); + + clickSaveForm(); + + expect(exists('autoFollowPatternFormError')).toBe(true); + expect(getFormErrorsMessages()).toEqual([ + 'Name is required.', + 'At least one leader index pattern is required.', + ]); + expect(find('ccrAutoFollowPatternFormSubmitButton').props().disabled).toBe(true); + }); + }); + + describe('form validation', () => { + describe('auto-follow pattern name', () => { + beforeEach(async () => { + ({ component, form, getUserActions, getFormErrorsMessages } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + ({ clickSaveForm } = getUserActions('autoFollowPatternForm')); + + await nextTick(); + component.update(); + }); + + test('should not allow spaces', () => { + form.setInputValue('ccrAutoFollowPatternFormNameInput', 'with space'); + clickSaveForm(); + expect(getFormErrorsMessages()).toContain('Spaces are not allowed in the name.'); + }); + + test('should not allow a "_" (underscore) as first character', () => { + form.setInputValue('ccrAutoFollowPatternFormNameInput', '_withUnderscore'); + clickSaveForm(); + expect(getFormErrorsMessages()).toContain(`Name can't begin with an underscore.`); + }); + + test('should not allow a "," (comma)', () => { + form.setInputValue('ccrAutoFollowPatternFormNameInput', 'with,coma'); + clickSaveForm(); + expect(getFormErrorsMessages()).toContain(`Commas are not allowed in the name.`); + }); + }); + + describe('remote clusters', () => { + describe('when no remote clusters were found', () => { + test('should indicate it and have a button to add one', async () => { + setLoadRemoteClustersResponse([]); + + ({ find, component } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + await nextTick(); + component.update(); + const errorCallOut = find('remoteClusterFieldNoClusterFoundError'); + + expect(errorCallOut.length).toBe(1); + expect(findTestSubject(errorCallOut, 'ccrRemoteClusterAddButton').length).toBe(1); + }); + }); + + describe('when there was an error loading the remote clusters', () => { + test('should indicate no clusters found and have a button to add one', async () => { + setLoadRemoteClustersResponse(undefined, { body: 'Houston we got a problem' }); + + ({ find, component } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + await nextTick(); + component.update(); + const errorCallOut = find('remoteClusterFieldNoClusterFoundError'); + + expect(errorCallOut.length).toBe(1); + expect(findTestSubject(errorCallOut, 'ccrRemoteClusterAddButton').length).toBe(1); + }); + }); + + describe('when none of the remote clusters is connected', () => { + const clusterName = 'new-york'; + const remoteClusters = [{ + name: clusterName, + seeds: ['localhost:9600'], + isConnected: false, + }]; + + beforeEach(async () => { + setLoadRemoteClustersResponse(remoteClusters); + + ({ find, exists, component } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + await nextTick(); + component.update(); + }); + + test('should show a callout warning and have a button to edit the cluster', () => { + const errorCallOut = find('remoteClusterFieldCallOutError'); + + expect(errorCallOut.length).toBe(1); + expect(errorCallOut.find('.euiCallOutHeader__title').text()).toBe(`Remote cluster '${clusterName}' is not connected`); + expect(findTestSubject(errorCallOut, 'ccrRemoteClusterEditButton').length).toBe(1); + }); + + test('should have a button to add another remote cluster', () => { + expect(exists('ccrRemoteClusterInlineAddButton')).toBe(true); + }); + + test('should indicate in the select option that the cluster is not connected', () => { + const selectOptions = find('ccrRemoteClusterSelect').find('option'); + expect(selectOptions.at(0).text()).toBe(`${clusterName} (not connected)`); + }); + }); + }); + + describe('index patterns', () => { + beforeEach(async () => { + ({ component, form, getUserActions, getFormErrorsMessages } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + ({ clickSaveForm } = getUserActions('autoFollowPatternForm')); + + await nextTick(); + component.update(); + }); + + test('should not allow spaces', () => { + expect(getFormErrorsMessages()).toEqual([]); + + form.setIndexPatternValue('with space'); + + expect(getFormErrorsMessages()).toContain('Spaces are not allowed in the index pattern.'); + }); + + test('should not allow invalid characters', () => { + const expectInvalidChar = (char) => { + form.setIndexPatternValue(`with${char}space`); + expect(getFormErrorsMessages()).toContain(`Remove the character ${char} from the index pattern.`); + }; + + return INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.reduce((promise, char) => { + return promise.then(() => expectInvalidChar(char)); + }, Promise.resolve()); + }); + }); + }); + + describe('generated indices preview', () => { + beforeEach(async () => { + ({ exists, find, component, form, getUserActions } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions)); + ({ clickSaveForm } = getUserActions('autoFollowPatternForm')); + + await nextTick(); + component.update(); + }); + + test('should display a preview of the possible indices generated by the auto-follow pattern', () => { + expect(exists('ccrAutoFollowPatternIndicesPreview')).toBe(false); + + form.setIndexPatternValue('kibana-'); + + expect(exists('ccrAutoFollowPatternIndicesPreview')).toBe(true); + }); + + test('should display 3 indices example when providing a wildcard(*)', () => { + form.setIndexPatternValue('kibana-*'); + const indicesPreview = find('ccrAutoFollowPatternIndexPreview'); + + expect(indicesPreview.length).toBe(3); + expect(indicesPreview.at(0).text()).toContain('kibana-'); + }); + + test('should only display 1 index example when *not* providing a wildcard', () => { + form.setIndexPatternValue('kibana'); + const indicesPreview = find('ccrAutoFollowPatternIndexPreview'); + + expect(indicesPreview.length).toBe(1); + expect(indicesPreview.at(0).text()).toEqual('kibana'); + }); + + test('should add the prefix and the suffix to the preview', () => { + const prefix = getRandomString(); + const suffix = getRandomString(); + + form.setIndexPatternValue('kibana'); + form.setInputValue('ccrAutoFollowPatternFormPrefixInput', prefix); + form.setInputValue('ccrAutoFollowPatternFormSuffixInput', suffix); + + const indicesPreview = find('ccrAutoFollowPatternIndexPreview'); + const textPreview = indicesPreview.at(0).text(); + + expect(textPreview).toContain(prefix); + expect(textPreview).toContain(suffix); + }); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js new file mode 100644 index 0000000000000..698a20cbd8f08 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_edit.test.js @@ -0,0 +1,144 @@ +/* + * 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 sinon from 'sinon'; + +import { initTestBed, registerHttpRequestMockHelpers, nextTick, findTestSubject } from './test_helpers'; +import { AutoFollowPatternAdd } from '../../public/app/sections/auto_follow_pattern_add'; +import { AutoFollowPatternEdit } from '../../public/app/sections/auto_follow_pattern_edit'; +import { AutoFollowPatternForm } from '../../public/app/components/auto_follow_pattern_form'; +import routing from '../../public/app/services/routing'; + +jest.mock('ui/chrome', () => ({ + addBasePath: (path) => path || 'api/cross_cluster_replication', + breadcrumbs: { set: () => {} }, +})); + +jest.mock('ui/index_patterns', () => { + const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); + const { validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/validate/validate_index_pattern'); + return { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE, validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES }; +}); + +const AUTO_FOLLOW_PATTERN_NAME = 'my-autofollow'; + +const AUTO_FOLLOW_PATTERN = { + name: AUTO_FOLLOW_PATTERN_NAME, + remoteCluster: 'cluster-2', + leaderIndexPatterns: ['my-pattern-*'], + followIndexPattern: 'prefix_{{leader_index}}_suffix' +}; + +const testBedOptions = { + memoryRouter: { + onRouter: (router) => routing.reactRouter = router, + // The auto-follow pattern id to fetch is read from the router ":id" param + // so we first set it in our initial entries + initialEntries: [`/${AUTO_FOLLOW_PATTERN_NAME}`], + // and then we declarae the :id param on the component route path + componentRoutePath: '/:id' + } +}; + +describe('Edit Auto-follow pattern', () => { + let server; + let find; + let component; + let getUserActions; + let getFormErrorsMessages; + let clickSaveForm; + let setLoadRemoteClustersResponse; + let setGetAutoFollowPatternResponse; + + beforeEach(() => { + server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Register helpers to mock Http Requests + ({ + setLoadRemoteClustersResponse, + setGetAutoFollowPatternResponse + } = registerHttpRequestMockHelpers(server)); + + // Set "default" mock responses by not providing any arguments + setLoadRemoteClustersResponse(); + + // Mock all HTTP Requests that have not been handled previously + server.respondWith([200, {}, '']); + }); + + describe('on component mount', () => { + const remoteClusters = [ + { name: 'cluster-1', seeds: ['localhost:123'], isConnected: true }, + { name: 'cluster-2', seeds: ['localhost:123'], isConnected: true }, + ]; + + beforeEach(async () => { + setLoadRemoteClustersResponse(remoteClusters); + setGetAutoFollowPatternResponse(AUTO_FOLLOW_PATTERN); + ({ component, find } = initTestBed(AutoFollowPatternEdit, undefined, testBedOptions)); + + await nextTick(); + component.update(); + }); + + /** + * As the "edit" auto-follow pattern component uses the same form underneath that + * the "create" auto-follow pattern, we won't test it again but simply make sure that + * the form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" component', async () => { + const { component: addAutofollowPatternComponent } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions); + + await nextTick(); + addAutofollowPatternComponent.update(); + + const formEdit = component.find(AutoFollowPatternForm); + const formAdd = addAutofollowPatternComponent.find(AutoFollowPatternForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should populate the form fields with the values from the auto-follow pattern loaded', () => { + expect(find('ccrAutoFollowPatternFormNameInput').props().value).toBe(AUTO_FOLLOW_PATTERN.name); + expect(find('ccrRemoteClusterInput').props().value).toBe(AUTO_FOLLOW_PATTERN.remoteCluster); + expect(find('ccrAutoFollowPatternFormIndexPatternInput').text()).toBe(AUTO_FOLLOW_PATTERN.leaderIndexPatterns.join('')); + expect(find('ccrAutoFollowPatternFormPrefixInput').props().value).toBe('prefix_'); + expect(find('ccrAutoFollowPatternFormSuffixInput').props().value).toBe('_suffix'); + }); + }); + + describe('when the remote cluster is disconnected', () => { + beforeEach(async () => { + setLoadRemoteClustersResponse([{ name: 'cluster-2', seeds: ['localhost:123'], isConnected: false }]); + setGetAutoFollowPatternResponse(AUTO_FOLLOW_PATTERN); + ({ component, find, getUserActions, getFormErrorsMessages } = initTestBed(AutoFollowPatternEdit, undefined, testBedOptions)); + ({ clickSaveForm } = getUserActions('autoFollowPatternForm')); + + await nextTick(); + component.update(); + }); + + test('should display an error and have a button to edit the remote cluster', () => { + const error = find('remoteClusterFieldCallOutError'); + + expect(error.length).toBe(1); + expect(error.find('.euiCallOutHeader__title').text()) + .toBe(`Can't edit auto-follow pattern because remote cluster '${AUTO_FOLLOW_PATTERN.remoteCluster}' is not connected`); + expect(findTestSubject(error, 'ccrRemoteClusterEditButton').length).toBe(1); + }); + + test('should prevent saving the form and display an error message for the required remote cluster', () => { + clickSaveForm(); + + expect(getFormErrorsMessages()).toEqual(['A connected remote cluster is required.']); + expect(find('ccrAutoFollowPatternFormSubmitButton').props().disabled).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js index 365903729e81f..3e95e457ce5d4 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/auto_follow_pattern_list.test.js @@ -16,7 +16,8 @@ jest.mock('ui/chrome', () => ({ })); jest.mock('ui/index_patterns', () => { - const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = require.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); // eslint-disable-line max-len + const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = + require.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); return { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE }; }); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js new file mode 100644 index 0000000000000..f7617bb797382 --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_add.test.js @@ -0,0 +1,303 @@ +/* + * 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 sinon from 'sinon'; + +import { initTestBed, registerHttpRequestMockHelpers, nextTick } from './test_helpers'; +import { FollowerIndexAdd } from '../../public/app/sections/follower_index_add'; +import { AutoFollowPatternAdd } from '../../public/app/sections/auto_follow_pattern_add'; +import { RemoteClustersFormField } from '../../public/app/components'; + +import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from '../../../../../src/legacy/ui/public/index_patterns'; +import routing from '../../public/app/services/routing'; + +jest.mock('ui/chrome', () => ({ + addBasePath: (path) => path || 'api/cross_cluster_replication', + breadcrumbs: { set: () => {} }, +})); + +jest.mock('ui/index_patterns', () => { + const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); + const { validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/validate/validate_index_pattern'); + return { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE, validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES }; +}); + +const testBedOptions = { + memoryRouter: { + onRouter: (router) => routing.reactRouter = router + } +}; + +describe('Create Follower index', () => { + let server; + let find; + let exists; + let component; + let getUserActions; + let form; + let getFormErrorsMessages; + let clickSaveForm; + let toggleAdvancedSettings; + let setLoadRemoteClustersResponse; + + beforeEach(() => { + server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Register helpers to mock Http Requests + ({ + setLoadRemoteClustersResponse, + } = registerHttpRequestMockHelpers(server)); + + // Set "default" mock responses by not providing any arguments + setLoadRemoteClustersResponse(); + + // Mock all HTTP Requests that have not been handled previously + server.respondWith([200, {}, '']); + }); + + describe('on component mount', () => { + beforeEach(() => { + ({ find, exists } = initTestBed(FollowerIndexAdd, undefined, testBedOptions)); + }); + + test('should display a "loading remote clusters" indicator', () => { + expect(exists('remoteClustersLoading')).toBe(true); + expect(find('remoteClustersLoading').text()).toBe('Loading remote clusters…'); + }); + + test('should have a link to the documentation', () => { + expect(exists('followerIndexDocsButton')).toBe(true); + }); + }); + + describe('when remote clusters are loaded', () => { + beforeEach(async () => { + ({ find, exists, component, getUserActions, getFormErrorsMessages } = initTestBed(FollowerIndexAdd, undefined, testBedOptions)); + + ({ clickSaveForm } = getUserActions('followerIndexForm')); + + await nextTick(); // We need to wait next tick for the mock server response to comes in + component.update(); + }); + + test('should display the Follower index form', async () => { + expect(exists('ccrFollowerIndexForm')).toBe(true); + }); + + test('should display errors and disable the save button when clicking "save" without filling the form', () => { + expect(exists('followerIndexFormError')).toBe(false); + expect(find('ccrFollowerIndexFormSubmitButton').props().disabled).toBe(false); + + clickSaveForm(); + + expect(exists('followerIndexFormError')).toBe(true); + expect(getFormErrorsMessages()).toEqual([ + 'Leader index is required.', + 'Name is required.' + ]); + expect(find('ccrFollowerIndexFormSubmitButton').props().disabled).toBe(true); + }); + }); + + describe('form validation', () => { + beforeEach(async () => { + ({ component, form, getUserActions, getFormErrorsMessages, exists, find } = initTestBed(FollowerIndexAdd, undefined, testBedOptions)); + + ({ clickSaveForm, toggleAdvancedSettings } = getUserActions('followerIndexForm')); + + await nextTick(); // We need to wait next tick for the mock server response to comes in + component.update(); + }); + + describe('remote cluster', () => { + // The implementation of the remote cluster "Select" + validation is + // done inside the component. The same component that we use in the section. + // To avoid copy/pasting the same tests here, we simply make sure that both sections use the + test('should use the same component as the section', async () => { + const { component: autoFollowPatternAddComponent } = initTestBed(AutoFollowPatternAdd, undefined, testBedOptions); + await nextTick(); + autoFollowPatternAddComponent.update(); + + const remoteClusterFormFieldFollowerIndex = component.find(RemoteClustersFormField); + const remoteClusterFormFieldAutoFollowPattern = autoFollowPatternAddComponent.find(RemoteClustersFormField); + + expect(remoteClusterFormFieldFollowerIndex.length).toBe(1); + expect(remoteClusterFormFieldAutoFollowPattern.length).toBe(1); + }); + }); + + describe('leader index', () => { + test('should not allow spaces', () => { + form.setInputValue('ccrFollowerIndexFormLeaderIndexInput', 'with space'); + clickSaveForm(); + expect(getFormErrorsMessages()).toContain('Spaces are not allowed in the leader index.'); + }); + + test('should not allow invalid characters', () => { + clickSaveForm(); // Make all errors visible + + const expectInvalidChar = (char) => { + form.setInputValue('ccrFollowerIndexFormLeaderIndexInput', `with${char}`); + expect(getFormErrorsMessages()).toContain(`Remove the characters ${char} from your leader index.`); + }; + + return INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.reduce((promise, char) => { + return promise.then(() => expectInvalidChar(char)); + }, Promise.resolve()); + }); + }); + + describe('follower index', () => { + test('should not allow spaces', () => { + form.setInputValue('ccrFollowerIndexFormFollowerIndexInput', 'with space'); + clickSaveForm(); + expect(getFormErrorsMessages()).toContain('Spaces are not allowed in the name.'); + }); + + test('should not allow a "." (period) as first character', () => { + form.setInputValue('ccrFollowerIndexFormFollowerIndexInput', '.withDot'); + clickSaveForm(); + expect(getFormErrorsMessages()).toContain(`Name can't begin with a period.`); + }); + + test('should not allow invalid characters', () => { + clickSaveForm(); // Make all errors visible + + const expectInvalidChar = (char) => { + form.setInputValue('ccrFollowerIndexFormFollowerIndexInput', `with${char}`); + expect(getFormErrorsMessages()).toContain(`Remove the characters ${char} from your name.`); + }; + + return INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.reduce((promise, char) => { + return promise.then(() => expectInvalidChar(char)); + }, Promise.resolve()); + }); + + describe('ES index name validation', () => { + let setGetClusterIndicesResponse; + beforeEach(() => { + ({ setGetClusterIndicesResponse } = registerHttpRequestMockHelpers(server)); + }); + + test('should make a request to check if the index name is available in ES', async () => { + setGetClusterIndicesResponse([]); + + // Keep track of the request count made until this point + const totalRequests = server.requests.length; + + form.setInputValue('ccrFollowerIndexFormFollowerIndexInput', 'index-name'); + await nextTick(550); // we need to wait as there is a debounce of 500ms on the http validation + + expect(server.requests.length).toBe(totalRequests + 1); + expect(server.requests[server.requests.length - 1].url).toBe('/api/index_management/indices'); + }); + + test('should display an error if the index already exists', async () => { + const indexName = 'index-name'; + setGetClusterIndicesResponse([{ name: indexName }]); + + form.setInputValue('ccrFollowerIndexFormFollowerIndexInput', indexName); + await nextTick(550); + component.update(); + + expect(getFormErrorsMessages()).toContain('An index with the same name already exists.'); + }); + }); + }); + + describe('advanced settings', () => { + const advancedSettingsInputFields = { + ccrFollowerIndexFormMaxReadRequestOperationCountInput: { + default: 5120, + type: 'number', + }, + ccrFollowerIndexFormMaxOutstandingReadRequestsInput: { + default: 12, + type: 'number', + }, + ccrFollowerIndexFormMaxReadRequestSizeInput: { + default: '32mb', + type: 'string', + }, + ccrFollowerIndexFormMaxWriteRequestOperationCountInput: { + default: 5120, + type: 'number', + }, + ccrFollowerIndexFormMaxWriteRequestSizeInput: { + default: '9223372036854775807b', + type: 'string', + }, + ccrFollowerIndexFormMaxOutstandingWriteRequestsInput: { + default: 9, + type: 'number', + }, + ccrFollowerIndexFormMaxWriteBufferCountInput: { + default: 2147483647, + type: 'number', + }, + ccrFollowerIndexFormMaxWriteBufferSizeInput: { + default: '512mb', + type: 'string', + }, + ccrFollowerIndexFormMaxRetryDelayInput: { + default: '500ms', + type: 'string', + }, + ccrFollowerIndexFormReadPollTimeoutInput: { + default: '1m', + type: 'string', + }, + }; + + test('should have a toggle to activate advanced settings', () => { + const expectDoesNotExist = (testSubject) => { + try { + expect(exists(testSubject)).toBe(false); + } catch { + throw new Error(`The advanced field "${testSubject}" exists.`); + } + }; + + const expectDoesExist = (testSubject) => { + try { + expect(exists(testSubject)).toBe(true); + } catch { + throw new Error(`The advanced field "${testSubject}" does not exist.`); + } + }; + + // Make sure no advanced settings is visible + Object.keys(advancedSettingsInputFields).forEach(expectDoesNotExist); + + toggleAdvancedSettings(); + + // Make sure no advanced settings is visible + Object.keys(advancedSettingsInputFields).forEach(expectDoesExist); + }); + + test('should set the correct default value for each advanced setting', () => { + toggleAdvancedSettings(); + + Object.entries(advancedSettingsInputFields).forEach(([testSubject, data]) => { + expect(find(testSubject).props().value).toBe(data.default); + }); + }); + + test('should set number input field for numeric advanced settings', () => { + toggleAdvancedSettings(); + + Object.entries(advancedSettingsInputFields).forEach(([testSubject, data]) => { + if (data.type === 'number') { + expect(find(testSubject).props().type).toBe('number'); + } + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js new file mode 100644 index 0000000000000..fc8eabfea18ea --- /dev/null +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js @@ -0,0 +1,173 @@ +/* + * 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 sinon from 'sinon'; + +import { initTestBed, registerHttpRequestMockHelpers, nextTick, findTestSubject } from './test_helpers'; +import { FollowerIndexAdd } from '../../public/app/sections/follower_index_add'; +import { FollowerIndexEdit } from '../../public/app/sections/follower_index_edit'; +import { FollowerIndexForm } from '../../public/app/components/follower_index_form/follower_index_form'; +import routing from '../../public/app/services/routing'; + +jest.mock('ui/chrome', () => ({ + addBasePath: (path) => path || 'api/cross_cluster_replication', + breadcrumbs: { set: () => {} }, +})); + +jest.mock('ui/index_patterns', () => { + const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); + const { validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES } = + jest.requireActual('../../../../../src/legacy/ui/public/index_patterns/validate/validate_index_pattern'); + return { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE, validateIndexPattern, ILLEGAL_CHARACTERS, CONTAINS_SPACES }; +}); + +const FOLLOWER_INDEX_NAME = 'my-follower-index'; + +const FOLLOWER_INDEX = { + name: FOLLOWER_INDEX_NAME, + remoteCluster: 'new-york', + leaderIndex: 'some-leader-test', + status: 'active', + maxReadRequestOperationCount: 7845, + maxOutstandingReadRequests: 16, + maxReadRequestSize: '64mb', + maxWriteRequestOperationCount: 2456, + maxWriteRequestSize: '1048b', + maxOutstandingWriteRequests: 69, + maxWriteBufferCount: 123456, + maxWriteBufferSize: '256mb', + maxRetryDelay: '225ms', + readPollTimeout: '2m' +}; + +const testBedOptions = { + memoryRouter: { + onRouter: (router) => routing.reactRouter = router, + // The auto-follow pattern id to fetch is read from the router ":id" param + // so we first set it in our initial entries + initialEntries: [`/${FOLLOWER_INDEX_NAME}`], + // and then we declarae the :id param on the component route path + componentRoutePath: '/:id' + } +}; + +describe('Edit Auto-follow pattern', () => { + let server; + let find; + let component; + let getUserActions; + let getFormErrorsMessages; + let clickSaveForm; + let setLoadRemoteClustersResponse; + let setGetFollowerIndexResponse; + + beforeEach(() => { + server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Register helpers to mock Http Requests + ({ + setLoadRemoteClustersResponse, + setGetFollowerIndexResponse + } = registerHttpRequestMockHelpers(server)); + + // Set "default" mock responses by not providing any arguments + setLoadRemoteClustersResponse(); + + // Mock all HTTP Requests that have not been handled previously + server.respondWith([200, {}, '']); + }); + + describe('on component mount', () => { + const remoteClusters = [ + { name: 'new-york', seeds: ['localhost:123'], isConnected: true }, + ]; + + beforeEach(async () => { + setLoadRemoteClustersResponse(remoteClusters); + setGetFollowerIndexResponse(FOLLOWER_INDEX); + ({ component, find } = initTestBed(FollowerIndexEdit, undefined, testBedOptions)); + + await nextTick(); + component.update(); + }); + + /** + * As the "edit" follower index component uses the same form underneath that + * the "create" follower index, we won't test it again but simply make sure that + * the form component is indeed shared between the 2 app sections. + */ + test('should use the same Form component as the "" component', async () => { + const { component: addFollowerIndexComponent } = initTestBed(FollowerIndexAdd, undefined, testBedOptions); + + await nextTick(); + addFollowerIndexComponent.update(); + + const formEdit = component.find(FollowerIndexForm); + const formAdd = addFollowerIndexComponent.find(FollowerIndexForm); + + expect(formEdit.length).toBe(1); + expect(formAdd.length).toBe(1); + }); + + test('should populate the form fields with the values from the follower index loaded', () => { + const inputToPropMap = { + ccrRemoteClusterInput: 'remoteCluster', + ccrFollowerIndexFormLeaderIndexInput: 'leaderIndex', + ccrFollowerIndexFormFollowerIndexInput: 'name', + ccrFollowerIndexFormMaxReadRequestOperationCountInput: 'maxReadRequestOperationCount', + ccrFollowerIndexFormMaxOutstandingReadRequestsInput: 'maxOutstandingReadRequests', + ccrFollowerIndexFormMaxReadRequestSizeInput: 'maxReadRequestSize', + ccrFollowerIndexFormMaxWriteRequestOperationCountInput: 'maxWriteRequestOperationCount', + ccrFollowerIndexFormMaxWriteRequestSizeInput: 'maxWriteRequestSize', + ccrFollowerIndexFormMaxOutstandingWriteRequestsInput: 'maxOutstandingWriteRequests', + ccrFollowerIndexFormMaxWriteBufferCountInput: 'maxWriteBufferCount', + ccrFollowerIndexFormMaxWriteBufferSizeInput: 'maxWriteBufferSize', + ccrFollowerIndexFormMaxRetryDelayInput: 'maxRetryDelay', + ccrFollowerIndexFormReadPollTimeoutInput: 'readPollTimeout', + }; + + Object.entries(inputToPropMap).forEach(([input, prop]) => { + const expected = FOLLOWER_INDEX[prop]; + const { value } = find(input).props(); + try { + expect(value).toBe(expected); + } catch { + throw new Error(`Input "${input}" does not equal "${expected}". (Value received: "${value}")`); + } + }); + }); + }); + + describe('when the remote cluster is disconnected', () => { + beforeEach(async () => { + setLoadRemoteClustersResponse([{ name: 'new-york', seeds: ['localhost:123'], isConnected: false }]); + setGetFollowerIndexResponse(FOLLOWER_INDEX); + ({ component, find, getUserActions, getFormErrorsMessages } = initTestBed(FollowerIndexEdit, undefined, testBedOptions)); + ({ clickSaveForm } = getUserActions('followerIndexForm')); + + await nextTick(); + component.update(); + }); + + test('should display an error and have a button to edit the remote cluster', () => { + const error = find('remoteClusterFieldCallOutError'); + + expect(error.length).toBe(1); + expect(error.find('.euiCallOutHeader__title').text()) + .toBe(`Can't edit follower index because remote cluster '${FOLLOWER_INDEX.remoteCluster}' is not connected`); + expect(findTestSubject(error, 'ccrRemoteClusterEditButton').length).toBe(1); + }); + + test('should prevent saving the form and display an error message for the required remote cluster', () => { + clickSaveForm(); + + expect(getFormErrorsMessages()).toEqual(['A connected remote cluster is required.']); + expect(find('ccrFollowerIndexFormSubmitButton').props().disabled).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js index 4b48b37a0c193..645791da10dba 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/follower_indices_list.test.js @@ -16,7 +16,8 @@ jest.mock('ui/chrome', () => ({ })); jest.mock('ui/index_patterns', () => { - const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = require.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); // eslint-disable-line max-len + const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = + require.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); return { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE }; }); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js index b8056e2625342..f99c138b4ea9b 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/home.test.js @@ -17,7 +17,8 @@ jest.mock('ui/chrome', () => ({ })); jest.mock('ui/index_patterns', () => { - const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = require.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); // eslint-disable-line max-len + const { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } = + require.requireActual('../../../../../src/legacy/ui/public/index_patterns/constants'); return { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE }; }); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/test_helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/test_helpers.js index ba0cdb0a67169..19820811874e0 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/test_helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/test_helpers.js @@ -26,9 +26,9 @@ const $q = { defer: () => ({ resolve() {} }) }; // axios has a $http like interface so using it to simulate $http setHttpClient(axios.create(), $q); -const initUserActions = ({ getMetadataFromEuiTable, find }) => (section) => { +const initUserActions = ({ getMetadataFromEuiTable, find, form }) => (section) => { const userActions = { - // Follower indices user actions + // Follower indices LIST followerIndicesList() { const { rows } = getMetadataFromEuiTable('ccrFollowerIndexListTable'); @@ -70,7 +70,7 @@ const initUserActions = ({ getMetadataFromEuiTable, find }) => (section) => { clickFollowerIndexAt, }; }, - // Auto-follow patterns user actions + // Auto-follow patterns LIST autoFollowPatternList() { const { rows } = getMetadataFromEuiTable('ccrAutoFollowPatternListTable'); @@ -119,6 +119,31 @@ const initUserActions = ({ getMetadataFromEuiTable, find }) => (section) => { clickRowActionButtonAt, clickAutoFollowPatternAt }; + }, + // Auto-follow pattern FORM + autoFollowPatternForm() { + const clickSaveForm = () => { + find('ccrAutoFollowPatternFormSubmitButton').simulate('click'); + }; + + return { + clickSaveForm, + }; + }, + // Follower index FORM + followerIndexForm() { + const clickSaveForm = () => { + find('ccrFollowerIndexFormSubmitButton').simulate('click'); + }; + + const toggleAdvancedSettings = () => { + form.selectCheckBox('ccrFollowerIndexFormCustomAdvancedSettingsToggle'); + }; + + return { + clickSaveForm, + toggleAdvancedSettings, + }; } }; @@ -131,9 +156,25 @@ export const initTestBed = (component, props = {}, options) => { const testBed = registerTestBed(component, {}, ccrStore)(props, options); const getUserActions = initUserActions(testBed); + // Cutsom Form helpers + const setIndexPatternValue = (value) => { + const comboBox = testBed.find('ccrAutoFollowPatternFormIndexPatternInput'); + const indexPatternsInput = findTestSubject(comboBox, 'comboBoxSearchInput'); + testBed.form.setInputValue(indexPatternsInput, value); + + // We need to press the ENTER key in order for the EuiComboBox to register + // the value. (keyCode 13 === ENTER) + comboBox.simulate('keydown', { keyCode: 13 }); + testBed.component.update(); + }; + return { ...testBed, getUserActions, + form: { + ...testBed.form, + setIndexPatternValue, + } }; }; @@ -185,10 +226,47 @@ export const registerHttpRequestMockHelpers = server => { ); }; + const setLoadRemoteClustersResponse = (response = [], error) => { + if (error) { + server.respondWith('GET', '/api/remote_clusters', + [error.status || 400, { 'Content-Type': 'application/json' }, JSON.stringify(error.body)] + ); + } else { + server.respondWith('GET', '/api/remote_clusters', + [200, { 'Content-Type': 'application/json' }, JSON.stringify(response)] + ); + } + }; + + const setGetAutoFollowPatternResponse = (response) => { + const defaultResponse = {}; + + server.respondWith('GET', /api\/cross_cluster_replication\/auto_follow_patterns\/.+/, + mockResponse(defaultResponse, response) + ); + }; + + const setGetClusterIndicesResponse = (response = []) => { + server.respondWith('GET', '/api/index_management/indices', + [200, { 'Content-Type': 'application/json' }, JSON.stringify(response)]); + }; + + const setGetFollowerIndexResponse = (response) => { + const defaultResponse = {}; + + server.respondWith('GET', /api\/cross_cluster_replication\/follower_indices\/.+/, + mockResponse(defaultResponse, response) + ); + }; + return { setLoadFollowerIndicesResponse, setLoadAutoFollowPatternsResponse, setDeleteAutoFollowPatternResponse, setAutoFollowStatsResponse, + setLoadRemoteClustersResponse, + setGetAutoFollowPatternResponse, + setGetClusterIndicesResponse, + setGetFollowerIndexResponse, }; }; 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 7068bf33d3318..a69527cc82ee5 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 @@ -148,13 +148,10 @@ export class AutoFollowPatternForm extends PureComponent { }; onLeaderIndexPatternInputChange = (leaderIndexPattern) => { - if (!leaderIndexPattern || !leaderIndexPattern.trim()) { - return; - } - + const isEmpty = !leaderIndexPattern || !leaderIndexPattern.trim(); const { autoFollowPattern: { leaderIndexPatterns } } = this.state; - if (leaderIndexPatterns.includes(leaderIndexPattern)) { + if (!isEmpty && leaderIndexPatterns.includes(leaderIndexPattern)) { const errorMsg = i18n.translate( 'xpack.crossClusterReplication.autoFollowPatternForm.leaderIndexPatternError.duplicateMessage', { @@ -172,7 +169,12 @@ export class AutoFollowPatternForm extends PureComponent { this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); } else { this.setState(({ fieldsErrors, autoFollowPattern: { leaderIndexPatterns } }) => { - const errors = validateAutoFollowPattern({ leaderIndexPatterns }); + const errors = Boolean(leaderIndexPatterns.length) + // Validate existing patterns, so we can surface an error if this required input is missing. + ? validateAutoFollowPattern({ leaderIndexPatterns }) + // Validate the input as the user types so they have immediate feedback about errors. + : validateAutoFollowPattern({ leaderIndexPatterns: [leaderIndexPattern] }); + return updateFormErrors(errors, fieldsErrors); }); } @@ -223,7 +225,7 @@ export class AutoFollowPatternForm extends PureComponent { return ( - + ); @@ -372,7 +374,7 @@ export class AutoFollowPatternForm extends PureComponent { * Leader index pattern(s) */ const renderLeaderIndexPatterns = () => { - const hasError = !!fieldsErrors.leaderIndexPatterns; + const hasError = !!(fieldsErrors.leaderIndexPatterns && fieldsErrors.leaderIndexPatterns.message); const isInvalid = hasError && (fieldsErrors.leaderIndexPatterns.alwaysVisible || areErrorsVisible); const formattedLeaderIndexPatterns = leaderIndexPatterns.map(pattern => ({ label: pattern })); @@ -576,6 +578,7 @@ export class AutoFollowPatternForm extends PureComponent { )} color="danger" iconType="cross" + data-test-subj="autoFollowPatternFormError" /> @@ -643,7 +646,7 @@ export class AutoFollowPatternForm extends PureComponent { return ( - + {renderAutoFollowPatternName()} {renderRemoteClusterField()} {renderLeaderIndexPatterns()} diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js index c76c13c44d51f..2c632462419df 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js @@ -29,6 +29,7 @@ export const AutoFollowPatternIndicesPreview = ({ prefix, suffix, leaderIndexPat ( href={autoFollowPatternUrl} target="_blank" iconType="help" + data-test-subj="autoFollowPatternDocsButton" > { this.onFieldsChange({ name }); + const error = indexNameValidator(name); + if (error) { + // If there is a client side error + // there is no need to validate the name + return; + } + if (!name || !name.trim()) { this.setState({ isValidatingIndexName: false, @@ -613,6 +620,7 @@ export class FollowerIndexForm extends PureComponent { )} color="danger" iconType="cross" + data-test-subj="followerIndexFormError" /> @@ -681,7 +689,7 @@ export class FollowerIndexForm extends PureComponent { return ( - + {renderRemoteClusterField()} {renderLeaderIndex()} {renderFollowerIndexName()} 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 index a1ec4a44e218a..c04081a88a5e4 100644 --- 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 @@ -38,6 +38,7 @@ export const FollowerIndexPageTitle = ({ title }) => ( href={followerIndexUrl} target="_blank" iconType="help" + data-test-subj="followerIndexDocsButton" > { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } { errorMessage } @@ -137,23 +138,21 @@ export class RemoteClustersFormField extends PureComponent { { areErrorsVisible && Boolean(errorMessage) ? this.renderValidRemoteClusterRequired() : null } { errorMessage } - - -
{/* Break out of EuiFormRow's flexbox layout */} - - - -
-
+ +
{/* Break out of EuiFormRow's flexbox layout */} + + + +
); }; @@ -170,6 +169,7 @@ export class RemoteClustersFormField extends PureComponent { title={title} color="danger" iconType="cross" + data-test-subj="remoteClusterFieldNoClusterFoundError" >

{ this.errorMessages.noClusterFound() } @@ -207,6 +207,7 @@ export class RemoteClustersFormField extends PureComponent { title={title} color={fatal ? 'danger' : 'warning'} iconType="cross" + data-test-subj="remoteClusterFieldCallOutError" >

{ description } @@ -318,9 +319,7 @@ export class RemoteClustersFormField extends PureComponent { isInvalid={isInvalid} fullWidth > - - {field} - + {field} ); } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js index 01d4bf11e034c..f7b3eb149c825 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js @@ -27,10 +27,11 @@ export class RemoteClustersProvider extends PureComponent { }) ); loadRemoteClusters() + .then(sortClusterByName) .then((remoteClusters) => { this.setState({ isLoading: false, - remoteClusters: sortClusterByName(remoteClusters) + remoteClusters }); }) .catch((error) => { diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js b/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js index 4988449802b53..b6c2e0544cd94 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/section_error.js @@ -10,7 +10,8 @@ import { EuiSpacer, } from '@elastic/eui'; -export function SectionError({ title, error }) { +export function SectionError(props) { + const { title, error, ...rest } = props; const data = error.data ? error.data : error; const { error: errorString, @@ -23,6 +24,7 @@ export function SectionError({ title, error }) { title={title} color="danger" iconType="alert" + {...rest} >

{message || errorString}
{ cause && ( 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 4fa0afd2fe579..65c23b27f6cb2 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 @@ -56,7 +56,7 @@ export class AutoFollowPatternAdd extends PureComponent { {({ isLoading, error, remoteClusters }) => { if (isLoading) { return ( - + { if (isLoading) { return ( - + ( ); export const loadRemoteClusters = () => ( - httpClient.get(`${apiPrefixRemoteClusters}`).then(extractData) + httpClient.get(apiPrefixRemoteClusters).then(extractData) ); export const createAutoFollowPattern = (autoFollowPattern) => ( diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js b/x-pack/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js index 94a0d34fd4e4b..87bbceedb201b 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js @@ -53,29 +53,53 @@ export const validateName = (name = '') => { }; export const validateLeaderIndexPattern = (indexPattern) => { - const errors = getIndexPatternErrors(indexPattern); - - if (errors[ILLEGAL_CHARACTERS]) { - return ( - {errors[ILLEGAL_CHARACTERS].join(' ')}, - characterListLength: errors[ILLEGAL_CHARACTERS].length, - }} - /> - ); + values={{ + characterList: {errors[ILLEGAL_CHARACTERS].join(' ')}, + characterListLength: errors[ILLEGAL_CHARACTERS].length, + }} + /> + }); + } + + if (errors[CONTAINS_SPACES]) { + return ({ + message: + }); + } } - if (errors[CONTAINS_SPACES]) { - return ( - - ); + if (!indexPattern || !indexPattern.trim()) { + return { + message: i18n.translate('xpack.crossClusterReplication.autoFollowPattern.leaderIndexPatternValidation.isEmpty', { + defaultMessage: 'At least one leader index pattern is required.', + }) + }; + } + + return null; +}; + +export const validateLeaderIndexPatterns = (indexPatterns) => { + // We only need to check if a value has been provided, because validation for this field + // has already been executed as the user has entered input into it. + if (!indexPatterns.length) { + return { + message: i18n.translate('xpack.crossClusterReplication.autoFollowPattern.leaderIndexPatternValidation.isEmpty', { + defaultMessage: 'At least one leader index pattern is required.', + }) + }; } return null; @@ -174,13 +198,7 @@ export const validateAutoFollowPattern = (autoFollowPattern = {}) => { break; case 'leaderIndexPatterns': - if (!fieldValue.length) { - error = { - message: i18n.translate('xpack.crossClusterReplication.autoFollowPattern.leaderIndexPatternValidation.isEmpty', { - defaultMessage: 'At least one leader index pattern is required.', - }) - }; - } + error = validateLeaderIndexPatterns(fieldValue); break; case 'followIndexPatternPrefix': 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 31e55bdd8a591..d859fe0191303 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 @@ -12,6 +12,12 @@ const isEmpty = value => { return !value || !value.trim().length; }; +const hasSpaces = (value) => ( + typeof value === 'string' + ? value.includes(' ') + : false +); + const beginsWithPeriod = value => { return value[0] === '.'; }; @@ -57,6 +63,15 @@ export const indexNameValidator = (value) => { )]; } + if(hasSpaces(value)) { + return [( + + )]; + } + return undefined; }; @@ -82,5 +97,14 @@ export const leaderIndexValidator = (value) => { )]; } + if(hasSpaces(value)) { + return [( + + )]; + } + return undefined; }; diff --git a/x-pack/test_utils/testbed/testbed.js b/x-pack/test_utils/testbed/testbed.js index 30bd82c710d87..6d64ad35a89d7 100644 --- a/x-pack/test_utils/testbed/testbed.js +++ b/x-pack/test_utils/testbed/testbed.js @@ -94,7 +94,11 @@ export const registerTestBed = (Component, defaultProps, store = {}) => (props, }; const setInputValue = (inputTestSubject, value, isAsync = false) => { - const formInput = find(inputTestSubject); + const formInput = typeof inputTestSubject === 'string' + ? find(inputTestSubject) + : inputTestSubject; + + formInput.simulate('change', { target: { value } }); component.update();