diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/common/services/auto_follow_pattern_serialization.ts b/x-pack/platform/plugins/private/cross_cluster_replication/common/services/auto_follow_pattern_serialization.ts index 67d365d2f68f8..7450ee3b39b9e 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/common/services/auto_follow_pattern_serialization.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/common/services/auto_follow_pattern_serialization.ts @@ -37,7 +37,10 @@ export const serializeAutoFollowPattern = ({ remoteCluster, leaderIndexPatterns, followIndexPattern, -}: AutoFollowPattern): AutoFollowPatternToEs => ({ +}: Pick< + AutoFollowPattern, + 'remoteCluster' | 'leaderIndexPatterns' | 'followIndexPattern' +>): AutoFollowPatternToEs => ({ remote_cluster: remoteCluster, leader_index_patterns: leaderIndexPatterns, follow_index_pattern: followIndexPattern, diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/common/services/follower_index_serialization.ts b/x-pack/platform/plugins/private/cross_cluster_replication/common/services/follower_index_serialization.ts index afe46a4183bf2..9f4deabf68d99 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/common/services/follower_index_serialization.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/common/services/follower_index_serialization.ts @@ -136,7 +136,10 @@ export const serializeAdvancedSettings = ({ read_poll_timeout: readPollTimeout, }); -export const serializeFollowerIndex = (followerIndex: FollowerIndex): FollowerIndexToEs => ({ +export const serializeFollowerIndex = ( + followerIndex: Pick & + FollowerIndexAdvancedSettings +): FollowerIndexToEs => ({ remote_cluster: followerIndex.remoteCluster, leader_index: followerIndex.leaderIndex, ...serializeAdvancedSettings(followerIndex), diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/common/services/utils.ts b/x-pack/platform/plugins/private/cross_cluster_replication/common/services/utils.ts index 900d8c9046efd..88ecead6b2514 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/common/services/utils.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/common/services/utils.ts @@ -5,31 +5,15 @@ * 2.0. */ -export const arrify = (val: any): any[] => (Array.isArray(val) ? val : [val]); +export const arrify = (val: T | T[]): T[] => (Array.isArray(val) ? val : [val]); /** - * Utilty to add some latency in a Promise chain - * - * @param {number} time Time in millisecond to wait + * Utility to remove empty fields ("") from a request body. */ -export const wait = - (time = 1000) => - (data: any): Promise => { - return new Promise((resolve) => { - setTimeout(() => resolve(data), time); - }); - }; - -/** - * Utility to remove empty fields ("") from a request body - */ -export const removeEmptyFields = (body: Record): Record => - Object.entries(body).reduce( - (acc: Record, [key, value]: [string, any]): Record => { - if (value !== '') { - acc[key] = value; - } - return acc; - }, - {} - ); +export const removeEmptyFields = (body: T): Partial => + Object.entries(body).reduce((acc: Partial, [key, value]): Partial => { + if (value !== '') { + acc[key as keyof T] = value; + } + return acc; + }, {}); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/moon.yml b/x-pack/platform/plugins/private/cross_cluster_replication/moon.yml index 2a8cc33d7ff07..d277b103369f2 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/moon.yml +++ b/x-pack/platform/plugins/private/cross_cluster_replication/moon.yml @@ -38,6 +38,7 @@ dependsOn: - '@kbn/licensing-types' - '@kbn/data-views-plugin' - '@kbn/index-management-shared-types' + - '@kbn/core-http-browser' tags: - plugin - prod diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.ts similarity index 66% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.ts index 8e8e4967c8343..654434d466854 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_add.test.ts @@ -7,15 +7,18 @@ import { ILLEGAL_CHARACTERS_VISIBLE } from '@kbn/data-views-plugin/public'; import { fireEvent, screen, within, act } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import './mocks'; import { setupEnvironment, pageHelpers, getRandomString } from './helpers'; const { setup } = pageHelpers.autoFollowPatternAdd; +type SetupEnvironmentReturn = ReturnType; + describe('Create Auto-follow pattern', () => { - let httpRequestsMockHelpers; - let httpSetup; - let user; + let httpRequestsMockHelpers: SetupEnvironmentReturn['httpRequestsMockHelpers']; + let httpSetup: SetupEnvironmentReturn['httpSetup']; + let user: UserEvent; beforeAll(() => { jest.useFakeTimers(); @@ -80,6 +83,96 @@ describe('Create Auto-follow pattern', () => { }); }); + describe('when the form is filled', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadRemoteClustersResponse([ + { name: 'my_remote', isConnected: true }, + ]); + ({ user } = setup()); + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + }); + + test('submitting a filled form should not surface required errors for filled fields', async () => { + // `AutoFollowPatternForm.onFieldsChange` calls `validateAutoFollowPattern` + // with a fixed object literal where untouched fields are present with + // value `undefined`. The validator must only evaluate fields whose values + // are explicitly provided; skipping `undefined` fields is what lets users + // fill the form incrementally without every still-empty field clobbering + // the valid state of fields they have already filled. Clicking Create + // flips `areErrorsVisible: true`, which is the only way a stomped error + // would become renderable, so the click is required to make the + // invariant observable. + const nameInput = screen.getByTestId('nameInput'); + await user.type(nameInput, 'test1'); + + const comboboxWrapper = screen.getByTestId('indexPatternInput'); + const input = + comboboxWrapper.querySelector('[role="combobox"]') ?? + comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } + await user.type(input, 'my*{enter}'); + + // Keep the save HTTP request pending so the submit path does not unmount + // the form before we can assert on the visible error state. + httpSetup.post.mockImplementation(() => new Promise(() => {})); + + const saveButton = screen.getByTestId('submitButton'); + await user.click(saveButton); + + expect(screen.queryByTestId('formError')).not.toBeInTheDocument(); + expect(screen.queryByText('Name is required.')).not.toBeInTheDocument(); + expect( + screen.queryByText('At least one leader index pattern is required.') + ).not.toBeInTheDocument(); + }); + + test('mutating one filled field after a failed submit should not stomp validation state of other filled fields', async () => { + // Once `areErrorsVisible: true` is latched (see the preceding test), any + // edit to a single field drives `onFieldsChange` with an object literal + // in which the *other* fields are present with value `undefined`. The + // validator must treat that as "not touched, do not re-validate" rather + // than "empty, report as required"; otherwise an edit to Name can stomp + // the valid state of Leader index patterns and render a phantom + // "required" error under an already-filled combobox. + const nameInput = screen.getByTestId('nameInput'); + await user.type(nameInput, 'test1'); + + const comboboxWrapper = screen.getByTestId('indexPatternInput'); + const input = + comboboxWrapper.querySelector('[role="combobox"]') ?? + comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } + await user.type(input, 'my*{enter}'); + + // Keep the save HTTP request pending so the happy-path submit does not + // unmount the form before we mutate Name again. Without this, the + // not-in-document assertion below could pass trivially because the form + // navigated away. + httpSetup.post.mockImplementation(() => new Promise(() => {})); + + const saveButton = screen.getByTestId('submitButton'); + await user.click(saveButton); + + // Mutate only the Name field. `fireEvent.change` writes the new value + // directly so we don't have to model cursor positioning inside an + // EuiFieldText. + fireEvent.change(nameInput, { target: { value: 'test' } }); + + // The leader index patterns combobox was not touched and still renders + // its ["my*"] tag; its error state must be left alone. + expect(screen.getByTestId('indexPatternInput').textContent).toContain('my*'); + expect( + screen.queryByText('At least one leader index pattern is required.') + ).not.toBeInTheDocument(); + }); + }); + describe('form validation', () => { describe('auto-follow pattern name', () => { beforeEach(async () => { @@ -178,7 +271,7 @@ describe('Create Auto-follow pattern', () => { const errorCallOut = screen.getByTestId('notConnectedError'); const title = errorCallOut.querySelector('.euiCallOutHeader__title'); - expect(title.textContent).toBe(`Remote cluster '${clusterName}' is not connected`); + expect(title?.textContent).toBe(`Remote cluster '${clusterName}' is not connected`); const editButton = within(errorCallOut).getByTestId('editButton'); expect(editButton).toBeInTheDocument(); @@ -194,7 +287,7 @@ describe('Create Auto-follow pattern', () => { test('should indicate in the select option that the cluster is not connected', () => { const select = screen.getByTestId('remoteClusterSelect'); const option = select.querySelector('option'); - expect(option.textContent).toBe(`${clusterName} (not connected)`); + expect(option?.textContent).toBe(`${clusterName} (not connected)`); }); }); }); @@ -214,8 +307,11 @@ describe('Create Auto-follow pattern', () => { test('should not allow spaces', () => { const comboboxWrapper = screen.getByTestId('indexPatternInput'); const input = - comboboxWrapper.querySelector('[role="combobox"]') || + comboboxWrapper.querySelector('[role="combobox"]') ?? comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } fireEvent.change(input, { target: { value: 'with space' } }); fireEvent.blur(input); @@ -226,11 +322,14 @@ describe('Create Auto-follow pattern', () => { test.each(ILLEGAL_CHARACTERS_VISIBLE)( 'should not allow invalid character %s', - (illegalChar) => { + (illegalChar: string) => { const comboboxWrapper = screen.getByTestId('indexPatternInput'); const input = - comboboxWrapper.querySelector('[role="combobox"]') || + comboboxWrapper.querySelector('[role="combobox"]') ?? comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } fireEvent.change(input, { target: { value: `legalchar` } }); fireEvent.blur(input); @@ -261,8 +360,11 @@ describe('Create Auto-follow pattern', () => { // The combobox should already be rendered after beforeEach timer advancement const comboboxWrapper = screen.getByTestId('indexPatternInput'); const input = - comboboxWrapper.querySelector('[role="combobox"]') || + comboboxWrapper.querySelector('[role="combobox"]') ?? comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } fireEvent.change(input, { target: { value: 'kibana' } }); fireEvent.blur(input); @@ -272,8 +374,11 @@ describe('Create Auto-follow pattern', () => { test('should display 3 indices example when providing a wildcard(*)', () => { const comboboxWrapper = screen.getByTestId('indexPatternInput'); const input = - comboboxWrapper.querySelector('[role="combobox"]') || + comboboxWrapper.querySelector('[role="combobox"]') ?? comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } fireEvent.change(input, { target: { value: 'kibana-*' } }); fireEvent.blur(input); @@ -286,8 +391,11 @@ describe('Create Auto-follow pattern', () => { test('should only display 1 index example when *not* providing a wildcard', () => { const comboboxWrapper = screen.getByTestId('indexPatternInput'); const input = - comboboxWrapper.querySelector('[role="combobox"]') || + comboboxWrapper.querySelector('[role="combobox"]') ?? comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } fireEvent.change(input, { target: { value: 'kibana' } }); fireEvent.blur(input); @@ -303,8 +411,11 @@ describe('Create Auto-follow pattern', () => { const comboboxWrapper = screen.getByTestId('indexPatternInput'); const input = - comboboxWrapper.querySelector('[role="combobox"]') || + comboboxWrapper.querySelector('[role="combobox"]') ?? comboboxWrapper.querySelector('input'); + if (!input) { + throw new Error('expected index pattern input'); + } fireEvent.change(input, { target: { value: 'kibana' } }); fireEvent.blur(input); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.tsx similarity index 62% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.tsx index b5d3536a1665c..94ed4ac56c304 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_edit.test.tsx @@ -6,15 +6,20 @@ */ import { screen, within, act } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import './mocks'; import { setupEnvironment, pageHelpers } from './helpers'; import { AUTO_FOLLOW_PATTERN_EDIT, AUTO_FOLLOW_PATTERN_EDIT_NAME } from './helpers/constants'; +import { API_BASE_PATH } from '../../../common/constants'; const { setup } = pageHelpers.autoFollowPatternEdit; +type SetupEnvironmentReturn = ReturnType; + describe('Edit Auto-follow pattern', () => { - let httpRequestsMockHelpers; - let user; + let httpRequestsMockHelpers: SetupEnvironmentReturn['httpRequestsMockHelpers']; + let httpSetup: SetupEnvironmentReturn['httpSetup']; + let user: UserEvent; beforeAll(() => { jest.useFakeTimers(); @@ -26,7 +31,7 @@ describe('Edit Auto-follow pattern', () => { beforeEach(() => { jest.clearAllMocks(); - ({ httpRequestsMockHelpers } = setupEnvironment()); + ({ httpRequestsMockHelpers, httpSetup } = setupEnvironment()); httpRequestsMockHelpers.setGetAutoFollowPatternResponse( AUTO_FOLLOW_PATTERN_EDIT_NAME, AUTO_FOLLOW_PATTERN_EDIT @@ -74,6 +79,50 @@ describe('Edit Auto-follow pattern', () => { expect(screen.getByTestId('prefixInput')).toHaveValue('prefix_'); expect(screen.getByTestId('suffixInput')).toHaveValue('_suffix'); }); + + test('should PUT only the fields accepted by the update route body schema', async () => { + // The update route's bodySchema forbids unknown properties, so leaking + // extra fields (e.g. `name`, `errors`) from the form state into the + // dispatched payload would produce a 400 at the server boundary. The + // container's mapDispatchToProps whitelists the allowed fields before + // dispatching the save thunk; this test pins that contract. + const putEndpoint = `${API_BASE_PATH}/auto_follow_patterns/${AUTO_FOLLOW_PATTERN_EDIT_NAME}`; + // Make sure the PUT resolves so the save flow can complete without noise. + httpSetup.put.mockImplementation((path) => { + const resolved = typeof path === 'string' ? path : path.path; + if (resolved === putEndpoint) { + return Promise.resolve({}); + } + return Promise.resolve({}); + }); + + const submitButton = screen.getByTestId('submitButton'); + await user.click(submitButton); + + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + type PutCall = [string | { path: string }, { body?: string } | undefined]; + const putCalls = httpSetup.put.mock.calls as unknown as PutCall[]; + const putCall = putCalls.find(([path]) => { + const resolved = typeof path === 'string' ? path : path.path; + return resolved === putEndpoint; + }); + + expect(putCall).toBeDefined(); + const [, options] = putCall!; + const rawBody = options?.body; + expect(typeof rawBody).toBe('string'); + const body = JSON.parse(rawBody as string); + + expect(body).toEqual({ + active: AUTO_FOLLOW_PATTERN_EDIT.active, + remoteCluster: AUTO_FOLLOW_PATTERN_EDIT.remoteCluster, + leaderIndexPatterns: AUTO_FOLLOW_PATTERN_EDIT.leaderIndexPatterns, + followIndexPattern: AUTO_FOLLOW_PATTERN_EDIT.followIndexPattern, + }); + }); }); describe('when the remote cluster is disconnected', () => { @@ -93,7 +142,7 @@ describe('Edit Auto-follow pattern', () => { expect(error).toBeInTheDocument(); const title = error.querySelector('.euiCallOutHeader__title'); - expect(title.textContent).toBe( + expect(title?.textContent).toBe( `Can't edit auto-follow pattern because remote cluster '${AUTO_FOLLOW_PATTERN_EDIT.remoteCluster}' is not connected` ); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.tsx similarity index 93% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.tsx index 72d4ae4dce3fe..085c3959bc461 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.tsx @@ -6,24 +6,32 @@ */ import { screen, within, act } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import { EuiTableTestHarness } from '@kbn/test-eui-helpers'; import './mocks'; import { getAutoFollowPatternMock } from './fixtures/auto_follow_pattern'; import { setupEnvironment, pageHelpers, getRandomString } from './helpers'; +import type { AutoFollowPatternListSetupResult } from './helpers/auto_follow_pattern_list.helpers'; const { setup } = pageHelpers.autoFollowPatternList; -const getActionsCell = (testId, rowIndex = 0) => { +type SetupEnvironmentReturn = ReturnType; + +const getActionsCell = (testId: string, rowIndex = 0): HTMLElement => { const table = new EuiTableTestHarness(testId); const tableRows = table.getRows(); - return within(tableRows[rowIndex]).getAllByRole('cell').pop(); + const cell = within(tableRows[rowIndex]).getAllByRole('cell').pop(); + if (!cell) { + throw new Error(`expected actions cell for ${testId} row ${rowIndex}`); + } + return cell; }; describe('', () => { - let httpRequestsMockHelpers; - let httpSetup; - let user; + let httpRequestsMockHelpers: SetupEnvironmentReturn['httpRequestsMockHelpers']; + let httpSetup: SetupEnvironmentReturn['httpSetup']; + let user: UserEvent; beforeAll(() => { jest.useFakeTimers(); @@ -75,16 +83,16 @@ describe('', () => { }); describe('when there are multiple pages of auto-follow patterns', () => { - let actions; + let actions: AutoFollowPatternListSetupResult['actions']; beforeEach(async () => { const autoFollowPatterns = [ - getAutoFollowPatternMock({ name: 'unique', followPattern: '{{leader_index}}' }), + getAutoFollowPatternMock({ name: 'unique', followIndexPattern: '{{leader_index}}' }), ]; for (let i = 0; i < 29; i++) { autoFollowPatterns.push( - getAutoFollowPatternMock({ name: `${i}`, followPattern: '{{leader_index}}' }) + getAutoFollowPatternMock({ name: `${i}`, followIndexPattern: '{{leader_index}}' }) ); } @@ -114,7 +122,7 @@ describe('', () => { }); describe('when there are auto-follow patterns', () => { - let actions; + let actions: AutoFollowPatternListSetupResult['actions']; // For deterministic tests, we need to make sure that autoFollowPattern1 comes before autoFollowPattern2 // in the table list that is rendered. As the table orders alphabetically by index name @@ -206,6 +214,7 @@ describe('', () => { // We will delete the *first* auto-follow pattern in the table httpRequestsMockHelpers.setDeleteAutoFollowPatternResponse(autoFollowPattern1.name, { itemsDeleted: [autoFollowPattern1.name], + errors: [], }); // After delete, the list loader will fetch again; return remaining item httpRequestsMockHelpers.setLoadAutoFollowPatternsResponse({ @@ -385,7 +394,7 @@ describe('', () => { expect(within(detailPanel2).getByTestId('titleErrors')).toBeInTheDocument(); const errors = within(detailPanel2).queryAllByTestId('recentError'); - expect(errors.map((error) => error.textContent)).toEqual([ + expect(errors.map((error: HTMLElement) => error.textContent)).toEqual([ 'April 16th, 2020 8:00:00 PM: bar', ]); }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/auto_follow_pattern.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/auto_follow_pattern.ts index 55e4753a577e0..b67c67f9d9894 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/auto_follow_pattern.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/auto_follow_pattern.ts @@ -8,19 +8,21 @@ import { getRandomString } from '@kbn/test-jest-helpers'; import type { AutoFollowPattern } from '../../../../common/types'; +type AutoFollowPatternMockParams = Partial<{ + name: string; + active: boolean; + remoteCluster: string; + leaderIndexPatterns: string[]; + followIndexPattern: string; +}>; + export const getAutoFollowPatternMock = ({ name = getRandomString(), active = false, remoteCluster = getRandomString(), leaderIndexPatterns = [`${getRandomString()}-*`], followIndexPattern = getRandomString(), -}: { - name: string; - active: boolean; - remoteCluster: string; - leaderIndexPatterns: string[]; - followIndexPattern: string; -}): AutoFollowPattern => ({ +}: AutoFollowPatternMockParams = {}): AutoFollowPattern => ({ name, active, remoteCluster, diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts index 6e55249a0f545..0c8c04988a0c3 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/fixtures/follower_index.ts @@ -11,19 +11,19 @@ import type { FollowerIndex } from '../../../../common/types'; const chance = new Chance(); -interface FollowerIndexMock { +type FollowerIndexMockParams = Partial<{ name: string; remoteCluster: string; leaderIndex: string; status: string; -} +}>; export const getFollowerIndexMock = ({ name = getRandomString(), remoteCluster = getRandomString(), leaderIndex = getRandomString(), status = 'Active', -}: FollowerIndexMock): FollowerIndex => ({ +}: FollowerIndexMockParams = {}): FollowerIndex => ({ name, remoteCluster, leaderIndex, diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.tsx similarity index 96% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.tsx index 8e2c56d6ec93b..4ed16bd7e171e 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_add.test.tsx @@ -7,15 +7,18 @@ import { ILLEGAL_CHARACTERS_VISIBLE } from '@kbn/data-views-plugin/public'; import { fireEvent, screen, act } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import './mocks'; import { setupEnvironment, pageHelpers } from './helpers'; const { setup } = pageHelpers.followerIndexAdd; +type SetupEnvironmentReturn = ReturnType; + describe('Create Follower index', () => { - let httpSetup; - let httpRequestsMockHelpers; - let user; + let httpSetup: SetupEnvironmentReturn['httpSetup']; + let httpRequestsMockHelpers: SetupEnvironmentReturn['httpRequestsMockHelpers']; + let user: UserEvent; beforeAll(() => { jest.useFakeTimers(); @@ -248,7 +251,7 @@ describe('Create Follower index', () => { await user.click(toggle); Object.entries(advancedSettingsInputFields).forEach(([testSubj, data]) => { - const input = screen.getByTestId(testSubj); + const input = screen.getByTestId(testSubj) as HTMLInputElement; if (data.type === 'number') { expect(input.type).toBe('number'); } diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.tsx similarity index 68% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.tsx index 085cd3d9ebda5..6be6850ced608 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_index_edit.test.tsx @@ -6,6 +6,7 @@ */ import { screen, within, act, fireEvent } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import { API_BASE_PATH } from '../../../common/constants'; import './mocks'; import { FOLLOWER_INDEX_EDIT, FOLLOWER_INDEX_EDIT_NAME } from './helpers/constants'; @@ -13,10 +14,12 @@ import { setupEnvironment, pageHelpers } from './helpers'; const { setup } = pageHelpers.followerIndexEdit; +type SetupEnvironmentReturn = ReturnType; + describe('Edit follower index', () => { - let httpSetup; - let httpRequestsMockHelpers; - let user; + let httpSetup: SetupEnvironmentReturn['httpSetup']; + let httpRequestsMockHelpers: SetupEnvironmentReturn['httpRequestsMockHelpers']; + let user: UserEvent; beforeAll(() => { jest.useFakeTimers(); @@ -86,7 +89,8 @@ describe('Edit follower index', () => { // Verify GET was called during mount const getCalls = httpSetup.get.mock.calls; const getFollowerCall = getCalls.find( - (call) => call[0] === `${API_BASE_PATH}/follower_indices/${FOLLOWER_INDEX_EDIT_NAME}` + (call: unknown[]) => + call[0] === `${API_BASE_PATH}/follower_indices/${FOLLOWER_INDEX_EDIT_NAME}` ); expect(getFollowerCall).toBeDefined(); @@ -115,6 +119,47 @@ describe('Edit follower index', () => { }) ); }); + + // The follower index edit backend route (and likewise the create route) + // validates the body with `schema.object({...}).unknowns: 'forbid'`. The + // loaded `FollowerIndex` shape includes the read-only server-only fields + // `status` and `shards`; if the form forwards them on save, the server + // will 400 the request. The PUT body must never contain those fields + // regardless of what the GET response looked like. + test('should not forward read-only fields (status, shards) in the PUT body', async () => { + const maxRetryDelayInput = screen.getByTestId('maxRetryDelayInput'); + fireEvent.change(maxRetryDelayInput, { target: { value: '10s' } }); + fireEvent.blur(maxRetryDelayInput); + + const saveButton = screen.getByTestId('submitButton'); + fireEvent.click(saveButton); + + const confirmButton = await screen.findByTestId('confirmModalConfirmButton'); + fireEvent.click(confirmButton); + + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + const putEndpoint = `${API_BASE_PATH}/follower_indices/${FOLLOWER_INDEX_EDIT_NAME}`; + type PutCall = [string | { path: string }, { body?: string } | undefined]; + const putCalls = httpSetup.put.mock.calls as unknown as PutCall[]; + const putCall = putCalls.find(([path]) => { + const resolved = typeof path === 'string' ? path : path.path; + return resolved === putEndpoint; + }); + expect(putCall).toBeDefined(); + const [, options] = putCall!; + const rawBody = options?.body; + expect(typeof rawBody).toBe('string'); + const body = JSON.parse(rawBody as string); + + expect(body).not.toHaveProperty('status'); + expect(body).not.toHaveProperty('shards'); + // And the save must still include the advanced setting the user changed, + // guarding against the flip side — accidentally stripping too much. + expect(body).toHaveProperty('maxRetryDelay', '10s'); + }); }); describe('when the remote cluster is disconnected', () => { diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.tsx similarity index 90% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.tsx index abe44a98654d0..2dada5f5a6c3b 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.tsx @@ -6,17 +6,22 @@ */ import { fireEvent, screen, within, act } from '@testing-library/react'; +import type { UserEvent } from '@testing-library/user-event'; import './mocks'; import { getFollowerIndexMock } from './fixtures/follower_index'; import { setupEnvironment, pageHelpers, getRandomString } from './helpers'; +import type { FollowerIndexListSetupResult } from './helpers/follower_index_list.helpers'; import { EuiTableTestHarness } from '@kbn/test-eui-helpers'; +import type { FollowerIndex } from '../../../common/types'; const { setup } = pageHelpers.followerIndexList; +type SetupEnvironmentReturn = ReturnType; + describe('', () => { - let httpRequestsMockHelpers; - let httpSetup; - let user; + let httpRequestsMockHelpers: SetupEnvironmentReturn['httpRequestsMockHelpers']; + let httpSetup: SetupEnvironmentReturn['httpSetup']; + let user: UserEvent; beforeAll(() => { jest.useFakeTimers(); @@ -64,10 +69,10 @@ describe('', () => { }); describe('when there are multiple pages of follower indices', () => { - const followerIndices = [{ name: 'unique', seeds: [] }]; + const followerIndices: FollowerIndex[] = [getFollowerIndexMock({ name: 'unique' })]; for (let i = 0; i < 29; i++) { - followerIndices.push({ name: `name${i}`, seeds: [] }); + followerIndices.push(getFollowerIndexMock({ name: `name${i}` })); } beforeEach(async () => { @@ -102,9 +107,9 @@ describe('', () => { }); describe('when there are follower indices', () => { - let table; - let tableCellsValues; - let actions; + let table: EuiTableTestHarness; + let tableCellsValues: string[][]; + let actions: FollowerIndexListSetupResult['actions']; const index1 = getFollowerIndexMock({ name: `a${getRandomString()}` }); const index2 = getFollowerIndexMock({ name: `b${getRandomString()}`, status: 'paused' }); @@ -165,7 +170,7 @@ describe('', () => { const contextMenu = await screen.findByTestId('contextMenu'); const buttons = within(contextMenu).queryAllByRole('button'); - const buttonsLabel = buttons.map((btn) => btn.textContent); + const buttonsLabel = buttons.map((btn: HTMLElement) => btn.textContent); expect(buttonsLabel).toEqual([ 'Pause replication', @@ -183,7 +188,7 @@ describe('', () => { const contextMenu = await screen.findByTestId('contextMenu'); const buttons = within(contextMenu).queryAllByRole('button'); - const buttonsLabel = buttons.map((btn) => btn.textContent); + const buttonsLabel = buttons.map((btn: HTMLElement) => btn.textContent); expect(buttonsLabel).toEqual([ 'Resume replication', @@ -244,8 +249,11 @@ describe('', () => { await user.click(actionButton); const contextMenuPanel = document.querySelector('.euiContextMenuPanel'); + if (!contextMenuPanel) { + throw new Error('expected row context menu panel'); + } const buttons = contextMenuPanel.querySelectorAll('button.euiContextMenuItem'); - const buttonLabels = Array.from(buttons).map((btn) => btn.textContent); + const buttonLabels = Array.from(buttons).map((btn: Element) => btn.textContent); expect(buttonLabels).toEqual([ 'Pause replication', @@ -261,8 +269,11 @@ describe('', () => { await user.click(actionButton); const contextMenuPanel = document.querySelector('.euiContextMenuPanel'); + if (!contextMenuPanel) { + throw new Error('expected row context menu panel'); + } const buttons = contextMenuPanel.querySelectorAll('button.euiContextMenuItem'); - const buttonLabels = Array.from(buttons).map((btn) => btn.textContent); + const buttonLabels = Array.from(buttons).map((btn: Element) => btn.textContent); expect(buttonLabels).toEqual([ 'Resume replication', @@ -358,7 +369,7 @@ describe('', () => { }); test('should set the correct follower index settings values', async () => { - const mapSettingsToFollowerIndexProp = { + const mapSettingsToFollowerIndexProp: Record = { maxReadReqOpCount: 'maxReadRequestOperationCount', maxOutstandingReadReq: 'maxOutstandingReadRequests', maxReadReqSize: 'maxReadRequestSize', @@ -379,7 +390,8 @@ describe('', () => { Object.entries(mapSettingsToFollowerIndexProp).forEach(([setting, prop]) => { const element = within(detailPanel).getByTestId(setting); - expect(element.textContent).toEqual(index1[prop].toString()); + const value = index1[prop]; + expect(element.textContent).toEqual(value !== undefined ? String(value) : ''); }); }); @@ -406,8 +418,8 @@ describe('', () => { const codeBlocks = within(detailPanel).queryAllByTestId('shardsStats'); expect(codeBlocks.length).toBe(index1.shards.length); - codeBlocks.forEach((codeBlock, i) => { - expect(JSON.parse(codeBlock.textContent)).toEqual(index1.shards[i]); + codeBlocks.forEach((codeBlock: HTMLElement, i: number) => { + expect(JSON.parse(codeBlock.textContent ?? '')).toEqual(index1.shards[i]); }); }); }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.ts similarity index 55% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.ts index 73721c4be7c1f..bce4ebeafec90 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.ts @@ -5,24 +5,24 @@ * 2.0. */ -import { renderWithRouter } from './render'; +import type { ComponentProps } from 'react'; +import { renderWithRouter, type CcrRenderResult, type OnRouterPayload } from './render'; import { AutoFollowPatternAdd } from '../../../app/sections/auto_follow_pattern_add'; import { createCrossClusterReplicationStore } from '../../../app/store'; -import { routing } from '../../../app/services/routing'; +import { routing, type CcrReactRouter } from '../../../app/services/routing'; -/** - * @param {object} [props] - * @returns {ReturnType} - */ -export const setup = (props = {}) => { +export const setup = ( + componentProps: Partial> = {} +): CcrRenderResult => { return renderWithRouter(AutoFollowPatternAdd, { store: createCrossClusterReplicationStore(), - onRouter: (router) => { - routing.reactRouter = { + onRouter: (router: OnRouterPayload) => { + const ccrRouter: CcrReactRouter = { ...router, getUrlForApp: () => '', }; + routing.reactRouter = ccrRouter; }, - defaultProps: props, + componentProps, }); }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.ts similarity index 60% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.ts index eaaa95c2c0078..8177f3e1cafe0 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.ts @@ -5,27 +5,27 @@ * 2.0. */ -import { renderWithRouter } from './render'; +import type { ComponentProps } from 'react'; +import { renderWithRouter, type CcrRenderResult, type OnRouterPayload } from './render'; import { AutoFollowPatternEdit } from '../../../app/sections/auto_follow_pattern_edit'; import { createCrossClusterReplicationStore } from '../../../app/store'; -import { routing } from '../../../app/services/routing'; +import { routing, type CcrReactRouter } from '../../../app/services/routing'; import { AUTO_FOLLOW_PATTERN_EDIT_NAME } from './constants'; -/** - * @param {object} [props] - * @returns {ReturnType} - */ -export const setup = (props = {}) => { +export const setup = ( + componentProps: Partial> = {} +): CcrRenderResult => { return renderWithRouter(AutoFollowPatternEdit, { store: createCrossClusterReplicationStore(), initialEntries: [`/${AUTO_FOLLOW_PATTERN_EDIT_NAME}`], routePath: '/:id', - onRouter: (router) => { - routing.reactRouter = { + onRouter: (router: OnRouterPayload) => { + const ccrRouter: CcrReactRouter = { ...router, getUrlForApp: () => '', }; + routing.reactRouter = ccrRouter; }, - defaultProps: props, + componentProps, }); }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.ts similarity index 66% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.ts index a8e646f1ae51b..41bc0b85c873c 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.ts @@ -6,40 +6,46 @@ */ import { screen, within, act } from '@testing-library/react'; -import { renderWithRouter } from './render'; +import type { ComponentProps } from 'react'; import { EuiTableTestHarness } from '@kbn/test-eui-helpers'; +import { renderWithRouter, type CcrRenderResult, type OnRouterPayload } from './render'; import { AutoFollowPatternList } from '../../../app/sections/home/auto_follow_pattern_list'; import { createCrossClusterReplicationStore } from '../../../app/store'; -import { routing } from '../../../app/services/routing'; +import { routing, type CcrReactRouter } from '../../../app/services/routing'; -/** - * @param {object} [props] - * @returns {ReturnType} - */ -export const setup = (props = {}) => { +interface AutoFollowPatternListActions { + selectAutoFollowPatternAt: (index: number) => Promise; + clickBulkDeleteButton: () => Promise; + clickConfirmModalDeleteAutoFollowPattern: () => Promise; + clickAutoFollowPatternAt: (index: number) => Promise; + clickPaginationNextButton: () => Promise; + search: (value: string) => Promise; +} + +export type AutoFollowPatternListSetupResult = CcrRenderResult & { + actions: AutoFollowPatternListActions; +}; + +export const setup = ( + componentProps: Partial> = {} +): AutoFollowPatternListSetupResult => { const result = renderWithRouter(AutoFollowPatternList, { store: createCrossClusterReplicationStore(), - onRouter: (router) => { - routing.reactRouter = { + onRouter: (router: OnRouterPayload) => { + const ccrRouter: CcrReactRouter = { ...router, - history: { - ...router.history, - parentHistory: { - createHref: () => '', - push: () => {}, - }, - }, getUrlForApp: () => '', }; + routing.reactRouter = ccrRouter; }, - defaultProps: props, + componentProps, }); return { ...result, // Helper actions for this specific page actions: { - async selectAutoFollowPatternAt(index) { + async selectAutoFollowPatternAt(index: number) { const table = new EuiTableTestHarness('autoFollowPatternListTable'); const checkbox = within(table.getRows()[index]).getByRole('checkbox'); await result.user.click(checkbox); @@ -59,12 +65,11 @@ export const setup = (props = {}) => { await result.user.click(confirmBtn); // Wait for delete HTTP request and list reload await act(async () => { - // eslint-disable-next-line no-undef await jest.runOnlyPendingTimersAsync(); }); }, - async clickAutoFollowPatternAt(index) { + async clickAutoFollowPatternAt(index: number) { const link = screen.getAllByTestId('autoFollowPatternLink')[index]; await result.user.click(link); }, @@ -75,7 +80,7 @@ export const setup = (props = {}) => { await result.user.click(nextBtn); }, - async search(value) { + async search(value: string) { const input = screen.getByTestId('autoFollowPatternSearch'); await result.user.clear(input); await result.user.type(input, value); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/constants.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/constants.ts similarity index 97% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/constants.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/constants.ts index 50cf96533c105..4c744566633bc 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/constants.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/constants.ts @@ -9,6 +9,7 @@ export const AUTO_FOLLOW_PATTERN_EDIT_NAME = 'my-autofollow'; export const AUTO_FOLLOW_PATTERN_EDIT = { name: AUTO_FOLLOW_PATTERN_EDIT_NAME, + active: true, remoteCluster: 'cluster-2', leaderIndexPatterns: ['my-pattern-*'], followIndexPattern: 'prefix_{{leader_index}}_suffix', @@ -31,4 +32,5 @@ export const FOLLOWER_INDEX_EDIT = { maxWriteBufferSize: '256mb', maxRetryDelay: '225ms', readPollTimeout: '2m', + shards: [], }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_add.helpers.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_add.helpers.ts similarity index 54% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_add.helpers.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_add.helpers.ts index 90f936cf9dc6b..83cb696cdfab9 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_add.helpers.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_add.helpers.ts @@ -5,24 +5,24 @@ * 2.0. */ -import { renderWithRouter } from './render'; +import type { ComponentProps } from 'react'; +import { renderWithRouter, type CcrRenderResult, type OnRouterPayload } from './render'; import { FollowerIndexAdd } from '../../../app/sections/follower_index_add'; import { createCrossClusterReplicationStore } from '../../../app/store'; -import { routing } from '../../../app/services/routing'; +import { routing, type CcrReactRouter } from '../../../app/services/routing'; -/** - * @param {object} [props] - * @returns {ReturnType} - */ -export const setup = (props = {}) => { +export const setup = ( + componentProps: Partial> = {} +): CcrRenderResult => { return renderWithRouter(FollowerIndexAdd, { store: createCrossClusterReplicationStore(), - onRouter: (router) => { - routing.reactRouter = { + onRouter: (router: OnRouterPayload) => { + const ccrRouter: CcrReactRouter = { ...router, getUrlForApp: () => '', }; + routing.reactRouter = ccrRouter; }, - defaultProps: props, + componentProps, }); }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_edit.helpers.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_edit.helpers.ts similarity index 59% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_edit.helpers.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_edit.helpers.ts index 6e4e749286e2f..4f69fcb2d775a 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_edit.helpers.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_edit.helpers.ts @@ -5,27 +5,27 @@ * 2.0. */ -import { renderWithRouter } from './render'; +import type { ComponentProps } from 'react'; +import { renderWithRouter, type CcrRenderResult, type OnRouterPayload } from './render'; import { FollowerIndexEdit } from '../../../app/sections/follower_index_edit'; import { createCrossClusterReplicationStore } from '../../../app/store'; -import { routing } from '../../../app/services/routing'; +import { routing, type CcrReactRouter } from '../../../app/services/routing'; import { FOLLOWER_INDEX_EDIT_NAME } from './constants'; -/** - * @param {object} [props] - * @returns {ReturnType} - */ -export const setup = (props = {}) => { +export const setup = ( + componentProps: Partial> = {} +): CcrRenderResult => { return renderWithRouter(FollowerIndexEdit, { store: createCrossClusterReplicationStore(), initialEntries: [`/${FOLLOWER_INDEX_EDIT_NAME}`], routePath: '/:id', - onRouter: (router) => { - routing.reactRouter = { + onRouter: (router: OnRouterPayload) => { + const ccrRouter: CcrReactRouter = { ...router, getUrlForApp: () => '', }; + routing.reactRouter = ccrRouter; }, - defaultProps: props, + componentProps, }); }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.ts similarity index 56% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.ts index 58b20ff7f5568..7c69f702a8344 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/follower_index_list.helpers.ts @@ -6,38 +6,45 @@ */ import { screen, within } from '@testing-library/react'; -import { renderWithRouter } from './render'; +import type { ComponentProps } from 'react'; import { EuiTableTestHarness } from '@kbn/test-eui-helpers'; +import { renderWithRouter, type CcrRenderResult, type OnRouterPayload } from './render'; import { FollowerIndicesList } from '../../../app/sections/home/follower_indices_list'; import { createCrossClusterReplicationStore } from '../../../app/store'; -import { routing } from '../../../app/services/routing'; +import { routing, type CcrReactRouter } from '../../../app/services/routing'; -/** - * @param {object} [props] - * @returns {ReturnType} - */ -export const setup = (props = {}) => { +interface FollowerIndexListActions { + selectFollowerIndexAt: (index: number) => Promise; + openContextMenu: () => Promise; + clickContextMenuButtonAt: (index: number) => Promise; + openTableRowContextMenuAt: (index: number) => Promise; + clickFollowerIndexAt: (index: number) => Promise; + clickPaginationNextButton: () => Promise; +} + +export type FollowerIndexListSetupResult = CcrRenderResult & { + actions: FollowerIndexListActions; +}; + +export const setup = ( + componentProps: Partial> = {} +): FollowerIndexListSetupResult => { const result = renderWithRouter(FollowerIndicesList, { store: createCrossClusterReplicationStore(), - onRouter: (router) => { - routing.reactRouter = { - history: { - ...router.history, - parentHistory: { - createHref: () => '', - push: () => {}, - }, - }, + onRouter: (router: OnRouterPayload) => { + const ccrRouter: CcrReactRouter = { + ...router, getUrlForApp: () => '', }; + routing.reactRouter = ccrRouter; }, - defaultProps: props, + componentProps, }); return { ...result, actions: { - async selectFollowerIndexAt(index) { + async selectFollowerIndexAt(index: number) { const table = new EuiTableTestHarness('followerIndexListTable'); const checkbox = within(table.getRows()[index]).getByRole('checkbox'); await result.user.click(checkbox); @@ -48,20 +55,24 @@ export const setup = (props = {}) => { await result.user.click(btn); }, - async clickContextMenuButtonAt(index) { + async clickContextMenuButtonAt(index: number) { const menu = screen.getByTestId('contextMenu'); const buttons = within(menu).getAllByRole('button'); await result.user.click(buttons[index]); }, - async openTableRowContextMenuAt(index) { + async openTableRowContextMenuAt(index: number) { const table = new EuiTableTestHarness('followerIndexListTable'); - const actionsCell = within(table.getRows()[index]).getAllByRole('cell').pop(); + const cells = within(table.getRows()[index]).getAllByRole('cell'); + const actionsCell = cells[cells.length - 1]; + if (!actionsCell) { + throw new Error('expected actions cell'); + } const btn = within(actionsCell).getByRole('button'); await result.user.click(btn); }, - async clickFollowerIndexAt(index) { + async clickFollowerIndexAt(index: number) { const links = screen.getAllByTestId('followerIndexLink'); await result.user.click(links[index]); }, diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/home.helpers.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/home.helpers.ts similarity index 52% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/home.helpers.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/home.helpers.ts index 2338668cf11b9..ff7944d63f303 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/home.helpers.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/home.helpers.ts @@ -5,23 +5,26 @@ * 2.0. */ -import { renderWithRouter } from './render'; +import type { ComponentProps } from 'react'; +import { renderWithRouter, type CcrRenderResult, type OnRouterPayload } from './render'; import { CrossClusterReplicationHome } from '../../../app/sections/home'; import { createCrossClusterReplicationStore } from '../../../app/store'; -import { routing } from '../../../app/services/routing'; +import { routing, type CcrReactRouter } from '../../../app/services/routing'; -/** - * @param {object} [props] - * @returns {ReturnType} - */ -export const setup = (props = {}) => { +export const setup = ( + componentProps: Partial> = {} +): CcrRenderResult => { return renderWithRouter(CrossClusterReplicationHome, { store: createCrossClusterReplicationStore(), initialEntries: ['/follower_indices'], routePath: '/:section', - onRouter: (router) => { - routing.reactRouter = router; + onRouter: (router: OnRouterPayload) => { + const ccrRouter: CcrReactRouter = { + ...router, + getUrlForApp: () => '', + }; + routing.reactRouter = ccrRouter; }, - defaultProps: props, + componentProps, }); }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.js deleted file mode 100644 index 8e54f122d8d42..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.js +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { httpServiceMock } from '@kbn/core/public/mocks'; -import { API_BASE_PATH } from '../../../../common/constants'; - -// Register helpers to mock HTTP Requests -const registerHttpRequestMockHelpers = (httpSetup) => { - const mockResponses = new Map( - ['GET', 'PUT', 'DELETE', 'POST'].map((method) => [method, new Map()]) - ); - - const mockMethodImplementation = (method, path) => { - return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); - }; - - httpSetup.get.mockImplementation((path) => mockMethodImplementation('GET', path)); - httpSetup.delete.mockImplementation((path) => mockMethodImplementation('DELETE', path)); - httpSetup.post.mockImplementation((path) => mockMethodImplementation('POST', path)); - httpSetup.put.mockImplementation((path) => mockMethodImplementation('PUT', path)); - - const mockResponse = (method, path, response, error) => { - const defuse = (promise) => { - promise.catch(() => {}); - return promise; - }; - - return mockResponses - .get(method) - .set(path, error ? defuse(Promise.reject({ body: error })) : Promise.resolve(response)); - }; - - const setLoadFollowerIndicesResponse = (response = { indices: [] }, error) => - mockResponse('GET', `${API_BASE_PATH}/follower_indices`, response, error); - - const setLoadAutoFollowPatternsResponse = (response = { patterns: [] }, error) => - mockResponse('GET', `${API_BASE_PATH}/auto_follow_patterns`, response, error); - - const setDeleteAutoFollowPatternResponse = (autoFollowId, response, error) => - mockResponse( - 'DELETE', - `${API_BASE_PATH}/auto_follow_patterns/${autoFollowId}`, - response, - error - ); - - const setAutoFollowStatsResponse = (response, error) => - mockResponse('GET', `${API_BASE_PATH}/stats/auto_follow`, response, error); - - const setLoadRemoteClustersResponse = (response = [], error) => - mockResponse('GET', '/api/remote_clusters', response, error); - - const setGetAutoFollowPatternResponse = (patternId, response = {}, error) => - mockResponse('GET', `${API_BASE_PATH}/auto_follow_patterns/${patternId}`, response, error); - - const setGetClusterIndicesResponse = (response = [], error) => - mockResponse('GET', '/api/index_management/indices', response, error); - - const setGetFollowerIndexResponse = (patternId, response = {}, error) => - mockResponse('GET', `${API_BASE_PATH}/follower_indices/${patternId}`, response, error); - - return { - setLoadFollowerIndicesResponse, - setLoadAutoFollowPatternsResponse, - setDeleteAutoFollowPatternResponse, - setAutoFollowStatsResponse, - setLoadRemoteClustersResponse, - setGetAutoFollowPatternResponse, - setGetClusterIndicesResponse, - setGetFollowerIndexResponse, - }; -}; - -export const init = () => { - const httpSetup = httpServiceMock.createSetupContract(); - - return { - httpSetup, - httpRequestsMockHelpers: registerHttpRequestMockHelpers(httpSetup), - }; -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.ts new file mode 100644 index 0000000000000..9418ae327c459 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/http_requests.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpFetchOptionsWithPath } from '@kbn/core-http-browser'; +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { API_BASE_PATH } from '../../../../common/constants'; +import type { AutoFollowPattern, AutoFollowStats, FollowerIndex } from '../../../../common/types'; +import type { DeleteAutoFollowPatternResponse, RemoteClusterRow } from '../../../app/services/api'; + +type HttpMethod = 'GET' | 'PUT' | 'DELETE' | 'POST'; + +type HttpSetupMock = ReturnType; + +const resolvePath = (pathOrOptions: string | HttpFetchOptionsWithPath): string => + typeof pathOrOptions === 'string' ? pathOrOptions : pathOrOptions.path; + +type MockHttpError = + | string + | { body: string } + | { message: string } + | { body: { message: string } }; + +// Register helpers to mock HTTP Requests +const registerHttpRequestMockHelpers = (httpSetup: HttpSetupMock) => { + const mockResponses = new Map>>( + ['GET', 'PUT', 'DELETE', 'POST'].map( + (method) => [method, new Map()] as [HttpMethod, Map>] + ) + ); + + const mockMethodImplementation = (method: HttpMethod, path: string) => { + return mockResponses.get(method)?.get(path) ?? Promise.resolve({}); + }; + + httpSetup.get.mockImplementation((path) => mockMethodImplementation('GET', resolvePath(path))); + httpSetup.delete.mockImplementation((path) => + mockMethodImplementation('DELETE', resolvePath(path)) + ); + httpSetup.post.mockImplementation((path) => mockMethodImplementation('POST', resolvePath(path))); + httpSetup.put.mockImplementation((path) => mockMethodImplementation('PUT', resolvePath(path))); + + const mockResponse = ( + method: HttpMethod, + path: string, + response?: unknown, + error?: MockHttpError + ) => { + const defuse = (promise: Promise) => { + promise.catch(() => {}); + return promise; + }; + + const createHttpFetchError = (mockError: MockHttpError) => { + const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + + const getBody = (mockErr: MockHttpError): unknown => { + if (typeof mockErr === 'string') { + return mockErr; + } + + return 'body' in mockErr ? mockErr.body : mockErr; + }; + + const body = getBody(mockError); + + const message = (() => { + if (typeof body === 'string') { + return body; + } + + if (isObject(body) && 'message' in body) { + return String(body.message); + } + + return 'Request failed'; + })(); + + const request: HttpFetchOptionsWithPath = { path }; + + const httpError = Object.assign(new Error(message), { body, request }); + + if (isObject(body) && 'statusCode' in body && typeof body.statusCode === 'number') { + Object.assign(httpError, { response: { status: body.statusCode } }); + } + + return httpError; + }; + + return mockResponses + .get(method)! + .set( + path, + error ? defuse(Promise.reject(createHttpFetchError(error))) : Promise.resolve(response) + ); + }; + + const setLoadFollowerIndicesResponse = ( + response: { indices: FollowerIndex[] } = { indices: [] }, + error?: MockHttpError + ) => mockResponse('GET', `${API_BASE_PATH}/follower_indices`, response, error); + + const setLoadAutoFollowPatternsResponse = ( + response: { patterns: AutoFollowPattern[] } = { patterns: [] }, + error?: MockHttpError + ) => mockResponse('GET', `${API_BASE_PATH}/auto_follow_patterns`, response, error); + + const setDeleteAutoFollowPatternResponse = ( + autoFollowId?: string, + response?: DeleteAutoFollowPatternResponse, + error?: MockHttpError + ) => + mockResponse( + 'DELETE', + `${API_BASE_PATH}/auto_follow_patterns/${autoFollowId}`, + response, + error + ); + + const setAutoFollowStatsResponse = (response?: Partial, error?: MockHttpError) => + mockResponse('GET', `${API_BASE_PATH}/stats/auto_follow`, response, error); + + const setLoadRemoteClustersResponse = ( + response: Array = [], + error?: MockHttpError + ) => mockResponse('GET', '/api/remote_clusters', response, error); + + const setGetAutoFollowPatternResponse = ( + patternId: string, + response: AutoFollowPattern, + error?: MockHttpError + ) => mockResponse('GET', `${API_BASE_PATH}/auto_follow_patterns/${patternId}`, response, error); + + const setGetClusterIndicesResponse = ( + response: Array<{ name: string }> = [], + error?: MockHttpError + ) => mockResponse('GET', '/api/index_management/indices', response, error); + + const setGetFollowerIndexResponse = ( + followerIndexId: string, + response: FollowerIndex, + error?: MockHttpError + ) => mockResponse('GET', `${API_BASE_PATH}/follower_indices/${followerIndexId}`, response, error); + + return { + setLoadFollowerIndicesResponse, + setLoadAutoFollowPatternsResponse, + setDeleteAutoFollowPatternResponse, + setAutoFollowStatsResponse, + setLoadRemoteClustersResponse, + setGetAutoFollowPatternResponse, + setGetClusterIndicesResponse, + setGetFollowerIndexResponse, + }; +}; + +export type HttpRequestsMockHelpers = ReturnType; + +export const init = () => { + const httpSetup = httpServiceMock.createSetupContract(); + + return { + httpSetup, + httpRequestsMockHelpers: registerHttpRequestMockHelpers(httpSetup), + }; +}; + +export type InitHttpRequestsResult = ReturnType; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/index.ts similarity index 74% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/index.ts index 300ab0de698eb..5a87ace15820b 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/index.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { getRandomString } from '@kbn/test-jest-helpers'; import { setup as autoFollowPatternListSetup } from './auto_follow_pattern_list.helpers'; import { setup as autoFollowPatternAddSetup } from './auto_follow_pattern_add.helpers'; import { setup as autoFollowPatternEditSetup } from './auto_follow_pattern_edit.helpers'; @@ -12,7 +13,6 @@ import { setup as followerIndexListSetup } from './follower_index_list.helpers'; import { setup as followerIndexAddSetup } from './follower_index_add.helpers'; import { setup as followerIndexEditSetup } from './follower_index_edit.helpers'; import { setup as homeSetup } from './home.helpers'; -import { getRandomString } from '@kbn/test-jest-helpers'; export { getRandomString }; export { setupEnvironment } from './setup_environment'; @@ -25,4 +25,14 @@ export const pageHelpers = { followerIndexAdd: { setup: followerIndexAddSetup }, followerIndexEdit: { setup: followerIndexEditSetup }, home: { setup: homeSetup }, +} as const satisfies { + autoFollowPatternList: { + setup: typeof autoFollowPatternListSetup; + }; + autoFollowPatternAdd: { setup: typeof autoFollowPatternAddSetup }; + autoFollowPatternEdit: { setup: typeof autoFollowPatternEditSetup }; + followerIndexList: { setup: typeof followerIndexListSetup }; + followerIndexAdd: { setup: typeof followerIndexAddSetup }; + followerIndexEdit: { setup: typeof followerIndexEditSetup }; + home: { setup: typeof homeSetup }; }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/render.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/render.tsx similarity index 60% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/render.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/render.tsx index 2642e86237e65..99f16ea49ca93 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/render.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/helpers/render.tsx @@ -6,12 +6,39 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, type RenderResult } from '@testing-library/react'; import * as userEventLib from '@testing-library/user-event'; +import type { UserEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; +import type { Store } from 'redux'; +import type { RouteComponentProps } from 'react-router-dom'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { MemoryRouter, Routes, Route } from '@kbn/shared-ux-router'; +export interface CcrRenderResult extends RenderResult { + user: UserEvent; + store?: Store; +} + +type CcrRouteProps = RouteComponentProps; + +/** Object passed to `onRouter` from the wrapped Route (matches CCR routing mocks). */ +export interface OnRouterPayload { + history: RouteComponentProps['history']; + route: { + match: RouteComponentProps['match']; + location: RouteComponentProps['location']; + }; +} + +export interface RenderWithRouterOptions { + store?: Store; + onRouter?: (router: OnRouterPayload) => void; + initialEntries?: string[]; + routePath?: string; + componentProps?: Partial; +} + /** * Render helper for CCR components with Redux store and React Router setup. * Returns a user event instance configured for fake timers. @@ -23,20 +50,22 @@ import { MemoryRouter, Routes, Route } from '@kbn/shared-ux-router'; * routePath: '/follower_indices/edit/:id', * }); */ -/** - * @returns {import('@testing-library/react').RenderResult & { user: import('@testing-library/user-event').UserEvent }} - */ -export const renderWithRouter = ( - Component, - { store, onRouter, initialEntries = ['/'], routePath = '/', defaultProps = {}, ...props } = {} -) => { +export const renderWithRouter = ( + Component: React.ComponentType>, + { + store, + onRouter, + initialEntries = ['/'], + routePath = '/', + componentProps = {}, + }: RenderWithRouterOptions = {} +): CcrRenderResult => { const user = userEventLib.default.setup({ - // eslint-disable-next-line no-undef advanceTimers: jest.advanceTimersByTime, pointerEventsCheck: 0, // Skip pointer-events check for EUI popovers/portals }); - const Wrapped = (routeProps) => { + const Wrapped = (routeProps: CcrRouteProps) => { // Setup routing callback if provided if (typeof onRouter === 'function') { const router = { @@ -48,8 +77,7 @@ export const renderWithRouter = ( const ComponentWithProps = ( ; + const getSelectedTabText = () => { const container = document; const selected = @@ -20,8 +23,8 @@ const getSelectedTabText = () => { }; describe('', () => { - let httpRequestsMockHelpers; - let user; + let httpRequestsMockHelpers: SetupEnvironmentReturn['httpRequestsMockHelpers']; + let user: UserEvent; beforeAll(() => { jest.useFakeTimers(); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/mocks/track_ui_metric.mock.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/mocks/track_ui_metric.mock.ts index 76354e035e0f7..f201a93daaa22 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/mocks/track_ui_metric.mock.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/__jest__/client_integration/mocks/track_ui_metric.mock.ts @@ -11,8 +11,6 @@ jest.mock('../../../app/services/track_ui_metric', () => { return { ...original, trackUiMetric: jest.fn(), - trackUserRequest: (request: Promise) => { - return request.then((response) => response); - }, + trackUserRequest: (request: Promise): Promise => request, }; }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/app.tsx b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/app.tsx index 87742f2ed0ae9..e4622c2b30eee 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/app.tsx +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/app.tsx @@ -17,11 +17,10 @@ import { EuiPageTemplate } from '@elastic/eui'; import { getFatalErrors } from './services/notifications'; import { routing } from './services/routing'; -// @ts-ignore import { loadPermissions } from './services/api'; +import { getErrorBody, isHttpFetchError, toCcrApiError } from './services/http_error'; import { SectionLoading, PageError } from '../shared_imports'; -// @ts-ignore import { CrossClusterReplicationHome, AutoFollowPatternAdd, @@ -35,15 +34,22 @@ interface AppProps { getUrlForApp: ApplicationStart['getUrlForApp']; } +interface PermissionError { + error: string; + cause?: string[]; + message?: string; + statusCode?: number; +} + interface AppState { isFetchingPermissions: boolean; - fetchPermissionError: any; + fetchPermissionError: PermissionError | undefined; hasPermission: boolean; - missingClusterPrivileges: any[]; + missingClusterPrivileges: string[]; } class AppComponent extends Component { - constructor(props: any) { + constructor(props: AppProps) { super(props); this.registerRouter(); @@ -72,19 +78,24 @@ class AppComponent extends Component { hasPermission, missingClusterPrivileges, }); - } catch (error) { - // Expect an error in the shape provided by Angular's $http service. - if (error && error.body) { + } catch (error: unknown) { + const apiError = toCcrApiError(error); + if (isHttpFetchError(apiError)) { + const body = getErrorBody(apiError); return this.setState({ isFetchingPermissions: false, - fetchPermissionError: error, + fetchPermissionError: { + error: body?.message ?? apiError.message, + message: body?.message, + statusCode: body?.statusCode, + }, }); } // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. getFatalErrors().add( - error, + apiError, i18n.translate('xpack.crossClusterReplication.app.checkPermissionsFatalErrorTitle', { defaultMessage: 'Cross-Cluster Replication app', }) diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.tsx.snap similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.js.snap rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/__snapshots__/auto_follow_pattern_form.test.tsx.snap diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts index ef26fa3e46111..e3ecc46d72550 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.container.ts @@ -5,25 +5,29 @@ * 2.0. */ +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { connect } from 'react-redux'; import type { Props } from './auto_follow_pattern_action_menu'; import { AutoFollowPatternActionMenu as AutoFollowPatternActionMenuView } from './auto_follow_pattern_action_menu'; +import type { CcrState } from '../../store'; -// @ts-ignore import { pauseAutoFollowPattern, resumeAutoFollowPattern } from '../../store/actions'; -const mapDispatchToProps = (dispatch: (action: any) => void) => { - return { - pauseAutoFollowPattern: (ids: string[]) => { - dispatch(pauseAutoFollowPattern(ids)); - }, - resumeAutoFollowPattern: (ids: string[]) => { - dispatch(resumeAutoFollowPattern(ids)); - }, - }; -}; +type OwnProps = Pick; +type DispatchProps = Pick; +type CcrDispatch = ThunkDispatch; -export const AutoFollowPatternActionMenu = connect>( +const mapDispatchToProps = (dispatch: CcrDispatch): DispatchProps => ({ + pauseAutoFollowPattern: (ids: string[]) => { + dispatch(pauseAutoFollowPattern(ids)); + }, + resumeAutoFollowPattern: (ids: string[]) => { + dispatch(resumeAutoFollowPattern(ids)); + }, +}); + +export const AutoFollowPatternActionMenu = connect<{}, DispatchProps, OwnProps, CcrState>( null, mapDispatchToProps )(AutoFollowPatternActionMenuView); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx index 8612daec2dc5d..c1c1374331b79 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_action_menu/auto_follow_pattern_action_menu.tsx @@ -12,7 +12,6 @@ import { i18n } from '@kbn/i18n'; import { AutoFollowPatternDeleteProvider } from '../auto_follow_pattern_delete_provider'; -// @ts-ignore import { routing } from '../../services/routing'; const actionsAriaLabel = i18n.translate( @@ -121,7 +120,7 @@ const AutoFollowPatternActionMenuUI: FunctionComponent = ({ closePopoverViaAction(); }, }, - ].filter(Boolean); + ].filter((item): item is NonNullable => Boolean(item)); const button = ( = ({ defaultMessage: 'Pattern options', } ), - items: panelItems as any, + items: panelItems, }, ]} /> @@ -167,7 +166,7 @@ const AutoFollowPatternActionMenuUI: FunctionComponent = ({ ); }; -export const AutoFollowPatternActionMenu = (props: Omit) => ( +export const AutoFollowPatternActionMenu = (props: Omit) => ( {(deleteAutoFollowPattern: (ids: string[]) => void) => ( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts deleted file mode 100644 index d6b0f9ef292a0..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { FC, ReactNode } from 'react'; - -declare const AutoFollowPatternDeleteProvider: FC<{ - children: (deleteAutoFollowPattern: (ids: string[]) => void) => ReactNode; -}>; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.tsx similarity index 74% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.tsx index 7583bff7b6b19..45b07dc5d73f2 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.tsx @@ -5,33 +5,51 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { PureComponent, Fragment, type ReactNode, type SyntheticEvent } from 'react'; import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiConfirmModal, htmlIdGenerator } from '@elastic/eui'; +import type { CcrState } from '../store/reducers'; import { deleteAutoFollowPattern } from '../store/actions'; import { arrify } from '../../../common/services/utils'; -class AutoFollowPatternDeleteProviderUi extends PureComponent { - state = { +interface Props { + /** Thunk is typed as `string` but the API accepts single or multiple ids. */ + deleteAutoFollowPattern: (id: string | string[]) => void; + children: (deleteAutoFollowPattern: (id: string | string[]) => void) => ReactNode; +} + +interface State { + isModalOpen: boolean; + ids: string[] | null; +} + +class AutoFollowPatternDeleteProviderUi extends PureComponent { + state: State = { isModalOpen: false, ids: null, }; - onMouseOverModal = (event) => { + stopModalEventPropagation = (event: SyntheticEvent) => { // This component can sometimes be used inside of an EuiToolTip, in which case mousing over // the modal can trigger the tooltip. Stopping propagation prevents this. event.stopPropagation(); }; - deleteAutoFollowPattern = (id) => { + deleteAutoFollowPattern = (id: string | string[]) => { this.setState({ isModalOpen: true, ids: arrify(id) }); }; onConfirm = () => { - this.props.deleteAutoFollowPattern(this.state.ids); + const { ids } = this.state; + if (!ids) { + return; + } + this.props.deleteAutoFollowPattern(ids); this.setState({ isModalOpen: false, ids: null }); }; @@ -43,6 +61,9 @@ class AutoFollowPatternDeleteProviderUi extends PureComponent { renderModal = () => { const { ids } = this.state; + if (!ids) { + return null; + } const isSingle = ids.length === 1; const title = isSingle ? i18n.translate( @@ -63,7 +84,6 @@ class AutoFollowPatternDeleteProviderUi extends PureComponent { const confirmModalTitleId = htmlIdGenerator()('confirmModalTitle'); return ( - // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events {!isSingle && ( @@ -118,8 +139,8 @@ class AutoFollowPatternDeleteProviderUi extends PureComponent { } } -const mapDispatchToProps = (dispatch) => ({ - deleteAutoFollowPattern: (id) => dispatch(deleteAutoFollowPattern(id)), +const mapDispatchToProps = (dispatch: ThunkDispatch) => ({ + deleteAutoFollowPattern: (id: string | string[]) => dispatch(deleteAutoFollowPattern(id)), }); export const AutoFollowPatternDeleteProvider = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.tsx similarity index 73% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.tsx index 8e5f0caaeb820..7e4c94dc217c6 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.tsx @@ -6,6 +6,7 @@ */ import { updateFormErrors } from './auto_follow_pattern_form'; +import type { AutoFollowPatternValidationErrors } from '../services/auto_follow_pattern_validators'; jest.mock('../services/auto_follow_pattern_validators', () => ({ validateAutoFollowPattern: jest.fn(), @@ -15,8 +16,8 @@ jest.mock('../services/auto_follow_pattern_validators', () => ({ describe(' { describe('updateFormErrors()', () => { it('should merge errors with existing fieldsErrors', () => { - const errors = { name: 'Some error' }; - const existingErrors = { leaderIndexPatterns: null }; + const errors: AutoFollowPatternValidationErrors = { name: 'Some error' }; + const existingErrors: AutoFollowPatternValidationErrors = { leaderIndexPatterns: null }; const output = updateFormErrors(errors, existingErrors); expect(output).toMatchSnapshot(); }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.tsx similarity index 78% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.tsx index 3df55310fae3a..f6d0fd8a8fca2 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_form.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, Fragment, type ReactNode } from 'react'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -40,42 +40,85 @@ import { SectionError } from './section_error'; import { AutoFollowPatternIndicesPreview } from './auto_follow_pattern_indices_preview'; import { RemoteClustersFormField } from './remote_clusters_form_field'; import { + type AutoFollowPatternValidationErrors, + type MessageError, validateAutoFollowPattern, validateLeaderIndexPattern, } from '../services/auto_follow_pattern_validators'; import { AutoFollowPatternRequestFlyout } from './auto_follow_pattern_request_flyout'; +import type { ApiStatus } from '../../../common/types'; +import type { AutoFollowPatternConfig, AutoFollowPatternCreateConfig } from '../services/api'; +import type { CcrApiError } from '../services/http_error'; +import type { ParsedAutoFollowPattern } from '../store/reducers/auto_follow_pattern'; const indexPatternIllegalCharacters = ILLEGAL_CHARACTERS_VISIBLE.join(' '); const indexNameIllegalCharacters = indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); -const getEmptyAutoFollowPattern = (remoteClusterName = '') => ({ +type AutoFollowPatternFormFields = Omit & { + active?: boolean; +}; + +const getEmptyAutoFollowPattern = (remoteClusterName = ''): AutoFollowPatternFormFields => ({ name: '', remoteCluster: remoteClusterName, leaderIndexPatterns: [], + followIndexPattern: '', followIndexPatternPrefix: '', followIndexPatternSuffix: '', }); -export const updateFormErrors = (errors, existingErrors) => ({ +export const updateFormErrors = ( + errors: AutoFollowPatternValidationErrors, + existingErrors: AutoFollowPatternValidationErrors +) => ({ fieldsErrors: { ...existingErrors, ...errors, }, }); -export class AutoFollowPatternForm extends PureComponent { - static propTypes = { - saveAutoFollowPattern: PropTypes.func.isRequired, - autoFollowPattern: PropTypes.object, - apiError: PropTypes.object, - apiStatus: PropTypes.string.isRequired, - currentUrl: PropTypes.string.isRequired, - remoteClusters: PropTypes.array, - saveButtonLabel: PropTypes.node, - }; +interface RemoteClusterOption { + name: string; + isConnected: boolean; +} + +interface BaseProps { + apiError?: CcrApiError | null; + apiStatus: ApiStatus; + currentUrl: string; + remoteClusters?: RemoteClusterOption[]; + saveButtonLabel: ReactNode; +} + +interface CreateModeProps extends BaseProps { + autoFollowPattern?: undefined; + createAutoFollowPattern: (name: string, autoFollowPattern: AutoFollowPatternCreateConfig) => void; + // Disallow providing the update handler in create mode; without this, callers passing a props bag + // (e.g. via spread) can accidentally provide both handlers when `autoFollowPattern` is omitted. + updateAutoFollowPattern?: never; +} + +interface UpdateModeProps extends BaseProps { + autoFollowPattern: ParsedAutoFollowPattern; + updateAutoFollowPattern: (name: string, autoFollowPattern: AutoFollowPatternConfig) => void; + // Disallow providing the create handler in update mode; without this, callers passing a props bag + // (e.g. via spread) can accidentally provide both handlers. + createAutoFollowPattern?: never; +} + +type Props = CreateModeProps | UpdateModeProps; - constructor(props) { +interface State { + autoFollowPattern: AutoFollowPatternFormFields; + fieldsErrors: AutoFollowPatternValidationErrors; + areErrorsVisible: boolean; + isNew: boolean; + isRequestVisible: boolean; +} + +export class AutoFollowPatternForm extends PureComponent { + constructor(props: Props) { super(props); const isNew = this.props.autoFollowPattern === undefined; @@ -83,9 +126,16 @@ export class AutoFollowPatternForm extends PureComponent { route: { location: { search }, }, - } = routing.reactRouter; + } = routing.reactRouterOrThrow; const queryParams = extractQueryParams(search); - const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); + const rawCluster = queryParams.cluster; + const clusterParam = + typeof rawCluster === 'string' + ? rawCluster + : Array.isArray(rawCluster) + ? rawCluster[0] + : undefined; + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters ?? [], clusterParam); const autoFollowPattern = isNew ? getEmptyAutoFollowPattern(remoteClusterName) : { @@ -94,7 +144,13 @@ export class AutoFollowPatternForm extends PureComponent { this.state = { autoFollowPattern, - fieldsErrors: validateAutoFollowPattern(autoFollowPattern), + fieldsErrors: validateAutoFollowPattern({ + name: autoFollowPattern.name, + leaderIndexPatterns: autoFollowPattern.leaderIndexPatterns, + followIndexPatternPrefix: autoFollowPattern.followIndexPatternPrefix, + followIndexPatternSuffix: autoFollowPattern.followIndexPatternSuffix, + remoteCluster: autoFollowPattern.remoteCluster, + }), areErrorsVisible: false, isNew, isRequestVisible: false, @@ -107,7 +163,7 @@ export class AutoFollowPatternForm extends PureComponent { })); }; - onFieldsChange = (fields) => { + onFieldsChange = (fields: Partial) => { this.setState(({ autoFollowPattern }) => ({ autoFollowPattern: { ...autoFollowPattern, @@ -115,22 +171,28 @@ export class AutoFollowPatternForm extends PureComponent { }, })); - const errors = validateAutoFollowPattern(fields); + const errors = validateAutoFollowPattern({ + name: fields.name, + leaderIndexPatterns: fields.leaderIndexPatterns, + followIndexPatternPrefix: fields.followIndexPatternPrefix, + followIndexPatternSuffix: fields.followIndexPatternSuffix, + remoteCluster: fields.remoteCluster, + }); this.onFieldsErrorChange(errors); }; - onFieldsErrorChange = (errors) => + onFieldsErrorChange = (errors: AutoFollowPatternValidationErrors) => this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); - onClusterChange = (remoteCluster) => { + onClusterChange = (remoteCluster: string) => { this.onFieldsChange({ remoteCluster }); }; - onCreateLeaderIndexPattern = (indexPattern) => { + onCreateLeaderIndexPattern = (indexPattern: string) => { const error = validateLeaderIndexPattern(indexPattern); if (error) { - const errors = { + const errors: AutoFollowPatternValidationErrors = { leaderIndexPatterns: { ...error, alwaysVisible: true, @@ -152,13 +214,13 @@ export class AutoFollowPatternForm extends PureComponent { this.onFieldsChange({ leaderIndexPatterns: newLeaderIndexPatterns }); }; - onLeaderIndexPatternChange = (indexPatterns) => { + onLeaderIndexPatternChange = (indexPatterns: Array>) => { this.onFieldsChange({ leaderIndexPatterns: indexPatterns.map(({ label }) => label), }); }; - onLeaderIndexPatternInputChange = (leaderIndexPattern) => { + onLeaderIndexPatternInputChange = (leaderIndexPattern: string) => { const isEmpty = !leaderIndexPattern || !leaderIndexPattern.trim(); const { autoFollowPattern: { leaderIndexPatterns }, @@ -172,7 +234,7 @@ export class AutoFollowPatternForm extends PureComponent { } ); - const errors = { + const errors: AutoFollowPatternValidationErrors = { leaderIndexPatterns: { message: errorMsg, alwaysVisible: true, @@ -181,15 +243,17 @@ export class AutoFollowPatternForm extends PureComponent { this.setState(({ fieldsErrors }) => updateFormErrors(errors, fieldsErrors)); } else { - this.setState(({ fieldsErrors, autoFollowPattern: { 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); - }); + this.setState( + ({ fieldsErrors, autoFollowPattern: { leaderIndexPatterns: existingPatterns } }) => { + const errors = Boolean(existingPatterns.length) + ? // Validate existing patterns, so we can surface an error if this required input is missing. + validateAutoFollowPattern({ leaderIndexPatterns: existingPatterns }) + : // Validate the input as the user types so they have immediate feedback about errors. + validateAutoFollowPattern({ leaderIndexPatterns: [leaderIndexPattern] }); + + return updateFormErrors(errors, fieldsErrors); + } + ); } }; @@ -204,9 +268,7 @@ export class AutoFollowPatternForm extends PureComponent { }; isFormValid() { - return Object.values(this.state.fieldsErrors).every( - (error) => error === undefined || error === null - ); + return Object.values(this.state.fieldsErrors).every((error) => error == null); } sendForm = () => { @@ -219,8 +281,16 @@ export class AutoFollowPatternForm extends PureComponent { this.setState({ areErrorsVisible: false }); - const { name, ...autoFollowPattern } = this.getFields(); - this.props.saveAutoFollowPattern(name, autoFollowPattern); + const { name, active, ...autoFollowPattern } = this.getFields(); + const props = this.props; + if (props.autoFollowPattern === undefined) { + props.createAutoFollowPattern(name, autoFollowPattern); + return; + } + props.updateAutoFollowPattern(name, { + ...autoFollowPattern, + active: active ?? true, + }); }; cancelForm = () => { @@ -258,8 +328,8 @@ export class AutoFollowPatternForm extends PureComponent { name, remoteCluster, leaderIndexPatterns, - followIndexPatternPrefix, - followIndexPatternSuffix, + followIndexPatternPrefix = '', + followIndexPatternSuffix = '', }, isNew, areErrorsVisible, @@ -299,7 +369,7 @@ export class AutoFollowPatternForm extends PureComponent { defaultMessage="Name" /> } - error={fieldsErrors.name} + error={fieldsErrors.name ?? undefined} isInvalid={isInvalid} fullWidth > @@ -329,12 +399,12 @@ export class AutoFollowPatternForm extends PureComponent { defaultMessage="Auto-follow patterns capture indices on remote clusters." /> ), - remoteClusterNotConnectedNotEditable: (name) => ({ + remoteClusterNotConnectedNotEditable: (clusterName: string) => ({ title: ( ), description: ( @@ -344,12 +414,12 @@ export class AutoFollowPatternForm extends PureComponent { /> ), }), - remoteClusterDoesNotExist: (name) => ( + remoteClusterDoesNotExist: (clusterName: string) => ( ), }; @@ -376,7 +446,7 @@ export class AutoFollowPatternForm extends PureComponent { > { - const hasError = !!( - fieldsErrors.leaderIndexPatterns && fieldsErrors.leaderIndexPatterns.message - ); + const leaderIndexPatternsError: MessageError | null | undefined = + fieldsErrors.leaderIndexPatterns; + const hasError = Boolean(leaderIndexPatternsError?.message); const isInvalid = - hasError && (fieldsErrors.leaderIndexPatterns.alwaysVisible || areErrorsVisible); + hasError && (Boolean(leaderIndexPatternsError?.alwaysVisible) || areErrorsVisible); const formattedLeaderIndexPatterns = leaderIndexPatterns.map((pattern) => ({ label: pattern, })); @@ -459,7 +529,7 @@ export class AutoFollowPatternForm extends PureComponent { /> } isInvalid={isInvalid} - error={fieldsErrors.leaderIndexPatterns && fieldsErrors.leaderIndexPatterns.message} + error={leaderIndexPatternsError && leaderIndexPatternsError.message} fullWidth > } - error={fieldsErrors.followIndexPatternPrefix} + error={fieldsErrors.followIndexPatternPrefix ?? undefined} isInvalid={isPrefixInvalid} fullWidth > @@ -545,7 +615,7 @@ export class AutoFollowPatternForm extends PureComponent { defaultMessage="Suffix" /> } - error={fieldsErrors.followIndexPatternSuffix} + error={fieldsErrors.followIndexPatternSuffix ?? undefined} isInvalid={isSuffixInvalid} fullWidth > @@ -588,7 +658,6 @@ export class AutoFollowPatternForm extends PureComponent { * Form Error warning message */ const renderFormErrorWarning = () => { - const { areErrorsVisible } = this.state; const isFormValid = this.isFormValid(); if (!areErrorsVisible || isFormValid) { @@ -619,7 +688,7 @@ export class AutoFollowPatternForm extends PureComponent { */ const renderActions = () => { const { apiStatus, saveButtonLabel } = this.props; - const { areErrorsVisible, isRequestVisible } = this.state; + const { isRequestVisible } = this.state; if (apiStatus === API_STATUS.SAVING) { return ( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.tsx similarity index 67% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.tsx index e235432f0a341..a9db3481ac0f5 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_indices_preview.tsx @@ -5,14 +5,24 @@ * 2.0. */ -import React from 'react'; +import React, { type FC } from 'react'; import { EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { getPreviewIndicesFromAutoFollowPattern } from '../services/auto_follow_pattern'; -export const AutoFollowPatternIndicesPreview = ({ prefix, suffix, leaderIndexPatterns }) => { +interface Props { + prefix?: string; + suffix?: string; + leaderIndexPatterns: string[]; +} + +export const AutoFollowPatternIndicesPreview: FC = ({ + prefix = '', + suffix = '', + leaderIndexPatterns, +}) => { const { indicesPreview } = getPreviewIndicesFromAutoFollowPattern({ prefix, suffix, @@ -33,13 +43,15 @@ export const AutoFollowPatternIndicesPreview = ({ prefix, suffix, leaderIndexPat defaultMessage="The above settings will generate index names that look like this:" />
    - {indicesPreview.map(({ followPattern: { prefix, suffix, template } }, i) => ( -
  • - {prefix} - {template} - {suffix} -
  • - ))} + {indicesPreview.map( + ({ followPattern: { prefix: fpPrefix, suffix: fpSuffix, template } }, i) => ( +
  • + {fpPrefix} + {template} + {fpSuffix} +
  • + ) + )}
); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.tsx similarity index 84% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.tsx index 8984fed221961..75c0102e59202 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_page_title.tsx @@ -5,15 +5,18 @@ * 2.0. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { type ReactNode } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiPageHeader, EuiButtonEmpty } from '@elastic/eui'; import { documentationLinks } from '../services/documentation_links'; -export const AutoFollowPatternPageTitle = ({ title }) => ( +interface Props { + title: ReactNode; +} + +export const AutoFollowPatternPageTitle = ({ title }: Props) => ( <> ( ); - -AutoFollowPatternPageTitle.propTypes = { - title: PropTypes.node.isRequired, -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_request_flyout.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_request_flyout.tsx similarity index 93% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_request_flyout.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_request_flyout.tsx index 52d20c910549d..2b90989700b8c 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_request_flyout.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/auto_follow_pattern_request_flyout.tsx @@ -7,7 +7,6 @@ import React, { PureComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import PropTypes from 'prop-types'; import { EuiButtonEmpty, @@ -23,15 +22,16 @@ import { } from '@elastic/eui'; import { serializeAutoFollowPattern } from '../../../common/services/auto_follow_pattern_serialization'; +import type { AutoFollowPatternCreateConfig } from '../services/api'; -export class AutoFollowPatternRequestFlyout extends PureComponent { - static propTypes = { - close: PropTypes.func.isRequired, - name: PropTypes.string.isRequired, - autoFollowPattern: PropTypes.object.isRequired, - isNew: PropTypes.bool, - }; +interface Props { + close: () => void; + name: string; + autoFollowPattern: AutoFollowPatternCreateConfig; + isNew?: boolean; +} +export class AutoFollowPatternRequestFlyout extends PureComponent { render() { const { name, autoFollowPattern, close, isNew } = this.props; const endpoint = `PUT /_ccr/auto_follow/${name ? name : ''}`; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_actions_provider.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_actions_provider.tsx similarity index 68% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_actions_provider.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_actions_provider.tsx index 80f4837d6c01c..64453beaeb9bf 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_actions_provider.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_actions_provider.tsx @@ -5,13 +5,25 @@ * 2.0. */ -import React from 'react'; +import React, { type ReactNode } from 'react'; + +import type { FollowerIndex } from '../../../../common/types'; import { FollowerIndexPauseProvider } from './follower_index_pause_provider'; import { FollowerIndexResumeProvider } from './follower_index_resume_provider'; import { FollowerIndexUnfollowProvider } from './follower_index_unfollow_provider'; -export const FollowerIndexActionsProvider = (props) => { +export interface FollowerIndexActionsCallbacks { + pauseFollowerIndex: (index: FollowerIndex | FollowerIndex[]) => void; + resumeFollowerIndex: (id: string | string[]) => void; + unfollowLeaderIndex: (id: string | string[]) => void; +} + +interface Props { + children: (getActions: () => FollowerIndexActionsCallbacks) => ReactNode; +} + +export const FollowerIndexActionsProvider = (props: Props) => { return ( {(pauseFollowerIndex) => ( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.tsx similarity index 79% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.tsx index 3c6a7fd4fd92c..c1e0e35bb1c1b 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.tsx @@ -5,41 +5,51 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, Fragment, type ReactNode, type SyntheticEvent } from 'react'; import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiConfirmModal, htmlIdGenerator } from '@elastic/eui'; +import type { CcrState } from '../../store/reducers'; +import type { FollowerIndex } from '../../../../common/types'; import { pauseFollowerIndex } from '../../store/actions'; import { arrify } from '../../../../common/services/utils'; import { areAllSettingsDefault } from '../../services/follower_index_default_settings'; -class FollowerIndexPauseProviderUi extends PureComponent { - static propTypes = { - onConfirm: PropTypes.func, - }; +interface Props { + pauseFollowerIndex: (id: string | string[]) => void; + children: (pauseFollowerIndex: (index: FollowerIndex | FollowerIndex[]) => void) => ReactNode; + onConfirm?: () => void; +} + +interface State { + isModalOpen: boolean; + indices: FollowerIndex[]; +} - state = { +class FollowerIndexPauseProviderUi extends PureComponent { + state: State = { isModalOpen: false, indices: [], }; - onMouseOverModal = (event) => { + stopModalEventPropagation = (event: SyntheticEvent) => { // This component can sometimes be used inside of an EuiToolTip, in which case mousing over // the modal can trigger the tooltip. Stopping propagation prevents this. event.stopPropagation(); }; - pauseFollowerIndex = (index) => { + pauseFollowerIndex = (index: FollowerIndex | FollowerIndex[]) => { this.setState({ isModalOpen: true, indices: arrify(index) }); }; onConfirm = () => { this.props.pauseFollowerIndex(this.state.indices.map((index) => index.name)); this.setState({ isModalOpen: false, indices: [] }); - this.props.onConfirm && this.props.onConfirm(); + this.props.onConfirm?.(); }; closeConfirmModal = () => { @@ -50,6 +60,9 @@ class FollowerIndexPauseProviderUi extends PureComponent { renderModal = () => { const { indices } = this.state; + if (!indices.length) { + return null; + } const isSingle = indices.length === 1; const title = isSingle ? i18n.translate( @@ -71,7 +84,6 @@ class FollowerIndexPauseProviderUi extends PureComponent { const confirmModalTitleId = htmlIdGenerator()('confirmModalTitle'); return ( - // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events {hasCustomSettings && ( @@ -144,8 +157,8 @@ class FollowerIndexPauseProviderUi extends PureComponent { } } -const mapDispatchToProps = (dispatch) => ({ - pauseFollowerIndex: (id) => dispatch(pauseFollowerIndex(id)), +const mapDispatchToProps = (dispatch: ThunkDispatch) => ({ + pauseFollowerIndex: (id: string | string[]) => dispatch(pauseFollowerIndex(id)), }); export const FollowerIndexPauseProvider = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.tsx similarity index 79% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.tsx index 1a48518ceecd5..de45363f78a20 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.tsx @@ -5,41 +5,54 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, Fragment, type ReactNode, type SyntheticEvent } from 'react'; import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiConfirmModal, EuiLink, htmlIdGenerator } from '@elastic/eui'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import type { CcrState } from '../../store/reducers'; import { routing } from '../../services/routing'; import { resumeFollowerIndex } from '../../store/actions'; import { arrify } from '../../../../common/services/utils'; -class FollowerIndexResumeProviderUi extends PureComponent { - static propTypes = { - onConfirm: PropTypes.func, - }; +interface Props { + resumeFollowerIndex: (id: string | string[]) => void; + children: (resumeFollowerIndex: (id: string | string[]) => void) => ReactNode; + onConfirm?: () => void; +} - state = { +interface State { + isModalOpen: boolean; + ids: string[] | null; +} + +class FollowerIndexResumeProviderUi extends PureComponent { + state: State = { isModalOpen: false, ids: null, }; - onMouseOverModal = (event) => { + stopModalEventPropagation = (event: SyntheticEvent) => { // This component can sometimes be used inside of an EuiToolTip, in which case mousing over // the modal can trigger the tooltip. Stopping propagation prevents this. event.stopPropagation(); }; - resumeFollowerIndex = (id) => { + resumeFollowerIndex = (id: string | string[]) => { this.setState({ isModalOpen: true, ids: arrify(id) }); }; onConfirm = () => { - this.props.resumeFollowerIndex(this.state.ids); + const { ids } = this.state; + if (!ids) { + return; + } + this.props.resumeFollowerIndex(ids); this.setState({ isModalOpen: false, ids: null }); - this.props.onConfirm && this.props.onConfirm(); + this.props.onConfirm?.(); }; closeConfirmModal = () => { @@ -50,6 +63,10 @@ class FollowerIndexResumeProviderUi extends PureComponent { renderModal = () => { const { ids } = this.state; + if (!ids) { + return null; + } + const reactRouter = routing.reactRouterOrThrow; const isSingle = ids.length === 1; const title = isSingle ? i18n.translate( @@ -70,7 +87,6 @@ class FollowerIndexResumeProviderUi extends PureComponent { const confirmModalTitleId = htmlIdGenerator()('confirmModalTitle'); return ( - // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events {isSingle ? ( @@ -103,7 +120,7 @@ class FollowerIndexResumeProviderUi extends PureComponent { editLink: ( ({ - resumeFollowerIndex: (id) => dispatch(resumeFollowerIndex(id)), +const mapDispatchToProps = (dispatch: ThunkDispatch) => ({ + resumeFollowerIndex: (id: string | string[]) => dispatch(resumeFollowerIndex(id)), }); export const FollowerIndexResumeProvider = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.tsx similarity index 77% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.tsx index 14657d819b78b..7aa6dd49cc7f2 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.tsx @@ -5,40 +5,53 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, Fragment, type ReactNode, type SyntheticEvent } from 'react'; import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiConfirmModal, htmlIdGenerator } from '@elastic/eui'; +import type { CcrState } from '../../store/reducers'; import { unfollowLeaderIndex } from '../../store/actions'; import { arrify } from '../../../../common/services/utils'; -class FollowerIndexUnfollowProviderUi extends PureComponent { - static propTypes = { - onConfirm: PropTypes.func, - }; +interface Props { + unfollowLeaderIndex: (id: string | string[]) => void; + children: (unfollowLeaderIndex: (id: string | string[]) => void) => ReactNode; + onConfirm?: () => void; +} - state = { +interface State { + isModalOpen: boolean; + ids: string[] | null; +} + +class FollowerIndexUnfollowProviderUi extends PureComponent { + state: State = { isModalOpen: false, ids: null, }; - onMouseOverModal = (event) => { + stopModalEventPropagation = (event: SyntheticEvent) => { // This component can sometimes be used inside of an EuiToolTip, in which case mousing over // the modal can trigger the tooltip. Stopping propagation prevents this. event.stopPropagation(); }; - unfollowLeaderIndex = (id) => { + unfollowLeaderIndex = (id: string | string[]) => { this.setState({ isModalOpen: true, ids: arrify(id) }); }; onConfirm = () => { - this.props.unfollowLeaderIndex(this.state.ids); + const { ids } = this.state; + if (!ids) { + return; + } + this.props.unfollowLeaderIndex(ids); this.setState({ isModalOpen: false, ids: null }); - this.props.onConfirm && this.props.onConfirm(); + this.props.onConfirm?.(); }; closeConfirmModal = () => { @@ -49,6 +62,9 @@ class FollowerIndexUnfollowProviderUi extends PureComponent { renderModal = () => { const { ids } = this.state; + if (!ids) { + return null; + } const isSingle = ids.length === 1; const title = isSingle ? i18n.translate( @@ -68,7 +84,6 @@ class FollowerIndexUnfollowProviderUi extends PureComponent { const modalTitleId = htmlIdGenerator()('confirmModalTitle'); return ( - // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events {isSingle ? ( @@ -136,8 +152,8 @@ class FollowerIndexUnfollowProviderUi extends PureComponent { } } -const mapDispatchToProps = (dispatch) => ({ - unfollowLeaderIndex: (id) => dispatch(unfollowLeaderIndex(id)), +const mapDispatchToProps = (dispatch: ThunkDispatch) => ({ + unfollowLeaderIndex: (id: string | string[]) => dispatch(unfollowLeaderIndex(id)), }); export const FollowerIndexUnfollowProvider = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_actions_providers/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.tsx.snap similarity index 94% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.tsx.snap index 017f565ff9f48..584fb2e10e12e 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.js.snap +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/__snapshots__/follower_index_form.test.tsx.snap @@ -5,6 +5,7 @@ Object { "followerIndex": Object { "leaderIndex": "bar", "name": "new-name", + "remoteCluster": "remote", }, } `; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.tsx similarity index 88% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.tsx index 92a7a3670722b..3474e50b41ce4 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/advanced_settings_fields.tsx @@ -6,12 +6,39 @@ */ import React from 'react'; +import type { ReactElement } from 'react'; +import type { DocLinksStart } from '@kbn/core/public'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { EuiLink } from '@elastic/eui'; + +import type { FollowerIndexAdvancedSettings } from '../../../../common/types'; import { getSettingDefault } from '../../services/follower_index_default_settings'; -export const getAdvancedSettingsFields = (documentationLinks) => { +type DocumentationLinks = DocLinksStart['links']; + +type AdvancedSettingField = keyof FollowerIndexAdvancedSettings; +type AdvancedSettingValue = string | number; + +export type AdvancedSettingValidator = ( + value: AdvancedSettingValue | undefined +) => ReactElement[] | undefined; + +interface AdvancedSettingFieldDefinition { + readonly field: AdvancedSettingField; + readonly testSubject: string; + readonly title: string; + readonly description: string; + readonly label: string; + readonly defaultValue: AdvancedSettingValue; + readonly helpText?: React.ReactNode; + readonly type?: string; + readonly validator?: AdvancedSettingValidator; +} + +export const getAdvancedSettingsFields = ( + documentationLinks: DocumentationLinks +): AdvancedSettingFieldDefinition[] => { const byteUnitsHelpText = ( { ]; }; -export const getEmptyAdvancedSettings = (documentationLinks) => - getAdvancedSettingsFields(documentationLinks).reduce((obj, advancedSetting) => { - const { field, defaultValue } = advancedSetting; - return { ...obj, [field]: defaultValue }; - }, {}); +export const getEmptyAdvancedSettings = (documentationLinks: DocumentationLinks) => + getAdvancedSettingsFields(documentationLinks).reduce>( + (obj, advancedSetting) => { + const { field, defaultValue } = advancedSetting; + return { ...obj, [field]: defaultValue }; + }, + {} + ); -export function areAdvancedSettingsEdited(followerIndex, documentationLinks) { +export function areAdvancedSettingsEdited( + followerIndex: FollowerIndexAdvancedSettings, + documentationLinks: DocumentationLinks +) { + const empty = getEmptyAdvancedSettings(documentationLinks); return getAdvancedSettingsFields(documentationLinks).some((advancedSetting) => { const { field } = advancedSetting; - return followerIndex[field] !== getEmptyAdvancedSettings(documentationLinks)[field]; + return followerIndex[field] !== empty[field]; }); } diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.tsx similarity index 65% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.tsx index d541c43d99b5c..5b764dca61fbb 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.test.tsx @@ -5,12 +5,12 @@ * 2.0. */ -import { updateFields, updateFormErrors } from './follower_index_form'; +import { updateFields, updateFormErrors, type FollowerIndexFormState } from './follower_index_form'; describe(' state transitions', () => { it('updateFormErrors() should merge errors with existing fieldsErrors', () => { - const errors = { name: 'Some error' }; - const state = { + const errors: Record = { name: 'Some error' }; + const state: Pick = { fieldsErrors: { leaderIndex: null }, }; const output = updateFormErrors(errors)(state); @@ -19,8 +19,12 @@ describe(' state transitions', () => { it('updateFields() should merge new fields value with existing followerIndex', () => { const fields = { name: 'new-name' }; - const state = { - followerIndex: { name: 'foo', leaderIndex: 'bar' }, + const state: Pick = { + followerIndex: { + name: 'foo', + remoteCluster: 'remote', + leaderIndex: 'bar', + }, }; const output = updateFields(fields)(state); expect(output).toMatchSnapshot(); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.tsx similarity index 74% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.tsx index 3b05a933a8724..49a12f49dee7a 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_form.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, Fragment, type ReactElement, type ReactNode } from 'react'; import { debounce } from 'lodash'; +import type { DebouncedFunc } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,16 +27,19 @@ import { EuiSwitch, EuiText, EuiTitle, + type EuiSwitchEvent, } from '@elastic/eui'; import { extractQueryParams, indices } from '../../../shared_imports'; import { indexNameValidator, leaderIndexValidator } from '../../services/input_validation'; import { routing } from '../../services/routing'; import { getFatalErrors } from '../../services/notifications'; -import { loadIndices } from '../../services/api'; +import { loadIndices, type FollowerIndexSaveBody } from '../../services/api'; import { documentationLinks } from '../../services/documentation_links'; import { API_STATUS } from '../../constants'; import { getRemoteClusterName } from '../../services/get_remote_cluster_name'; +import type { CcrApiError } from '../../services/http_error'; +import { getErrorStatus, isHttpFetchError, toCcrApiError } from '../../services/http_error'; import { RemoteClustersFormField } from '../remote_clusters_form_field'; import { SectionError } from '../section_error'; import { FormEntryRow } from '../form_entry_row'; @@ -44,38 +47,84 @@ import { getAdvancedSettingsFields, getEmptyAdvancedSettings, areAdvancedSettingsEdited, + type AdvancedSettingValidator, } from './advanced_settings_fields'; import { FollowerIndexRequestFlyout } from './follower_index_request_flyout'; +import type { + ApiStatus, + FollowerIndex, + FollowerIndexAdvancedSettings, +} from '../../../../common/types'; const indexNameIllegalCharacters = indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' '); -const getFieldToValidatorMap = (advancedSettingsFields) => - advancedSettingsFields.reduce( - (map, advancedSetting) => { - const { field, validator } = advancedSetting; - map[field] = validator; - return map; - }, - { - name: indexNameValidator, - leaderIndex: leaderIndexValidator, +type FieldValidator = (value: string | number | undefined) => ReactElement[] | undefined; +type FollowerIndexFormFields = FollowerIndexSaveBody & Pick; + +const getFieldToValidatorMap = ( + advancedSettingsFields: ReturnType +): Record => { + const map: Record = { + name: (value) => indexNameValidator(String(value ?? '')), + leaderIndex: (value) => leaderIndexValidator(String(value ?? '')), + }; + for (const advancedSetting of advancedSettingsFields) { + if (typeof advancedSetting.validator === 'function') { + const validator: AdvancedSettingValidator = advancedSetting.validator; + map[advancedSetting.field] = validator; } - ); + } + return map; +}; -const getEmptyFollowerIndex = (remoteClusterName = '') => ({ +const getEmptyFollowerIndex = (remoteClusterName = ''): FollowerIndexFormFields => ({ name: '', remoteCluster: remoteClusterName, leaderIndex: '', ...getEmptyAdvancedSettings(documentationLinks), }); +type FollowerIndexFieldError = + | ReactElement[] + | { message: ReactNode; alwaysVisible?: boolean } + | undefined + | null; + +interface RemoteClusterOption { + name: string; + isConnected: boolean; +} + +interface Props { + saveFollowerIndex: (name: string, followerIndex: FollowerIndexSaveBody) => void; + clearApiError: () => void; + followerIndex?: Omit; + apiError?: CcrApiError | null; + apiStatus: ApiStatus; + remoteClusters?: RemoteClusterOption[]; + saveButtonLabel: ReactNode; + currentUrl: string; +} + +interface State { + isNew: boolean; + followerIndex: FollowerIndexFormFields; + fieldsErrors: Record; + areErrorsVisible: boolean; + areAdvancedSettingsVisible: boolean; + isValidatingIndexName: boolean; + isRequestVisible: boolean; +} + +export type FollowerIndexFormState = State; + /** * State transitions: fields update */ export const updateFields = - (fields) => - ({ followerIndex }) => ({ + (fields: Partial) => + ({ followerIndex }: Pick) => ({ followerIndex: { ...followerIndex, ...fields, @@ -86,50 +135,53 @@ export const updateFields = * State transitions: errors update */ export const updateFormErrors = - (errors) => - ({ fieldsErrors }) => ({ + (errors: Record) => + ({ fieldsErrors }: Pick) => ({ fieldsErrors: { ...fieldsErrors, ...errors, }, }); -export class FollowerIndexForm extends PureComponent { - static propTypes = { - saveFollowerIndex: PropTypes.func.isRequired, - clearApiError: PropTypes.func.isRequired, - followerIndex: PropTypes.object, - apiError: PropTypes.object, - apiStatus: PropTypes.string.isRequired, - remoteClusters: PropTypes.array, - saveButtonLabel: PropTypes.node, - }; +export class FollowerIndexForm extends PureComponent { + cachedAdvancedSettings: Partial = {}; - constructor(props) { + validateIndexName: DebouncedFunc<(name: string) => Promise>; + + constructor(props: Props) { super(props); const { route: { location: { search }, }, - } = routing.reactRouter; + } = routing.reactRouterOrThrow; const queryParams = extractQueryParams(search); + const rawCluster = queryParams.cluster; + const clusterParam = + typeof rawCluster === 'string' + ? rawCluster + : Array.isArray(rawCluster) + ? rawCluster[0] + : undefined; + + const followerIndexProp = this.props.followerIndex; + const isNew = followerIndexProp === undefined; + const remoteClusterName = getRemoteClusterName(this.props.remoteClusters ?? [], clusterParam); + + let followerIndex: FollowerIndexFormFields; + if (followerIndexProp === undefined) { + followerIndex = getEmptyFollowerIndex(remoteClusterName); + } else { + const { status: _status, ...editableFollowerIndex } = followerIndexProp; + followerIndex = { + ...getEmptyFollowerIndex(), + ...editableFollowerIndex, + }; + } - const isNew = this.props.followerIndex === undefined; - const remoteClusterName = getRemoteClusterName(this.props.remoteClusters, queryParams.cluster); - const followerIndex = isNew - ? getEmptyFollowerIndex(remoteClusterName) - : { - ...getEmptyFollowerIndex(), - ...this.props.followerIndex, - }; - - // eslint-disable-next-line no-nested-ternary - const areAdvancedSettingsVisible = isNew - ? false - : areAdvancedSettingsEdited(followerIndex, documentationLinks) - ? true - : false; + const areAdvancedSettingsVisible = + !isNew && areAdvancedSettingsEdited(followerIndex, documentationLinks); const fieldsErrors = this.getFieldsErrors(followerIndex); @@ -143,8 +195,7 @@ export class FollowerIndexForm extends PureComponent { isRequestVisible: false, }; - this.cachedAdvancedSettings = {}; - this.validateIndexName = debounce(this.validateIndexName, 500, { trailing: true }); + this.validateIndexName = debounce(this.validateIndexNameImpl, 500, { trailing: true }); } toggleRequest = () => { @@ -153,11 +204,11 @@ export class FollowerIndexForm extends PureComponent { })); }; - onFieldsChange = (fields) => { + onFieldsChange = (fields: Partial) => { this.setState(updateFields(fields)); const newFields = { - ...this.state.fields, + ...this.state.followerIndex, ...fields, }; @@ -168,22 +219,23 @@ export class FollowerIndexForm extends PureComponent { } }; - getFieldsErrors = (newFields) => { - return Object.keys(newFields).reduce((errors, field) => { - const advancedSettings = getAdvancedSettingsFields(documentationLinks); - const validator = getFieldToValidatorMap(advancedSettings)[field]; - const value = newFields[field]; + getFieldsErrors = (newFields: Partial) => { + const advancedSettings = getAdvancedSettingsFields(documentationLinks); + const validatorMap = getFieldToValidatorMap(advancedSettings); + const errors: Record = {}; + for (const [field, value] of Object.entries(newFields)) { + const validator = validatorMap[field]; if (validator) { - const error = validator(value); - errors[field] = error; + errors[field] = validator(value); } + } - return errors; - }, {}); + return errors; }; - onIndexNameChange = ({ name }) => { + onIndexNameChange = (value: string | number) => { + const name = String(value); this.onFieldsChange({ name }); const error = indexNameValidator(name); @@ -208,10 +260,10 @@ export class FollowerIndexForm extends PureComponent { this.validateIndexName(name); }; - validateIndexName = async (name) => { + validateIndexNameImpl = async (name: string) => { try { - const indices = await loadIndices(); - const doesExist = indices.some((index) => index.name === name); + const loadedIndices = await loadIndices(); + const doesExist = loadedIndices.some((index) => index.name === name); if (doesExist) { const error = { message: ( @@ -230,25 +282,25 @@ export class FollowerIndexForm extends PureComponent { isValidatingIndexName: false, }); } catch (error) { - if (error) { - if (error.name === 'AbortError') { - // Ignore aborted requests - return; - } - // This could be an HTTP error - if (error.body) { - // All validation does is check for a name collision, so we can just let the user attempt - // to save the follower index and get an error back from the API. - return this.setState({ - isValidatingIndexName: false, - }); - } + const apiError = toCcrApiError(error); + if (apiError.name === 'AbortError') { + // Ignore aborted requests + return; + } + + // This could be an HTTP error. + if (isHttpFetchError(apiError)) { + // All validation does is check for a name collision, so we can just let the user attempt + // to save the follower index and get an error back from the API. + return this.setState({ + isValidatingIndexName: false, + }); } // This error isn't an HTTP error, so let the fatal error screen tell the user something // unexpected happened. getFatalErrors().add( - error, + apiError, i18n.translate( 'xpack.crossClusterReplication.followerIndexForm.indexNameValidationFatalErrorTitle', { @@ -259,7 +311,7 @@ export class FollowerIndexForm extends PureComponent { } }; - onClusterChange = (remoteCluster) => { + onClusterChange = (remoteCluster: string) => { this.onFieldsChange({ remoteCluster }); }; @@ -267,7 +319,7 @@ export class FollowerIndexForm extends PureComponent { return this.state.followerIndex; }; - toggleAdvancedSettings = (event) => { + toggleAdvancedSettings = (event: EuiSwitchEvent) => { // If the user edits the advanced settings but then hides them, we need to make sure the // edited values don't get sent to the API when the user saves, but we *do* want to restore // these values to the form when the user re-opens the advanced settings. @@ -289,16 +341,15 @@ export class FollowerIndexForm extends PureComponent { // Save a cache of the advanced settings. const fields = this.getFields(); - this.cachedAdvancedSettings = getAdvancedSettingsFields(documentationLinks).reduce( - (cache, { field }) => { - const value = fields[field]; - if (value !== '') { - cache[field] = value; - } - return cache; - }, - {} - ); + this.cachedAdvancedSettings = getAdvancedSettingsFields(documentationLinks).reduce< + Partial + >((cache, { field }) => { + const value = fields[field]; + if (value !== '') { + Object.assign(cache, { [field]: value }); + } + return cache; + }, {}); // Hide the advanced settings. this.setState({ @@ -321,9 +372,8 @@ export class FollowerIndexForm extends PureComponent { return; } - const { name, ...followerIndex } = this.getFields(); - - this.props.saveFollowerIndex(name, followerIndex); + const { name, ...saveBody } = this.getFields(); + this.props.saveFollowerIndex(name, saveBody); }; cancelForm = () => { @@ -336,38 +386,36 @@ export class FollowerIndexForm extends PureComponent { renderApiErrors() { const { apiError } = this.props; - if (apiError) { - const title = i18n.translate( - 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', - { - defaultMessage: `Can't create follower index`, - } - ); - const { leaderIndex } = this.state.followerIndex; - const error = - apiError.status === 404 - ? { - data: { - message: i18n.translate( - 'xpack.crossClusterReplication.followerIndexForm.leaderIndexNotFoundError', - { - defaultMessage: `The leader index ''{leaderIndex}'' does not exist.`, - values: { leaderIndex }, - } - ), - }, - } - : apiError; - - return ( - - - - - ); + if (!apiError) { + return null; } - return null; + const title = i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.savingErrorTitle', + { + defaultMessage: `Can't create follower index`, + } + ); + const { leaderIndex } = this.state.followerIndex; + const error = + getErrorStatus(apiError) === 404 + ? { + message: i18n.translate( + 'xpack.crossClusterReplication.followerIndexForm.leaderIndexNotFoundError', + { + defaultMessage: `The leader index ''{leaderIndex}'' does not exist.`, + values: { leaderIndex }, + } + ), + } + : apiError; + + return ( + + + + + ); } renderForm = () => { @@ -450,7 +498,7 @@ export class FollowerIndexForm extends PureComponent { defaultMessage="Replication requires a leader index on a remote cluster." /> ), - remoteClusterNotConnectedNotEditable: (name) => ({ + remoteClusterNotConnectedNotEditable: (name: string) => ({ title: ( ), }), - remoteClusterDoesNotExist: (name) => ( + remoteClusterDoesNotExist: (name: string) => ( ); @@ -652,7 +700,7 @@ export class FollowerIndexForm extends PureComponent { helpText={helpText} type={type} areErrorsVisible={areErrorsVisible} - onValueUpdate={this.onFieldsChange} + onValueUpdate={(value) => this.onFieldsChange({ [field]: value })} testSubj={testSubject} /> ); @@ -668,7 +716,6 @@ export class FollowerIndexForm extends PureComponent { * Form Error warning message */ const renderFormErrorWarning = () => { - const { areErrorsVisible } = this.state; const isFormValid = this.isFormValid(); if (!areErrorsVisible || isFormValid) { @@ -700,7 +747,7 @@ export class FollowerIndexForm extends PureComponent { */ const renderActions = () => { const { apiStatus, saveButtonLabel } = this.props; - const { areErrorsVisible, isRequestVisible } = this.state; + const { isRequestVisible } = this.state; if (apiStatus === API_STATUS.SAVING) { return ( @@ -804,6 +851,7 @@ export class FollowerIndexForm extends PureComponent { render() { const { followerIndex, isRequestVisible } = this.state; + const { name, ...requestPayload } = this.getFields(); return ( @@ -812,8 +860,8 @@ export class FollowerIndexForm extends PureComponent { {isRequestVisible ? ( this.setState({ isRequestVisible: false })} /> ) : null} diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.tsx similarity index 89% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.tsx index 462ec9668d424..9776f191b4aa0 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/follower_index_request_flyout.tsx @@ -7,7 +7,6 @@ import React, { PureComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import PropTypes from 'prop-types'; import { EuiButtonEmpty, @@ -23,14 +22,15 @@ import { } from '@elastic/eui'; import { serializeFollowerIndex } from '../../../../common/services/follower_index_serialization'; +import type { FollowerIndexSaveBody } from '../../services/api'; -export class FollowerIndexRequestFlyout extends PureComponent { - static propTypes = { - close: PropTypes.func.isRequired, - name: PropTypes.string.isRequired, - followerIndex: PropTypes.object.isRequired, - }; +interface Props { + close: () => void; + name: string; + followerIndex: FollowerIndexSaveBody; +} +export class FollowerIndexRequestFlyout extends PureComponent { render() { const { name, followerIndex, close } = this.props; const endpoint = `PUT /${name ? name : ''}/_ccr/follow`; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_form/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_page_title.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_page_title.tsx similarity index 84% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_page_title.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_page_title.tsx index b4b8dbf3241a8..a016853f73865 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_page_title.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/follower_index_page_title.tsx @@ -5,15 +5,18 @@ * 2.0. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { type ReactNode } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiPageHeader, EuiButtonEmpty } from '@elastic/eui'; import { documentationLinks } from '../services/documentation_links'; -export const FollowerIndexPageTitle = ({ title }) => ( +interface Props { + title: ReactNode; +} + +export const FollowerIndexPageTitle = ({ title }: Props) => ( <> ( ); - -FollowerIndexPageTitle.propTypes = { - title: PropTypes.node.isRequired, -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/form_entry_row.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/form_entry_row.tsx similarity index 61% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/form_entry_row.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/form_entry_row.tsx index a544d4ed2b2e3..290b83ec08c49 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/form_entry_row.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/form_entry_row.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import React, { PureComponent, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, Fragment, type ReactElement, type ReactNode } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { @@ -21,46 +20,57 @@ import { * State transitions: fields update */ export const updateFields = - (newValues) => - ({ fields }) => ({ + >(newValues: Partial) => + ({ fields }: { fields: T }) => ({ fields: { ...fields, ...newValues, }, }); -export class FormEntryRow extends PureComponent { - static propTypes = { - title: PropTypes.node, - description: PropTypes.node, - label: PropTypes.node, - helpText: PropTypes.node, - type: PropTypes.string, - onValueUpdate: PropTypes.func.isRequired, - field: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - isLoading: PropTypes.bool, - error: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), - disabled: PropTypes.bool, - areErrorsVisible: PropTypes.bool.isRequired, - testSubj: PropTypes.string, - }; +interface StructuredError { + message?: ReactNode; + alwaysVisible?: boolean; +} + +type FormEntryError = ReactElement[] | StructuredError | undefined | null; + +const isStructuredError = (error: FormEntryError | string | undefined): error is StructuredError => + !!error && typeof error === 'object' && !Array.isArray(error); + +interface Props { + /** EuiDescribedFormGroup expects a React element for the title slot. */ + title: ReactElement; + description?: ReactNode; + label?: ReactNode; + helpText?: ReactNode; + type?: string; + onValueUpdate: (value: string | number) => void; + field: string; + value: string | number | undefined; + defaultValue?: string | number; + isLoading?: boolean; + error?: FormEntryError | string; + disabled?: boolean; + areErrorsVisible: boolean; + testSubj?: string; +} - onFieldChange = (value) => { - const { field, onValueUpdate, type } = this.props; +export class FormEntryRow extends PureComponent { + onFieldChange = (value: string | number) => { + const { onValueUpdate, type } = this.props; const isNumber = type === 'number'; let valueParsed = value; if (isNumber) { - valueParsed = !!value ? Math.max(0, parseInt(value, 10)) : value; // make sure we don't send NaN value or a negative number + valueParsed = !!value ? Math.max(0, parseInt(String(value), 10)) : value; // make sure we don't send NaN value or a negative number } - onValueUpdate({ [field]: valueParsed }); + onValueUpdate(valueParsed); }; - renderField = (isInvalid) => { + renderField = (isInvalid: boolean) => { const { value, type, disabled, isLoading, testSubj } = this.props; switch (type) { case 'number': @@ -103,8 +113,8 @@ export class FormEntryRow extends PureComponent { defaultValue, } = this.props; - const hasError = !!error; - const isInvalid = hasError && (error.alwaysVisible || areErrorsVisible); + const structured = isStructuredError(error) ? error : undefined; + const isInvalid = !!error && ((structured && structured.alwaysVisible) || areErrorsVisible); const canBeResetToDefault = defaultValue !== undefined; const isResetToDefaultVisible = value !== defaultValue; @@ -130,7 +140,7 @@ export class FormEntryRow extends PureComponent { diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/index.d.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/index.d.ts deleted file mode 100644 index 5fe864c8198a7..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export declare const SectionError: any; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_form_field.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_form_field.tsx similarity index 83% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_form_field.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_form_field.tsx index a83e820b3d837..24c67578ce9e8 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_form_field.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_form_field.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, PureComponent } from 'react'; +import React, { Fragment, PureComponent, type ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { @@ -21,6 +21,35 @@ import { import { routing } from '../services/routing'; +export interface RemoteClustersFormFieldErrorMessages { + noClusterFound: () => ReactNode; + remoteClusterNotConnectedEditable?: (name: string) => { + title: ReactNode; + description: ReactNode; + }; + remoteClusterNotConnectedNotEditable?: (name: string) => { + title: ReactNode; + description: ReactNode; + }; + remoteClusterDoesNotExist?: (name: string) => ReactNode; +} + +interface RemoteClusterRow { + name: string; + isConnected: boolean; +} + +interface Props { + selected: string | null; + remoteClusters: RemoteClusterRow[]; + currentUrl: string; + isEditable: boolean; + areErrorsVisible: boolean; + onChange: (clusterName: string) => void; + onError: (error: { message: ReactNode } | null) => void; + errorMessages: RemoteClustersFormFieldErrorMessages; +} + const errorMessages = { noClusterFound: () => ( ), - remoteClusterNotConnectedEditable: (name) => ({ + remoteClusterNotConnectedEditable: (name: string) => ({ title: ( { + errorMessages: RemoteClustersFormFieldErrorMessages = { ...errorMessages, ...this.props.errorMessages, }; @@ -58,7 +87,7 @@ export class RemoteClustersFormField extends PureComponent { onError(error); } - validateRemoteCluster(clusterName) { + validateRemoteCluster(clusterName: string | null) { const { remoteClusters } = this.props; const remoteCluster = remoteClusters.find((c) => c.name === clusterName); @@ -76,7 +105,7 @@ export class RemoteClustersFormField extends PureComponent { }; } - onRemoteClusterChange = (cluster) => { + onRemoteClusterChange = (cluster: string) => { const { onChange, onError } = this.props; const { error } = this.validateRemoteCluster(cluster); onChange(cluster); @@ -90,7 +119,7 @@ export class RemoteClustersFormField extends PureComponent { return ( { this.onRemoteClusterChange(e.target.value); }} @@ -217,14 +246,18 @@ export class RemoteClustersFormField extends PureComponent { ); }; - renderCurrentRemoteClusterNotConnected = (name, fatal) => { + renderCurrentRemoteClusterNotConnected = (name: string, fatal?: boolean) => { const { isEditable, currentUrl } = this.props; const { remoteClusterNotConnectedEditable, remoteClusterNotConnectedNotEditable } = this.errorMessages; - const { title, description } = isEditable - ? remoteClusterNotConnectedEditable(name) - : remoteClusterNotConnectedNotEditable(name); + const resolver = isEditable + ? remoteClusterNotConnectedEditable + : remoteClusterNotConnectedNotEditable; + if (!resolver) { + return null; + } + const { title, description } = resolver(name); return ( { + renderRemoteClusterDoesNotExist = (name: string) => { const { currentUrl } = this.props; const title = i18n.translate( 'xpack.crossClusterReplication.remoteClustersFormField.remoteClusterNotFoundTitle', @@ -265,7 +298,7 @@ export class RemoteClustersFormField extends PureComponent { return ( -

{this.errorMessages.remoteClusterDoesNotExist(name)}

+

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

- {field} + <>{field}
); } diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_provider.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_provider.ts similarity index 54% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_provider.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_provider.ts index c8993441f2672..575b9370db793 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_provider.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/remote_clusters_provider.ts @@ -5,11 +5,27 @@ * 2.0. */ -import { PureComponent } from 'react'; // eslint-disable-line no-unused-vars -import { loadRemoteClusters } from '../services/api'; +import { PureComponent, type ReactNode } from 'react'; +import { loadRemoteClusters, type RemoteClusterRow } from '../services/api'; +import type { CcrApiError } from '../services/http_error'; +import { toCcrApiError } from '../services/http_error'; -export class RemoteClustersProvider extends PureComponent { - state = { +interface State { + isLoading: boolean; + error: CcrApiError | null; + remoteClusters: RemoteClusterRow[]; +} + +interface Props { + children: (params: { + isLoading: boolean; + error: CcrApiError | null; + remoteClusters: RemoteClusterRow[]; + }) => ReactNode; +} + +export class RemoteClustersProvider extends PureComponent { + state: State = { isLoading: true, error: null, remoteClusters: [], @@ -20,8 +36,8 @@ export class RemoteClustersProvider extends PureComponent { } loadRemoteClusters() { - const sortClusterByName = (remoteClusters) => - remoteClusters.sort((a, b) => { + const sortClusterByName = (remoteClusters: RemoteClusterRow[]) => + remoteClusters.sort((a: RemoteClusterRow, b: RemoteClusterRow) => { if (a.name < b.name) { return -1; } @@ -31,7 +47,7 @@ export class RemoteClustersProvider extends PureComponent { return 0; }); loadRemoteClusters() - .then(sortClusterByName) + .then((clusters) => sortClusterByName(clusters)) .then((remoteClusters) => { this.setState({ isLoading: false, @@ -41,7 +57,7 @@ export class RemoteClustersProvider extends PureComponent { .catch((error) => { this.setState({ isLoading: false, - error, + error: toCcrApiError(error), }); }); } diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_error.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_error.js deleted file mode 100644 index e646a6fc082c9..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_error.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; - -export function SectionError(props) { - const { title, error, ...rest } = props; - const data = error.body ? error.body : error; - const { error: errorString, attributes, message } = data; - - return ( - -
{message || errorString}
- {attributes?.error?.root_cause && ( - - -
    - {attributes.error.root_cause.map(({ type, reason }, i) => ( -
  • - {type}: {reason} -
  • - ))} -
-
- )} -
- ); -} diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_error.tsx b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_error.tsx new file mode 100644 index 0000000000000..368d185b28524 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_error.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, type ReactNode } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import type { CcrApiError } from '../services/http_error'; +import { getErrorBody, isHttpFetchError } from '../services/http_error'; + +interface Props { + title: ReactNode; + error: CcrApiError | { message?: ReactNode; error?: string }; + 'data-test-subj'?: string; +} + +export function SectionError({ title, error, ...rest }: Props) { + const errorBody = + error instanceof Error && isHttpFetchError(error) ? getErrorBody(error) : undefined; + const message: ReactNode | undefined = errorBody?.message ?? error.message; + const errorString: string | undefined = + error instanceof Error || errorBody ? undefined : error.error; + + const attributes = errorBody?.attributes; + + const getRootCauses = (): Array<{ type: string; reason: string }> | undefined => { + if (attributes == null || typeof attributes !== 'object') { + return; + } + + const attributesError = Reflect.get(attributes, 'error'); + if (attributesError == null || typeof attributesError !== 'object') { + return; + } + + const rootCause = Reflect.get(attributesError, 'root_cause'); + if (!Array.isArray(rootCause)) { + return; + } + + const result: Array<{ type: string; reason: string }> = []; + for (const item of rootCause) { + if (item == null || typeof item !== 'object') { + continue; + } + + const type = Reflect.get(item, 'type'); + const reason = Reflect.get(item, 'reason'); + if (typeof type === 'string' && typeof reason === 'string') { + result.push({ type, reason }); + } + } + + return result.length ? result : undefined; + }; + + const rootCauses = getRootCauses(); + + return ( + +
{message || errorString}
+ {rootCauses && ( + + +
    + {rootCauses.map(({ type, reason }, i) => ( +
  • + {type}: {reason} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_loading.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_loading.tsx similarity index 80% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_loading.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_loading.tsx index 5f4761344f0ef..40bbb5aae1b1d 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_loading.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/components/section_loading.tsx @@ -5,11 +5,16 @@ * 2.0. */ -import React from 'react'; +import React, { type ReactNode } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiText, EuiTextColor } from '@elastic/eui'; -export function SectionLoading({ children, dataTestSubj = '' }) { +interface Props { + children: ReactNode; + dataTestSubj?: string; +} + +export function SectionLoading({ children, dataTestSubj = '' }: Props) { return ( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/constants/sections.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/constants/sections.ts index b335de451d0ab..b2fa75e112042 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/constants/sections.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/constants/sections.ts @@ -10,4 +10,4 @@ export const SECTIONS = { FOLLOWER_INDEX: 'followerIndex', REMOTE_CLUSTER: 'remoteCluster', CCR_STATS: 'ccrStats', -}; +} as const; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.ts similarity index 53% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.ts index be6481be4f943..502dee7624dfe 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.container.ts @@ -6,23 +6,29 @@ */ import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { SECTIONS } from '../../constants'; +import type { AutoFollowPatternCreateConfig } from '../../services/api'; +import type { CcrState } from '../../store'; import { getApiStatus, getApiError } from '../../store/selectors'; -import { saveAutoFollowPattern, clearApiError } from '../../store/actions'; +import { createAutoFollowPattern, clearApiError } from '../../store/actions'; import { AutoFollowPatternAdd as AutoFollowPatternAddView } from './auto_follow_pattern_add'; const scope = SECTIONS.AUTO_FOLLOW_PATTERN; -const mapStateToProps = (state) => ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ apiStatus: getApiStatus(`${scope}-save`)(state), apiError: getApiError(`${scope}-save`)(state), }); -const mapDispatchToProps = (dispatch) => ({ - saveAutoFollowPattern: (id, autoFollowPattern) => - dispatch(saveAutoFollowPattern(id, autoFollowPattern)), - clearApiError: () => dispatch(clearApiError(scope)), +const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + createAutoFollowPattern: (id: string, autoFollowPattern: AutoFollowPatternCreateConfig) => + dispatch(createAutoFollowPattern(id, autoFollowPattern)), + clearApiError: () => dispatch(clearApiError(`${scope}-save`)), }); export const AutoFollowPatternAdd = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.tsx similarity index 77% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.tsx index a6ab123786c8e..8df230dbb900f 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_add/auto_follow_pattern_add.tsx @@ -6,11 +6,14 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import type { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPageSection } from '@elastic/eui'; +import type { ApiStatus } from '../../../../common/types'; import { listBreadcrumb, addBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; +import type { AutoFollowPatternCreateConfig } from '../../services/api'; +import type { CcrApiError } from '../../services/http_error'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, @@ -18,14 +21,14 @@ import { } from '../../components'; import { SectionLoading } from '../../../shared_imports'; -export class AutoFollowPatternAdd extends PureComponent { - static propTypes = { - saveAutoFollowPattern: PropTypes.func.isRequired, - clearApiError: PropTypes.func.isRequired, - apiError: PropTypes.object, - apiStatus: PropTypes.string.isRequired, - }; +export interface AutoFollowPatternAddProps extends RouteComponentProps { + createAutoFollowPattern: (id: string, autoFollowPattern: AutoFollowPatternCreateConfig) => void; + clearApiError: () => void; + apiError: CcrApiError | null; + apiStatus: ApiStatus; +} +export class AutoFollowPatternAdd extends PureComponent { componentDidMount() { setBreadcrumbs([listBreadcrumb('/auto_follow_patterns'), addBreadcrumb]); } @@ -36,7 +39,7 @@ export class AutoFollowPatternAdd extends PureComponent { render() { const { - saveAutoFollowPattern, + createAutoFollowPattern, apiStatus, apiError, match: { url: currentUrl }, @@ -72,7 +75,7 @@ export class AutoFollowPatternAdd extends PureComponent { apiError={apiError} currentUrl={currentUrl} remoteClusters={error ? [] : remoteClusters} - saveAutoFollowPattern={saveAutoFollowPattern} + createAutoFollowPattern={createAutoFollowPattern} saveButtonLabel={ ({ + getAutoFollowPattern: jest.fn(() => ({ type: 'MOCK/GET_AUTO_FOLLOW_PATTERN' })), + updateAutoFollowPattern: jest.fn(() => ({ type: 'MOCK/UPDATE_AUTO_FOLLOW_PATTERN' })), + selectEditAutoFollowPattern: jest.fn(() => ({ type: 'MOCK/SELECT_EDIT_AUTO_FOLLOW_PATTERN' })), + clearApiError: jest.fn((scope: string) => ({ type: 'MOCK/CLEAR_API_ERROR', payload: scope })), +})); + +import { mapDispatchToProps } from './auto_follow_pattern_edit.container'; +import { + updateAutoFollowPattern, + clearApiError, + selectEditAutoFollowPattern, + getAutoFollowPattern, +} from '../../store/actions'; + +const mockedUpdateAutoFollowPattern = jest.mocked(updateAutoFollowPattern); +const mockedClearApiError = jest.mocked(clearApiError); +const mockedSelectEditAutoFollowPattern = jest.mocked(selectEditAutoFollowPattern); +const mockedGetAutoFollowPattern = jest.mocked(getAutoFollowPattern); + +describe('auto_follow_pattern_edit.container mapDispatchToProps', () => { + const dispatch = jest.fn(); + + beforeEach(() => { + dispatch.mockClear(); + mockedUpdateAutoFollowPattern.mockClear(); + mockedClearApiError.mockClear(); + mockedSelectEditAutoFollowPattern.mockClear(); + mockedGetAutoFollowPattern.mockClear(); + }); + + describe('updateAutoFollowPattern', () => { + it('should strip unknown fields from the update payload (defense-in-depth against strict backend schema)', () => { + const actions = mapDispatchToProps(dispatch); + + // Upstream sources of the pattern object (selectors, reducers, caller + // code) may include extra fields such as `name` or `errors`. The backend + // update route rejects unknown fields via + // `schema.object({...}).unknowns: 'forbid'` (default), so the container + // must not forward anything outside the documented contract. + const enrichedPayload = { + active: true, + remoteCluster: 'cluster-a', + leaderIndexPatterns: ['logs-*'], + followIndexPattern: 'replica-{{leader_index}}', + name: 'should-not-leak', + errors: ['should-not-leak'], + unexpected: 42, + } as unknown as Parameters< + ReturnType['updateAutoFollowPattern'] + >[1]; + + actions.updateAutoFollowPattern('my-pattern', enrichedPayload); + + expect(mockedUpdateAutoFollowPattern).toHaveBeenCalledTimes(1); + expect(mockedUpdateAutoFollowPattern).toHaveBeenCalledWith('my-pattern', { + active: true, + remoteCluster: 'cluster-a', + leaderIndexPatterns: ['logs-*'], + followIndexPattern: 'replica-{{leader_index}}', + }); + }); + }); + + describe('clearApiError', () => { + it('should clear both the -get and -save scopes', () => { + const actions = mapDispatchToProps(dispatch); + actions.clearApiError(); + + const calledScopes = mockedClearApiError.mock.calls.map(([scope]) => scope); + expect(calledScopes).toEqual( + expect.arrayContaining(['autoFollowPattern-get', 'autoFollowPattern-save']) + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.ts similarity index 54% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.ts index eb22e6cc8b7c4..b2868823d8e36 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.container.ts @@ -6,8 +6,12 @@ */ import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { SECTIONS } from '../../constants'; +import type { AutoFollowPatternConfig } from '../../services/api'; +import type { CcrState } from '../../store'; import { getApiStatus, getApiError, @@ -16,7 +20,7 @@ import { } from '../../store/selectors'; import { getAutoFollowPattern, - saveAutoFollowPattern, + updateAutoFollowPattern, selectEditAutoFollowPattern, clearApiError, } from '../../store/actions'; @@ -24,7 +28,9 @@ import { AutoFollowPatternEdit as AutoFollowPatternEditView } from './auto_follo const scope = SECTIONS.AUTO_FOLLOW_PATTERN; -const mapStateToProps = (state) => ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ apiStatus: { get: getApiStatus(`${scope}-get`)(state), save: getApiStatus(`${scope}-save`)(state), @@ -37,25 +43,23 @@ const mapStateToProps = (state) => ({ autoFollowPattern: getSelectedAutoFollowPattern('edit')(state), }); -const mapDispatchToProps = (dispatch) => ({ - getAutoFollowPattern: (id) => dispatch(getAutoFollowPattern(id)), - selectAutoFollowPattern: (id) => dispatch(selectEditAutoFollowPattern(id)), - saveAutoFollowPattern: (id, autoFollowPattern) => { - // Strip out errors. +export const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + getAutoFollowPattern: (id: string) => dispatch(getAutoFollowPattern(id)), + selectAutoFollowPattern: (id: string | null) => dispatch(selectEditAutoFollowPattern(id)), + updateAutoFollowPattern: (id: string, autoFollowPattern: AutoFollowPatternConfig) => { + // Only forward the fields accepted by the update route's body schema + // (`schema.object({...}).unknowns: 'forbid'` by default). Upstream sources + // of `autoFollowPattern` (selectors, reducers) may include extra fields + // such as `name` or `errors`; forwarding those would produce a 400 at the + // server boundary. const { active, remoteCluster, leaderIndexPatterns, followIndexPattern } = autoFollowPattern; - - dispatch( - saveAutoFollowPattern( - id, - { - active, - remoteCluster, - leaderIndexPatterns, - followIndexPattern, - }, - true - ) - ); + const updatePayload: AutoFollowPatternConfig = { + active, + remoteCluster, + leaderIndexPatterns, + followIndexPattern, + }; + return dispatch(updateAutoFollowPattern(id, updatePayload)); }, clearApiError: () => { dispatch(clearApiError(`${scope}-get`)); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.tsx similarity index 63% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.tsx index 6c381484891ea..9a899cb48a59d 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/auto_follow_pattern_edit/auto_follow_pattern_edit.tsx @@ -6,42 +6,58 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import type { ReactNode } from 'react'; +import type { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiPageSection, EuiPageTemplate } from '@elastic/eui'; -import { listBreadcrumb, editBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import type { ApiStatus } from '../../../../common/types'; +import type { AutoFollowPatternWithErrors } from '../../store/selectors'; +import { listBreadcrumb, editBreadcrumb, setBreadcrumbs } from '../../services/breadcrumbs'; +import type { AutoFollowPatternConfig } from '../../services/api'; import { AutoFollowPatternForm, AutoFollowPatternPageTitle, RemoteClustersProvider, } from '../../components'; +import type { CcrApiError } from '../../services/http_error'; +import { getErrorBody, getErrorStatus } from '../../services/http_error'; import { API_STATUS } from '../../constants'; import { SectionLoading } from '../../../shared_imports'; -export class AutoFollowPatternEdit extends PureComponent { - static propTypes = { - getAutoFollowPattern: PropTypes.func.isRequired, - selectAutoFollowPattern: PropTypes.func.isRequired, - saveAutoFollowPattern: PropTypes.func.isRequired, - clearApiError: PropTypes.func.isRequired, - apiError: PropTypes.object.isRequired, - apiStatus: PropTypes.object.isRequired, - autoFollowPattern: PropTypes.object, - autoFollowPatternId: PropTypes.string, - }; - - static getDerivedStateFromProps({ autoFollowPatternId }, { lastAutoFollowPatternId }) { +export interface AutoFollowPatternEditProps extends RouteComponentProps<{ id: string }> { + getAutoFollowPattern: (id: string) => void; + selectAutoFollowPattern: (id: string | null) => void; + updateAutoFollowPattern: (id: string, autoFollowPattern: AutoFollowPatternConfig) => void; + clearApiError: () => void; + apiError: { get: CcrApiError | null; save: CcrApiError | null }; + apiStatus: { get: ApiStatus; save: ApiStatus }; + autoFollowPattern: AutoFollowPatternWithErrors | null; + autoFollowPatternId: string | null; +} + +export interface AutoFollowPatternEditState { + lastAutoFollowPatternId: string | undefined; +} + +export class AutoFollowPatternEdit extends PureComponent< + AutoFollowPatternEditProps, + AutoFollowPatternEditState +> { + static getDerivedStateFromProps( + { autoFollowPatternId }: Pick, + { lastAutoFollowPatternId }: AutoFollowPatternEditState + ): Partial | null { if (lastAutoFollowPatternId !== autoFollowPatternId) { - return { lastAutoFollowPatternId: autoFollowPatternId }; + return { lastAutoFollowPatternId: autoFollowPatternId ?? undefined }; } return null; } - state = { lastAutoFollowPatternId: undefined }; + state: AutoFollowPatternEditState = { lastAutoFollowPatternId: undefined }; componentDidMount() { const { @@ -57,14 +73,17 @@ export class AutoFollowPatternEdit extends PureComponent { setBreadcrumbs([listBreadcrumb('/auto_follow_patterns'), editBreadcrumb]); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: AutoFollowPatternEditProps, prevState: AutoFollowPatternEditState) { const { autoFollowPattern, getAutoFollowPattern } = this.props; // Fetch the auto-follow pattern on the server if we don't have it (i.e. page reload) if ( !autoFollowPattern && prevState.lastAutoFollowPatternId !== this.state.lastAutoFollowPatternId ) { - getAutoFollowPattern(this.state.lastAutoFollowPatternId); + const id = this.state.lastAutoFollowPatternId; + if (id !== undefined) { + getAutoFollowPattern(id); + } } } @@ -72,25 +91,27 @@ export class AutoFollowPatternEdit extends PureComponent { this.props.clearApiError(); } - renderGetAutoFollowPatternError(error) { + renderGetAutoFollowPatternError(error: CcrApiError) { const { match: { params: { id: name }, }, } = this.props; - const errorMessage = - error.body.statusCode === 404 - ? { - error: i18n.translate( - 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorMessage', - { - defaultMessage: `The auto-follow pattern ''{name}'' does not exist.`, - values: { name }, - } - ), - } - : error; + const statusCode = getErrorStatus(error); + const body = getErrorBody(error); + const errorMessage: ReactNode = + statusCode === 404 + ? i18n.translate( + 'xpack.crossClusterReplication.autoFollowPatternEditForm.loadingErrorMessage', + { + defaultMessage: `The auto-follow pattern ''{name}'' does not exist.`, + values: { name }, + } + ) + : body?.message ?? error.message; + + const listNav = reactRouterNavigate(this.props.history, `/auto_follow_patterns`); return ( {errorMessage}

} actions={ @@ -123,13 +144,13 @@ export class AutoFollowPatternEdit extends PureComponent { ); } - renderLoading(loadingTitle) { + renderLoading(loadingTitle: ReactNode) { return {loadingTitle}; } render() { const { - saveAutoFollowPattern, + updateAutoFollowPattern, apiStatus, apiError, autoFollowPattern, @@ -177,7 +198,7 @@ export class AutoFollowPatternEdit extends PureComponent { currentUrl={currentUrl} remoteClusters={error ? [] : remoteClusters} autoFollowPattern={autoFollowPattern} - saveAutoFollowPattern={saveAutoFollowPattern} + updateAutoFollowPattern={updateAutoFollowPattern} saveButtonLabel={ ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ apiStatus: getApiStatus(`${scope}-save`)(state), apiError: getApiError(`${scope}-save`)(state), }); -const mapDispatchToProps = (dispatch) => ({ - saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex)), +const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + saveFollowerIndex: (id: string, followerIndex: FollowerIndexSaveBody) => + dispatch(saveFollowerIndex(id, followerIndex)), clearApiError: () => dispatch(clearApiError(`${scope}-save`)), }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.tsx similarity index 81% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.tsx index 91e262fa84818..9ff98e9257b72 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/follower_index_add.tsx @@ -6,11 +6,14 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import type { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiPageSection } from '@elastic/eui'; +import type { ApiStatus } from '../../../../common/types'; import { setBreadcrumbs, listBreadcrumb, addBreadcrumb } from '../../services/breadcrumbs'; +import type { FollowerIndexSaveBody } from '../../services/api'; +import type { CcrApiError } from '../../services/http_error'; import { FollowerIndexForm, FollowerIndexPageTitle, @@ -18,14 +21,14 @@ import { } from '../../components'; import { SectionLoading } from '../../../shared_imports'; -export class FollowerIndexAdd extends PureComponent { - static propTypes = { - saveFollowerIndex: PropTypes.func.isRequired, - clearApiError: PropTypes.func.isRequired, - apiError: PropTypes.object, - apiStatus: PropTypes.string.isRequired, - }; +export interface FollowerIndexAddProps extends RouteComponentProps { + saveFollowerIndex: (name: string, followerIndex: FollowerIndexSaveBody) => void | Promise; + clearApiError: () => void; + apiError: CcrApiError | null; + apiStatus: ApiStatus; +} +export class FollowerIndexAdd extends PureComponent { componentDidMount() { setBreadcrumbs([listBreadcrumb('/follower_indices'), addBreadcrumb]); } diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_add/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.ts similarity index 66% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.ts index 2f4d6eb489acd..a17c3b37a2a89 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.container.ts @@ -6,8 +6,12 @@ */ import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { SECTIONS } from '../../constants'; +import type { FollowerIndexSaveBody } from '../../services/api'; +import type { CcrState } from '../../store'; import { getApiStatus, getApiError, @@ -24,7 +28,9 @@ import { FollowerIndexEdit as FollowerIndexEditView } from './follower_index_edi const scope = SECTIONS.FOLLOWER_INDEX; -const mapStateToProps = (state) => ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ apiStatus: { get: getApiStatus(`${scope}-get`)(state), save: getApiStatus(`${scope}-save`)(state), @@ -37,10 +43,11 @@ const mapStateToProps = (state) => ({ followerIndex: getSelectedFollowerIndex('edit')(state), }); -const mapDispatchToProps = (dispatch) => ({ - getFollowerIndex: (id) => dispatch(getFollowerIndex(id)), - selectFollowerIndex: (id) => dispatch(selectEditFollowerIndex(id)), - saveFollowerIndex: (id, followerIndex) => dispatch(saveFollowerIndex(id, followerIndex, true)), +const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + getFollowerIndex: (id: string) => dispatch(getFollowerIndex(id)), + selectFollowerIndex: (id: string | null) => dispatch(selectEditFollowerIndex(id)), + saveFollowerIndex: (id: string, followerIndex: FollowerIndexSaveBody) => + dispatch(saveFollowerIndex(id, followerIndex, true)), clearApiError: () => { dispatch(clearApiError(`${scope}-get`)); dispatch(clearApiError(`${scope}-save`)); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.tsx similarity index 71% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.tsx index ee0cf671d9390..0f4fb602bb8fd 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.tsx @@ -6,7 +6,8 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import type { ReactNode } from 'react'; +import type { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -18,8 +19,12 @@ import { htmlIdGenerator, } from '@elastic/eui'; -import { setBreadcrumbs, listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import type { ApiStatus, FollowerIndexWithPausedStatus } from '../../../../common/types'; +import { setBreadcrumbs, listBreadcrumb, editBreadcrumb } from '../../services/breadcrumbs'; +import type { CcrApiError } from '../../services/http_error'; +import { getErrorBody, getErrorStatus } from '../../services/http_error'; +import type { FollowerIndexSaveBody } from '../../services/api'; import { FollowerIndexForm, FollowerIndexPageTitle, @@ -28,26 +33,39 @@ import { import { API_STATUS } from '../../constants'; import { SectionLoading } from '../../../shared_imports'; -export class FollowerIndexEdit extends PureComponent { - static propTypes = { - getFollowerIndex: PropTypes.func.isRequired, - selectFollowerIndex: PropTypes.func.isRequired, - saveFollowerIndex: PropTypes.func.isRequired, - clearApiError: PropTypes.func.isRequired, - apiError: PropTypes.object.isRequired, - apiStatus: PropTypes.object.isRequired, - followerIndex: PropTypes.object, - followerIndexId: PropTypes.string, - }; +export interface FollowerIndexEditProps extends RouteComponentProps<{ id: string }> { + getFollowerIndex: (id: string) => void; + selectFollowerIndex: (id: string | null) => void; + saveFollowerIndex: (name: string, followerIndex: FollowerIndexSaveBody) => void; + clearApiError: () => void; + apiError: { get: CcrApiError | null; save: CcrApiError | null }; + apiStatus: { get: ApiStatus; save: ApiStatus }; + followerIndex: FollowerIndexWithPausedStatus | null; + followerIndexId: string | null; +} + +export interface FollowerIndexEditState { + lastFollowerIndexId: string | undefined; + showConfirmModal: boolean; +} + +export class FollowerIndexEdit extends PureComponent< + FollowerIndexEditProps, + FollowerIndexEditState +> { + private editedFollowerIndexPayload?: { name: string; followerIndex: FollowerIndexSaveBody }; - static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) { + static getDerivedStateFromProps( + { followerIndexId }: Pick, + { lastFollowerIndexId }: FollowerIndexEditState + ): Partial | null { if (lastFollowerIndexId !== followerIndexId) { - return { lastFollowerIndexId: followerIndexId }; + return { lastFollowerIndexId: followerIndexId ?? undefined }; } return null; } - state = { + state: FollowerIndexEditState = { lastFollowerIndexId: undefined, showConfirmModal: false, }; @@ -59,7 +77,7 @@ export class FollowerIndexEdit extends PureComponent { }, selectFollowerIndex, } = this.props; - let decodedId; + let decodedId: string; try { // When we navigate through the router (history.push) we need to decode both the uri and the id decodedId = decodeURI(id); @@ -75,11 +93,14 @@ export class FollowerIndexEdit extends PureComponent { setBreadcrumbs([listBreadcrumb('/follower_indices'), editBreadcrumb]); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: FollowerIndexEditProps, prevState: FollowerIndexEditState) { const { followerIndex, getFollowerIndex } = this.props; // Fetch the follower index on the server if we don't have it (i.e. page reload) if (!followerIndex && prevState.lastFollowerIndexId !== this.state.lastFollowerIndexId) { - getFollowerIndex(this.state.lastFollowerIndexId); + const id = this.state.lastFollowerIndexId; + if (id !== undefined) { + getFollowerIndex(id); + } } } @@ -87,13 +108,17 @@ export class FollowerIndexEdit extends PureComponent { this.props.clearApiError(); } - saveFollowerIndex = (name, followerIndex) => { + saveFollowerIndex = (name: string, followerIndex: FollowerIndexSaveBody) => { this.editedFollowerIndexPayload = { name, followerIndex }; this.showConfirmModal(); }; confirmSaveFollowerIhdex = () => { - const { name, followerIndex } = this.editedFollowerIndexPayload; + const payload = this.editedFollowerIndexPayload; + if (!payload) { + return; + } + const { name, followerIndex } = payload; this.props.saveFollowerIndex(name, followerIndex); this.closeConfirmModal(); }; @@ -102,29 +127,31 @@ export class FollowerIndexEdit extends PureComponent { closeConfirmModal = () => this.setState({ showConfirmModal: false }); - renderLoading(loadingTitle) { + renderLoading(loadingTitle: ReactNode) { return {loadingTitle}; } - renderGetFollowerIndexError(error) { + renderGetFollowerIndexError(error: CcrApiError) { const { match: { params: { id: name }, }, } = this.props; - const errorMessage = - error.body.statusCode === 404 - ? { - error: i18n.translate( - 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorMessage', - { - defaultMessage: `The follower index ''{name}'' does not exist.`, - values: { name }, - } - ), - } - : error; + const statusCode = getErrorStatus(error); + const body = getErrorBody(error); + const errorMessage: ReactNode = + statusCode === 404 + ? i18n.translate( + 'xpack.crossClusterReplication.followerIndexEditForm.loadingErrorMessage', + { + defaultMessage: `The follower index ''{name}'' does not exist.`, + values: { name }, + } + ) + : body?.message ?? error.message; + + const listNav = reactRouterNavigate(this.props.history, `/follower_indices`); return ( {errorMessage}

} actions={ { - const { - followerIndexId, - followerIndex: { isPaused }, - } = this.props; + const { followerIndexId, followerIndex } = this.props; + + if (!followerIndex) { + return null; + } + + const { isPaused } = followerIndex; const confirmModalTitleId = htmlIdGenerator()('confirmModalTitle'); const title = i18n.translate( 'xpack.crossClusterReplication.followerIndexEditForm.confirmModal.title', @@ -226,9 +256,6 @@ export class FollowerIndexEdit extends PureComponent { const { showConfirmModal } = this.state; - /* remove non-editable properties */ - const { shards, ...rest } = followerIndex || {}; // eslint-disable-line no-unused-vars - if (apiStatus.get === API_STATUS.LOADING || !followerIndex) { return this.renderLoading( i18n.translate( @@ -242,6 +269,8 @@ export class FollowerIndexEdit extends PureComponent { return this.renderGetFollowerIndexError(apiError.get); } + const { shards: _shards, ...rest } = followerIndex; + return ( {({ isLoading, error, remoteClusters }) => { diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/follower_index_edit/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.ts similarity index 70% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.ts index 78e4ddc0f87e3..5ff701af4a950 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.container.ts @@ -6,8 +6,11 @@ */ import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { SECTIONS } from '../../../constants'; +import type { CcrState } from '../../../store'; import { getListAutoFollowPatterns, getSelectedAutoFollowPatternId, @@ -24,7 +27,9 @@ import { AutoFollowPatternList as AutoFollowPatternListView } from './auto_follo const scope = SECTIONS.AUTO_FOLLOW_PATTERN; -const mapStateToProps = (state) => ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ autoFollowPatterns: getListAutoFollowPatterns(state), autoFollowPatternId: getSelectedAutoFollowPatternId('detail')(state), apiStatus: getApiStatus(scope)(state), @@ -32,9 +37,10 @@ const mapStateToProps = (state) => ({ isAuthorized: isApiAuthorized(scope)(state), }); -const mapDispatchToProps = (dispatch) => ({ - loadAutoFollowPatterns: (inBackground) => dispatch(loadAutoFollowPatterns(inBackground)), - selectAutoFollowPattern: (id) => dispatch(selectDetailAutoFollowPattern(id)), +const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + loadAutoFollowPatterns: (inBackground?: boolean) => + dispatch(loadAutoFollowPatterns(inBackground)), + selectAutoFollowPattern: (id: string | null) => dispatch(selectDetailAutoFollowPattern(id)), loadAutoFollowStats: () => dispatch(loadAutoFollowStats()), }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.tsx similarity index 68% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.tsx index bd4028d6bac0f..6969b9bccc12e 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/auto_follow_pattern_list.tsx @@ -6,7 +6,8 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import type { History } from 'history'; +import type { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiText, EuiSpacer, EuiPageTemplate } from '@elastic/eui'; @@ -14,27 +15,50 @@ import { EuiButton, EuiText, EuiSpacer, EuiPageTemplate } from '@elastic/eui'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { extractQueryParams, PageError, PageLoading } from '../../../../shared_imports'; import { trackUiMetric, METRIC_TYPE } from '../../../services/track_ui_metric'; +import type { CcrApiError } from '../../../services/http_error'; +import { getErrorBody } from '../../../services/http_error'; import { API_STATUS, UIM_AUTO_FOLLOW_PATTERN_LIST_LOAD } from '../../../constants'; +import type { ApiStatus } from '../../../../../common/types'; +import type { ParsedAutoFollowPattern } from '../../../store/reducers/auto_follow_pattern'; import { AutoFollowPatternTable, DetailPanel } from './components'; const REFRESH_RATE_MS = 30000; -const getQueryParamPattern = ({ location: { search } }) => { - const { pattern } = extractQueryParams(search); - return pattern ? decodeURIComponent(pattern) : null; +const getQueryParamPattern = (history: History) => { + const { pattern } = extractQueryParams(history.location.search); + if (!pattern) { + return null; + } + const patternStr = Array.isArray(pattern) ? pattern[0] : pattern; + return decodeURIComponent(String(patternStr)); }; -export class AutoFollowPatternList extends PureComponent { - static propTypes = { - loadAutoFollowPatterns: PropTypes.func, - selectAutoFollowPattern: PropTypes.func, - loadAutoFollowStats: PropTypes.func, - autoFollowPatterns: PropTypes.array, - apiStatus: PropTypes.string, - apiError: PropTypes.object, - }; +export interface AutoFollowPatternListProps extends RouteComponentProps { + loadAutoFollowPatterns: (inBackground?: boolean) => void; + selectAutoFollowPattern: (id: string | null) => void; + loadAutoFollowStats: () => void; + autoFollowPatterns: ParsedAutoFollowPattern[]; + autoFollowPatternId: string | null; + apiStatus: ApiStatus; + apiError: CcrApiError | null; + isAuthorized: boolean; +} + +interface AutoFollowPatternListState { + lastAutoFollowPatternId: string | null; + isDetailPanelOpen: boolean; +} + +export class AutoFollowPatternList extends PureComponent< + AutoFollowPatternListProps, + AutoFollowPatternListState +> { + private interval?: ReturnType; - static getDerivedStateFromProps({ autoFollowPatternId }, { lastAutoFollowPatternId }) { + static getDerivedStateFromProps( + { autoFollowPatternId }: Pick, + { lastAutoFollowPatternId }: AutoFollowPatternListState + ): Partial | null { if (autoFollowPatternId !== lastAutoFollowPatternId) { return { lastAutoFollowPatternId: autoFollowPatternId, @@ -44,7 +68,7 @@ export class AutoFollowPatternList extends PureComponent { return null; } - state = { + state: AutoFollowPatternListState = { lastAutoFollowPatternId: null, isDetailPanelOpen: false, }; @@ -64,7 +88,7 @@ export class AutoFollowPatternList extends PureComponent { this.interval = setInterval(() => loadAutoFollowPatterns(true), REFRESH_RATE_MS); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: AutoFollowPatternListProps, prevState: AutoFollowPatternListState) { const { history, loadAutoFollowStats } = this.props; const { lastAutoFollowPatternId } = this.state; @@ -88,7 +112,9 @@ export class AutoFollowPatternList extends PureComponent { } componentWillUnmount() { - clearInterval(this.interval); + if (this.interval !== undefined) { + clearInterval(this.interval); + } } renderEmpty() { @@ -164,18 +190,18 @@ export class AutoFollowPatternList extends PureComponent { if (!isAuthorized) { return ( - } + title={i18n.translate( + 'xpack.crossClusterReplication.autoFollowPatternList.permissionErrorTitle', + { + defaultMessage: 'Permission error', + } + )} error={{ - error: ( - + error: i18n.translate( + 'xpack.crossClusterReplication.autoFollowPatternList.noPermissionText', + { + defaultMessage: 'You do not have permission to view or add auto-follow patterns.', + } ), }} /> @@ -190,7 +216,9 @@ export class AutoFollowPatternList extends PureComponent { } ); - return ; + const body = getErrorBody(apiError); + const error = { error: body?.message ?? apiError.message, statusCode: body?.statusCode }; + return ; } if (isEmpty) { diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.ts similarity index 61% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.ts index ea15a36ee6860..bc9573ec6d0fb 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.container.ts @@ -6,8 +6,11 @@ */ import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { SECTIONS } from '../../../../../constants'; +import type { CcrState } from '../../../../../store'; import { selectDetailAutoFollowPattern, pauseAutoFollowPattern, @@ -18,14 +21,16 @@ import { AutoFollowPatternTable as AutoFollowPatternTableComponent } from './aut const scope = SECTIONS.AUTO_FOLLOW_PATTERN; -const mapStateToProps = (state) => ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ apiStatusDelete: getApiStatus(`${scope}-delete`)(state), }); -const mapDispatchToProps = (dispatch) => ({ - selectAutoFollowPattern: (name) => dispatch(selectDetailAutoFollowPattern(name)), - pauseAutoFollowPattern: (name) => dispatch(pauseAutoFollowPattern(name)), - resumeAutoFollowPattern: (name) => dispatch(resumeAutoFollowPattern(name)), +const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + selectAutoFollowPattern: (name: string) => dispatch(selectDetailAutoFollowPattern(name)), + pauseAutoFollowPattern: (name: string) => dispatch(pauseAutoFollowPattern(name)), + resumeAutoFollowPattern: (name: string) => dispatch(resumeAutoFollowPattern(name)), }); export const AutoFollowPatternTable = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.tsx similarity index 75% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.tsx index 2dfc17c2c4f95..3823cea036de9 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/auto_follow_pattern_table.tsx @@ -6,10 +6,10 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { EuiInMemoryTableProps, EuiSearchBarOnChangeArgs } from '@elastic/eui'; import { EuiInMemoryTable, EuiButton, @@ -26,6 +26,8 @@ import { } from '../../../../../components'; import { routing } from '../../../../../services/routing'; import { trackUiMetric } from '../../../../../services/track_ui_metric'; +import type { ApiStatus } from '../../../../../../../common/types'; +import type { ParsedAutoFollowPattern } from '../../../../../store/reducers/auto_follow_pattern'; const actionI18nTexts = { pause: i18n.translate( @@ -54,7 +56,10 @@ const actionI18nTexts = { ), }; -const getFilteredPatterns = (autoFollowPatterns, queryText) => { +const getFilteredPatterns = ( + autoFollowPatterns: ParsedAutoFollowPattern[], + queryText: string +): ParsedAutoFollowPattern[] => { if (queryText) { const normalizedSearchText = queryText.toLowerCase(); @@ -79,15 +84,29 @@ const getFilteredPatterns = (autoFollowPatterns, queryText) => { return autoFollowPatterns; }; -export class AutoFollowPatternTable extends PureComponent { - static propTypes = { - autoFollowPatterns: PropTypes.array, - selectAutoFollowPattern: PropTypes.func.isRequired, - pauseAutoFollowPattern: PropTypes.func.isRequired, - resumeAutoFollowPattern: PropTypes.func.isRequired, - }; +export interface AutoFollowPatternTableProps { + autoFollowPatterns: ParsedAutoFollowPattern[]; + selectAutoFollowPattern: (name: string) => void; + pauseAutoFollowPattern: (name: string) => void; + resumeAutoFollowPattern: (name: string) => void; + apiStatusDelete: ApiStatus; +} + +interface AutoFollowPatternTableState { + prevAutoFollowPatterns: ParsedAutoFollowPattern[]; + selectedItems: string[]; + filteredAutoFollowPatterns: ParsedAutoFollowPattern[]; + queryText: string; +} - static getDerivedStateFromProps(props, state) { +export class AutoFollowPatternTable extends PureComponent< + AutoFollowPatternTableProps, + AutoFollowPatternTableState +> { + static getDerivedStateFromProps( + props: AutoFollowPatternTableProps, + state: AutoFollowPatternTableState + ): Partial | null { const { autoFollowPatterns } = props; const { prevAutoFollowPatterns, queryText } = state; @@ -102,7 +121,7 @@ export class AutoFollowPatternTable extends PureComponent { return null; } - constructor(props) { + constructor(props: AutoFollowPatternTableProps) { super(props); this.state = { @@ -113,9 +132,9 @@ export class AutoFollowPatternTable extends PureComponent { }; } - onSearch = ({ query }) => { + onSearch = ({ query, queryText }: EuiSearchBarOnChangeArgs) => { const { autoFollowPatterns } = this.props; - const { text } = query; + const text = query?.text ?? queryText; // We cache the filtered indices instead of calculating them inside render() because // of https://github.com/elastic/eui/issues/3445. @@ -125,7 +144,9 @@ export class AutoFollowPatternTable extends PureComponent { }); }; - getTableColumns(deleteAutoFollowPattern) { + getTableColumns( + deleteAutoFollowPattern: (name: string) => void + ): EuiInMemoryTableProps['columns'] { const { selectAutoFollowPattern } = this.props; return [ @@ -139,7 +160,7 @@ export class AutoFollowPatternTable extends PureComponent { ), sortable: true, truncateText: false, - render: (name) => { + render: (name: string) => { return ( { @@ -162,7 +183,7 @@ export class AutoFollowPatternTable extends PureComponent { defaultMessage: 'Status', } ), - render: (active) => { + render: (active: boolean) => { const statusText = active ? i18n.translate( 'xpack.crossClusterReplication.autoFollowPatternList.table.statusTextActive', @@ -201,7 +222,7 @@ export class AutoFollowPatternTable extends PureComponent { defaultMessage: 'Leader patterns', } ), - render: (leaderIndexPatterns) => leaderIndexPatterns.join(', '), + render: (leaderIndexPatterns: string[]) => leaderIndexPatterns.join(', '), }, { field: 'followIndexPatternPrefix', @@ -232,33 +253,40 @@ export class AutoFollowPatternTable extends PureComponent { ), actions: [ { + type: 'icon', name: actionI18nTexts.pause, description: actionI18nTexts.pause, icon: 'pause', - onClick: (item) => this.props.pauseAutoFollowPattern(item.name), - available: (item) => item.active, + onClick: (item: ParsedAutoFollowPattern) => + this.props.pauseAutoFollowPattern(item.name), + available: (item: ParsedAutoFollowPattern) => item.active, 'data-test-subj': 'contextMenuPauseButton', }, { + type: 'icon', name: actionI18nTexts.resume, description: actionI18nTexts.resume, icon: 'play', - onClick: (item) => this.props.resumeAutoFollowPattern(item.name), - available: (item) => !item.active, + onClick: (item: ParsedAutoFollowPattern) => + this.props.resumeAutoFollowPattern(item.name), + available: (item: ParsedAutoFollowPattern) => !item.active, 'data-test-subj': 'contextMenuResumeButton', }, { + type: 'icon', name: actionI18nTexts.edit, description: actionI18nTexts.edit, icon: 'pencil', - onClick: (item) => routing.navigate(routing.getAutoFollowPatternPath(item.name)), + onClick: (item: ParsedAutoFollowPattern) => + routing.navigate(routing.getAutoFollowPatternPath(item.name)), 'data-test-subj': 'contextMenuEditButton', }, { + type: 'icon', name: actionI18nTexts.delete, description: actionI18nTexts.delete, icon: 'trash', - onClick: (item) => deleteAutoFollowPattern(item.name), + onClick: (item: ParsedAutoFollowPattern) => deleteAutoFollowPattern(item.name), 'data-test-subj': 'contextMenuDeleteButton', }, ], @@ -282,11 +310,12 @@ export class AutoFollowPatternTable extends PureComponent { render() { const { selectedItems, filteredAutoFollowPatterns } = this.state; + const reactRouter = routing.reactRouterOrThrow; const sorting = { sort: { field: 'name', - direction: 'asc', + direction: 'asc' as const, }, }; @@ -296,22 +325,23 @@ export class AutoFollowPatternTable extends PureComponent { }; const selection = { - onSelectionChange: (selectedItems) => - this.setState({ selectedItems: selectedItems.map(({ name }) => name) }), + onSelectionChange: (rows: ParsedAutoFollowPattern[]) => + this.setState({ selectedItems: rows.map(({ name }) => name) }), }; - const search = { + const search: EuiInMemoryTableProps['search'] = { toolsLeft: selectedItems.length ? ( - filteredAutoFollowPatterns.find((item) => item.name === name) - )} + patterns={this.state.selectedItems + .map((name) => filteredAutoFollowPatterns.find((item) => item.name === name)) + .filter((p): p is ParsedAutoFollowPattern => p !== undefined)} /> ) : undefined, toolsRight: ( ({ - 'data-test-subj': `cell_${column.field}`, + 'data-test-subj': `cell_${'field' in column ? column.field : ''}`, })} data-test-subj="autoFollowPatternListTable" tableCaption={i18n.translate( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/auto_follow_pattern_table/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.ts similarity index 89% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.ts index 4b8689a616635..a4bd67f71ceb4 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.container.ts @@ -6,18 +6,19 @@ */ import { connect } from 'react-redux'; -import { DetailPanel as DetailPanelView } from './detail_panel'; +import type { CcrState } from '../../../../../store'; import { getSelectedAutoFollowPattern, getSelectedAutoFollowPatternId, getApiStatus, } from '../../../../../store/selectors'; import { SECTIONS } from '../../../../../constants'; +import { DetailPanel as DetailPanelView } from './detail_panel'; const scope = SECTIONS.AUTO_FOLLOW_PATTERN; -const mapStateToProps = (state) => ({ +const mapStateToProps = (state: CcrState) => ({ autoFollowPatternId: getSelectedAutoFollowPatternId('detail')(state), autoFollowPattern: getSelectedAutoFollowPattern('detail')(state), apiStatus: getApiStatus(scope)(state), diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.tsx similarity index 93% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.tsx index fd374735604a1..af469dd7ca9dd 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/auto_follow_pattern_list/components/detail_panel/detail_panel.tsx @@ -6,7 +6,6 @@ */ import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n-react'; import moment from 'moment'; @@ -38,21 +37,24 @@ import { AutoFollowPatternActionMenu, } from '../../../../../components'; -export class DetailPanel extends Component { - static propTypes = { - apiStatus: PropTypes.string, - autoFollowPatternId: PropTypes.string, - autoFollowPattern: PropTypes.object, - closeDetailPanel: PropTypes.func.isRequired, - }; +import type { ApiStatus } from '../../../../../../../common/types'; +import type { AutoFollowPatternWithErrors } from '../../../../../store/selectors'; +export interface DetailPanelProps { + apiStatus: ApiStatus; + autoFollowPatternId: string | null; + autoFollowPattern: AutoFollowPatternWithErrors | null; + closeDetailPanel: () => void; +} + +export class DetailPanel extends Component { renderAutoFollowPattern({ followIndexPatternPrefix, followIndexPatternSuffix, remoteCluster, leaderIndexPatterns, active, - }) { + }: AutoFollowPatternWithErrors) { return ( <>
@@ -194,7 +196,11 @@ export class DetailPanel extends Component { ); } - renderIndicesPreview(prefix, suffix, leaderIndexPatterns) { + renderIndicesPreview( + prefix: string | undefined, + suffix: string | undefined, + leaderIndexPatterns: string[] + ) { return (
    - {autoFollowPattern.errors.map((error, i) => ( + {pattern.errors.map((error, i) => (
  • {moment(error.timestamp).format('MMMM Do, YYYY h:mm:ss A')}:{' '} {error.autoFollowException.reason} @@ -274,6 +280,8 @@ export class DetailPanel extends Component { return this.renderAutoFollowPatternNotFound(); } + const reactRouter = routing.reactRouterOrThrow; + const { followIndexPatternPrefix, followIndexPatternSuffix, leaderIndexPatterns } = autoFollowPattern; @@ -302,7 +310,7 @@ export class DetailPanel extends Component { { + state: ContextMenuState = { isPopoverOpen: false, }; @@ -49,7 +58,7 @@ export class ContextMenu extends PureComponent { }); }; - editFollowerIndex = (id) => { + editFollowerIndex = (id: string) => { const uri = routing.getFollowerIndexPath(id); routing.navigate(uri); }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/context_menu/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.tsx b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.ts similarity index 67% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.tsx rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.ts index 35545e7501ae3..d993707d7410a 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.tsx +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.container.ts @@ -6,7 +6,10 @@ */ import { connect } from 'react-redux'; -import type { ApiStatus, FollowerIndex } from '../../../../../../../common/types'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; + +import type { ApiStatus, FollowerIndexWithPausedStatus } from '../../../../../../../common/types'; import { DetailPanel as DetailPanelView } from './detail_panel'; import { @@ -16,12 +19,15 @@ import { } from '../../../../../store/selectors'; import { getFollowerIndex } from '../../../../../store/actions'; import { SECTIONS } from '../../../../../constants'; +import type { CcrState } from '../../../../../store'; const scope = SECTIONS.FOLLOWER_INDEX; +type CcrDispatch = ThunkDispatch; + interface StateProps { - followerIndexId: string; - followerIndex: FollowerIndex; + followerIndexId: string | null; + followerIndex: FollowerIndexWithPausedStatus | null; apiStatus: ApiStatus; } @@ -29,13 +35,13 @@ interface DispatchProps { getFollowerIndex: (id: string) => void; } -const mapStateToProps = (state: any): StateProps => ({ +const mapStateToProps = (state: CcrState): StateProps => ({ followerIndexId: getSelectedFollowerIndexId('detail')(state), followerIndex: getSelectedFollowerIndex('detail')(state), apiStatus: getApiStatus(scope)(state), }); -const mapDispatchToProps = (dispatch: any): DispatchProps => ({ +const mapDispatchToProps = (dispatch: CcrDispatch): DispatchProps => ({ getFollowerIndex: (id: string) => dispatch(getFollowerIndex(id)), }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.test.tsx b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.test.tsx index 8767dc239669c..fbd37d48b3b3c 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.test.tsx +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.test.tsx @@ -25,7 +25,7 @@ jest.mock('../../../../../services/routing', () => { routing: { getFollowerIndexPath: jest.fn(() => '/follower-index-path'), navigate: jest.fn(), - _reactRouter: { + reactRouter: { getUrlForApp: jest.fn(() => '/mock-url'), }, }, diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.tsx b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.tsx index ab56dff20ada8..734bd70878063 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.tsx +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/detail_panel/detail_panel.tsx @@ -405,8 +405,8 @@ const FollowerIndexDetails = ({ followerIndex, isPollingStatus }: FollowerIndexD export interface DetailPanelProps { apiStatus?: ApiStatus; - followerIndexId?: string; - followerIndex?: FollowerIndexWithPausedStatus; + followerIndexId?: string | null; + followerIndex?: FollowerIndexWithPausedStatus | null; closeDetailPanel: () => void; getFollowerIndex: (id: string) => void; } @@ -553,7 +553,7 @@ export const DetailPanel = ({ ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ apiStatusDelete: getApiStatus(`${scope}-delete`)(state), }); -const mapDispatchToProps = (dispatch) => ({ - selectFollowerIndex: (name) => dispatch(selectDetailFollowerIndex(name)), +const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + selectFollowerIndex: (name: string) => dispatch(selectDetailFollowerIndex(name)), }); export const FollowerIndicesTable = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.tsx similarity index 75% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.tsx index f0c50937ec85a..e71142d637c79 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.tsx @@ -6,9 +6,13 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { + DefaultItemAction, + EuiInMemoryTableProps, + EuiSearchBarOnChangeArgs, +} from '@elastic/eui'; import { EuiHealth, EuiButton, @@ -18,12 +22,13 @@ import { EuiOverlayMask, } from '@elastic/eui'; +import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { API_STATUS, UIM_FOLLOWER_INDEX_SHOW_DETAILS_CLICK } from '../../../../../constants'; import { FollowerIndexActionsProvider } from '../../../../../components'; import { routing } from '../../../../../services/routing'; import { trackUiMetric } from '../../../../../services/track_ui_metric'; import { ContextMenu } from '../context_menu'; -import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import type { ApiStatus, FollowerIndexWithPausedStatus } from '../../../../../../../common/types'; const actionI18nTexts = { pause: i18n.translate( @@ -52,7 +57,10 @@ const actionI18nTexts = { ), }; -const getFilteredIndices = (followerIndices, queryText) => { +const getFilteredIndices = ( + followerIndices: FollowerIndexWithPausedStatus[], + queryText: string +): FollowerIndexWithPausedStatus[] => { if (queryText) { const normalizedSearchText = queryText.toLowerCase(); @@ -79,13 +87,27 @@ const getFilteredIndices = (followerIndices, queryText) => { return followerIndices; }; -export class FollowerIndicesTable extends PureComponent { - static propTypes = { - followerIndices: PropTypes.array, - selectFollowerIndex: PropTypes.func.isRequired, - }; +export interface FollowerIndicesTableProps { + followerIndices: FollowerIndexWithPausedStatus[]; + selectFollowerIndex: (name: string) => void; + apiStatusDelete: ApiStatus; +} + +interface FollowerIndicesTableState { + prevFollowerIndices: FollowerIndexWithPausedStatus[]; + selectedItems: FollowerIndexWithPausedStatus[]; + filteredIndices: FollowerIndexWithPausedStatus[]; + queryText: string; +} - static getDerivedStateFromProps(props, state) { +export class FollowerIndicesTable extends PureComponent< + FollowerIndicesTableProps, + FollowerIndicesTableState +> { + static getDerivedStateFromProps( + props: FollowerIndicesTableProps, + state: FollowerIndicesTableState + ): Partial | null { const { followerIndices } = props; const { prevFollowerIndices, queryText } = state; @@ -100,7 +122,7 @@ export class FollowerIndicesTable extends PureComponent { return null; } - constructor(props) { + constructor(props: FollowerIndicesTableProps) { super(props); this.state = { @@ -111,9 +133,9 @@ export class FollowerIndicesTable extends PureComponent { }; } - onSearch = ({ query }) => { + onSearch = ({ query, queryText }: EuiSearchBarOnChangeArgs) => { const { followerIndices } = this.props; - const { text } = query; + const text = query?.text ?? queryText; // We cache the filtered indices instead of calculating them inside render() because // of https://github.com/elastic/eui/issues/3445. @@ -123,46 +145,56 @@ export class FollowerIndicesTable extends PureComponent { }); }; - editFollowerIndex = (id) => { + editFollowerIndex = (id: string) => { const uri = routing.getFollowerIndexPath(id); routing.navigate(uri); }; - getTableColumns(actionHandlers) { + getTableColumns(actionHandlers: { + pauseFollowerIndex: (item: FollowerIndexWithPausedStatus) => void; + resumeFollowerIndex: (name: string) => void; + unfollowLeaderIndex: (name: string) => void; + }): EuiInMemoryTableProps['columns'] { const { selectFollowerIndex } = this.props; - const actions = [ + const actions: Array> = [ /* Pause follower index */ { + type: 'icon', name: actionI18nTexts.pause, description: actionI18nTexts.pause, icon: 'pause', - onClick: (item) => actionHandlers.pauseFollowerIndex(item), - available: (item) => !item.isPaused, + onClick: (item: FollowerIndexWithPausedStatus) => actionHandlers.pauseFollowerIndex(item), + available: (item: FollowerIndexWithPausedStatus) => !item.isPaused, 'data-test-subj': 'pauseButton', }, /* Resume follower index */ { + type: 'icon', name: actionI18nTexts.resume, description: actionI18nTexts.resume, icon: 'play', - onClick: (item) => actionHandlers.resumeFollowerIndex(item.name), - available: (item) => item.isPaused, + onClick: (item: FollowerIndexWithPausedStatus) => + actionHandlers.resumeFollowerIndex(item.name), + available: (item: FollowerIndexWithPausedStatus) => !!item.isPaused, 'data-test-subj': 'resumeButton', }, /* Edit follower index */ { + type: 'icon', name: actionI18nTexts.edit, description: actionI18nTexts.edit, - onClick: (item) => this.editFollowerIndex(item.name), + onClick: (item: FollowerIndexWithPausedStatus) => this.editFollowerIndex(item.name), icon: 'pencil', 'data-test-subj': 'editButton', }, /* Unfollow leader index */ { + type: 'icon', name: actionI18nTexts.unfollow, description: actionI18nTexts.unfollow, - onClick: (item) => actionHandlers.unfollowLeaderIndex(item.name), + onClick: (item: FollowerIndexWithPausedStatus) => + actionHandlers.unfollowLeaderIndex(item.name), icon: 'chartThreshold', 'data-test-subj': 'unfollowButton', }, @@ -179,7 +211,7 @@ export class FollowerIndicesTable extends PureComponent { ), sortable: true, truncateText: false, - render: (name) => { + render: (name: string) => { return ( { @@ -203,7 +235,7 @@ export class FollowerIndicesTable extends PureComponent { ), truncateText: true, sortable: true, - render: (isPaused) => { + render: (isPaused: boolean) => { return isPaused ? ( this.setState({ selectedItems: newSelectedItems }), + onSelectionChange: (newSelectedItems: FollowerIndexWithPausedStatus[]) => + this.setState({ selectedItems: newSelectedItems }), }; - const search = { + const search: EuiInMemoryTableProps['search'] = { toolsLeft: selectedItems.length ? ( ) : undefined, toolsRight: ( ({ - 'data-test-subj': `cell-${column.field}`, + 'data-test-subj': `cell-${'field' in column ? column.field : ''}`, })} data-test-subj="followerIndexListTable" /> diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.ts similarity index 68% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.ts index 087fbc938fbc1..6db7e1edd9baa 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.container.ts @@ -6,8 +6,11 @@ */ import { connect } from 'react-redux'; +import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; import { SECTIONS } from '../../../constants'; +import type { CcrState } from '../../../store'; import { getListFollowerIndices, getSelectedFollowerIndexId, @@ -20,7 +23,9 @@ import { FollowerIndicesList as FollowerIndicesListView } from './follower_indic const scope = SECTIONS.FOLLOWER_INDEX; -const mapStateToProps = (state) => ({ +type CcrDispatch = ThunkDispatch; + +const mapStateToProps = (state: CcrState) => ({ followerIndices: getListFollowerIndices(state), followerIndexId: getSelectedFollowerIndexId('detail')(state), apiStatus: getApiStatus(scope)(state), @@ -28,9 +33,9 @@ const mapStateToProps = (state) => ({ isAuthorized: isApiAuthorized(scope)(state), }); -const mapDispatchToProps = (dispatch) => ({ - loadFollowerIndices: (inBackground) => dispatch(loadFollowerIndices(inBackground)), - selectFollowerIndex: (id) => dispatch(selectDetailFollowerIndex(id)), +const mapDispatchToProps = (dispatch: CcrDispatch) => ({ + loadFollowerIndices: (inBackground?: boolean) => dispatch(loadFollowerIndices(inBackground)), + selectFollowerIndex: (id: string | null) => dispatch(selectDetailFollowerIndex(id)), }); export const FollowerIndicesList = connect( diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.tsx similarity index 69% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.tsx index c5502199118f2..4992d43caadc1 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/follower_indices_list.tsx @@ -6,7 +6,8 @@ */ import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import type { History } from 'history'; +import type { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, EuiText, EuiSpacer, EuiPageTemplate } from '@elastic/eui'; @@ -14,26 +15,48 @@ import { EuiButton, EuiText, EuiSpacer, EuiPageTemplate } from '@elastic/eui'; import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { extractQueryParams, PageLoading, PageError } from '../../../../shared_imports'; import { trackUiMetric, METRIC_TYPE } from '../../../services/track_ui_metric'; +import type { CcrApiError } from '../../../services/http_error'; +import { getErrorBody } from '../../../services/http_error'; import { API_STATUS, UIM_FOLLOWER_INDEX_LIST_LOAD } from '../../../constants'; +import type { ApiStatus, FollowerIndexWithPausedStatus } from '../../../../../common/types'; import { FollowerIndicesTable, DetailPanel } from './components'; const REFRESH_RATE_MS = 30000; -const getQueryParamName = ({ location: { search } }) => { - const { name } = extractQueryParams(search); - return name ? decodeURIComponent(name) : null; +const getQueryParamName = (history: History) => { + const { name } = extractQueryParams(history.location.search); + if (!name) { + return null; + } + const nameStr = Array.isArray(name) ? name[0] : name; + return decodeURIComponent(String(nameStr)); }; -export class FollowerIndicesList extends PureComponent { - static propTypes = { - loadFollowerIndices: PropTypes.func, - selectFollowerIndex: PropTypes.func, - followerIndices: PropTypes.array, - apiStatus: PropTypes.string, - apiError: PropTypes.object, - }; +export interface FollowerIndicesListProps extends RouteComponentProps { + loadFollowerIndices: (inBackground?: boolean) => void; + selectFollowerIndex: (id: string | null) => void; + followerIndices: FollowerIndexWithPausedStatus[]; + followerIndexId: string | null; + apiStatus: ApiStatus; + apiError: CcrApiError | null; + isAuthorized: boolean; +} + +interface FollowerIndicesListState { + lastFollowerIndexId: string | null; + isDetailPanelOpen: boolean; +} + +export class FollowerIndicesList extends PureComponent< + FollowerIndicesListProps, + FollowerIndicesListState +> { + private interval?: ReturnType; - static getDerivedStateFromProps({ followerIndexId }, { lastFollowerIndexId }) { + static getDerivedStateFromProps( + { followerIndexId }: Pick, + { lastFollowerIndexId }: FollowerIndicesListState + ): Partial | null { if (followerIndexId !== lastFollowerIndexId) { return { lastFollowerIndexId: followerIndexId, @@ -43,7 +66,7 @@ export class FollowerIndicesList extends PureComponent { return null; } - state = { + state: FollowerIndicesListState = { lastFollowerIndexId: null, isDetailPanelOpen: false, }; @@ -61,7 +84,7 @@ export class FollowerIndicesList extends PureComponent { this.interval = setInterval(() => loadFollowerIndices(true), REFRESH_RATE_MS); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: FollowerIndicesListProps, prevState: FollowerIndicesListState) { const { history } = this.props; const { lastFollowerIndexId } = this.state; @@ -86,7 +109,9 @@ export class FollowerIndicesList extends PureComponent { } componentWillUnmount() { - clearInterval(this.interval); + if (this.interval !== undefined) { + clearInterval(this.interval); + } } renderEmpty() { @@ -170,18 +195,18 @@ export class FollowerIndicesList extends PureComponent { if (!isAuthorized) { return ( - } + title={i18n.translate( + 'xpack.crossClusterReplication.followerIndexList.permissionErrorTitle', + { + defaultMessage: 'Permission error', + } + )} error={{ - error: ( - + error: i18n.translate( + 'xpack.crossClusterReplication.followerIndexList.noPermissionText', + { + defaultMessage: 'You do not have permission to view or add follower indices.', + } ), }} /> @@ -196,7 +221,9 @@ export class FollowerIndicesList extends PureComponent { } ); - return ; + const body = getErrorBody(apiError); + const error = { error: body?.message ?? apiError.message, statusCode: body?.statusCode }; + return ; } if (isEmpty) { diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/follower_indices_list/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.container.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.container.ts similarity index 60% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.container.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.container.ts index 7739937021af9..6ff3a90d5c0b1 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.container.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.container.ts @@ -6,8 +6,10 @@ */ import { connect } from 'react-redux'; +import type { RouteComponentProps } from 'react-router-dom'; import { SECTIONS } from '../../constants'; +import type { CcrState } from '../../store'; import { getListAutoFollowPatterns, getListFollowerIndices, @@ -15,14 +17,19 @@ import { } from '../../store/selectors'; import { CrossClusterReplicationHome as CrossClusterReplicationHomeView } from './home'; -const mapStateToProps = (state) => ({ +const mapStateToProps = (state: CcrState) => ({ autoFollowPatterns: getListAutoFollowPatterns(state), isAutoFollowApiAuthorized: isApiAuthorized(SECTIONS.AUTO_FOLLOW_PATTERN)(state), followerIndices: getListFollowerIndices(state), isFollowerIndexApiAuthorized: isApiAuthorized(SECTIONS.FOLLOWER_INDEX)(state), }); -export const CrossClusterReplicationHome = connect( - mapStateToProps, - null -)(CrossClusterReplicationHomeView); +type CrossClusterReplicationHomeOwnProps = RouteComponentProps<{ section: string }>; +type CrossClusterReplicationHomeStateProps = ReturnType; + +export const CrossClusterReplicationHome = connect< + CrossClusterReplicationHomeStateProps, + {}, + CrossClusterReplicationHomeOwnProps, + CcrState +>(mapStateToProps)(CrossClusterReplicationHomeView); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.tsx similarity index 71% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.tsx index 68bb384aa218c..b556c7773d25a 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/home.tsx @@ -6,22 +6,46 @@ */ import React, { PureComponent } from 'react'; +import type { ReactNode } from 'react'; +import type { RouteComponentProps } from 'react-router-dom'; import { Route, Routes } from '@kbn/shared-ux-router'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiPageHeader } from '@elastic/eui'; +import type { FollowerIndexWithPausedStatus } from '../../../../common/types'; +import type { ParsedAutoFollowPattern } from '../../store/reducers/auto_follow_pattern'; import { setBreadcrumbs, listBreadcrumb } from '../../services/breadcrumbs'; import { routing } from '../../services/routing'; import { AutoFollowPatternList } from './auto_follow_pattern_list'; import { FollowerIndicesList } from './follower_indices_list'; -export class CrossClusterReplicationHome extends PureComponent { - state = { +export interface CrossClusterReplicationHomeProps extends RouteComponentProps<{ section: string }> { + autoFollowPatterns: ParsedAutoFollowPattern[]; + isAutoFollowApiAuthorized: boolean; + followerIndices: FollowerIndexWithPausedStatus[]; + isFollowerIndexApiAuthorized: boolean; +} + +interface CrossClusterReplicationHomeState { + activeSection: string; +} + +interface HomeTab { + id: string; + name: ReactNode; + testSubj: string; +} + +export class CrossClusterReplicationHome extends PureComponent< + CrossClusterReplicationHomeProps, + CrossClusterReplicationHomeState +> { + state: CrossClusterReplicationHomeState = { activeSection: 'follower_indices', }; - tabs = [ + readonly tabs: HomeTab[] = [ { id: 'follower_indices', name: ( @@ -48,7 +72,7 @@ export class CrossClusterReplicationHome extends PureComponent { setBreadcrumbs([listBreadcrumb()]); } - static getDerivedStateFromProps(props) { + static getDerivedStateFromProps(props: CrossClusterReplicationHomeProps) { const { match: { params: { section }, @@ -59,7 +83,7 @@ export class CrossClusterReplicationHome extends PureComponent { }; } - onSectionChange = (section) => { + onSectionChange = (section: string) => { setBreadcrumbs([listBreadcrumb(`/${section}`)]); routing.navigate(`/${section}`); }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/home/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/index.d.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/index.d.ts deleted file mode 100644 index 93d441399b722..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export declare const CrossClusterReplicationHome: any; -export declare const AutoFollowPatternAdd: any; -export declare const AutoFollowPatternEdit: any; -export declare const FollowerIndexAdd: any; -export declare const FollowerIndexEdit: any; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/sections/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.ts.snap similarity index 98% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.ts.snap index 46659f167a919..335d81b3acf23 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.js.snap +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/__snapshots__/auto_follow_pattern_validators.test.ts.snap @@ -30,6 +30,5 @@ Object { />, "leaderIndexPatterns": null, "name": "Name can't begin with an underscore.", - "otherProp": null, } `; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/api.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/api.js deleted file mode 100644 index 8067b2cc11b9a..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/api.js +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - API_BASE_PATH, - API_REMOTE_CLUSTERS_BASE_PATH, - API_INDEX_MANAGEMENT_BASE_PATH, -} from '../../../common/constants'; -import { arrify } from '../../../common/services/utils'; -import { - UIM_FOLLOWER_INDEX_CREATE, - UIM_FOLLOWER_INDEX_UPDATE, - UIM_FOLLOWER_INDEX_PAUSE, - UIM_FOLLOWER_INDEX_PAUSE_MANY, - UIM_FOLLOWER_INDEX_RESUME, - UIM_FOLLOWER_INDEX_RESUME_MANY, - UIM_FOLLOWER_INDEX_UNFOLLOW, - UIM_FOLLOWER_INDEX_UNFOLLOW_MANY, - UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS, - UIM_AUTO_FOLLOW_PATTERN_CREATE, - UIM_AUTO_FOLLOW_PATTERN_UPDATE, - UIM_AUTO_FOLLOW_PATTERN_DELETE, - UIM_AUTO_FOLLOW_PATTERN_DELETE_MANY, - UIM_AUTO_FOLLOW_PATTERN_PAUSE, - UIM_AUTO_FOLLOW_PATTERN_PAUSE_MANY, - UIM_AUTO_FOLLOW_PATTERN_RESUME, - UIM_AUTO_FOLLOW_PATTERN_RESUME_MANY, -} from '../constants'; -import { trackUserRequest } from './track_ui_metric'; -import { areAllSettingsDefault } from './follower_index_default_settings'; - -let httpClient; - -export function setHttpClient(client) { - httpClient = client; -} - -export const getHttpClient = () => { - return httpClient; -}; - -// --- - -const createIdString = (ids) => ids.map((id) => encodeURIComponent(id)).join(','); - -/* Auto Follow Pattern */ -export const loadAutoFollowPatterns = (asSystemRequest) => - httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`, { asSystemRequest }); - -export const getAutoFollowPattern = (id) => - httpClient.get(`${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`); - -export const loadRemoteClusters = () => httpClient.get(API_REMOTE_CLUSTERS_BASE_PATH); - -export const createAutoFollowPattern = (autoFollowPattern) => { - const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns`, { - body: JSON.stringify(autoFollowPattern), - }); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE); -}; - -export const updateAutoFollowPattern = (id, autoFollowPattern) => { - const request = httpClient.put( - `${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`, - { body: JSON.stringify(autoFollowPattern) } - ); - return trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE); -}; - -export const deleteAutoFollowPattern = (id) => { - const ids = arrify(id); - const idString = ids.map((_id) => encodeURIComponent(_id)).join(','); - const request = httpClient.delete(`${API_BASE_PATH}/auto_follow_patterns/${idString}`); - const uiMetric = - ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_DELETE_MANY : UIM_AUTO_FOLLOW_PATTERN_DELETE; - return trackUserRequest(request, uiMetric); -}; - -export const pauseAutoFollowPattern = (id) => { - const ids = arrify(id); - const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/pause`); - - const uiMetric = - ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_PAUSE_MANY : UIM_AUTO_FOLLOW_PATTERN_PAUSE; - return trackUserRequest(request, uiMetric); -}; - -export const resumeAutoFollowPattern = (id) => { - const ids = arrify(id); - const idString = ids.map(encodeURIComponent).join(','); - const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns/${idString}/resume`); - - const uiMetric = - ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_RESUME_MANY : UIM_AUTO_FOLLOW_PATTERN_RESUME; - return trackUserRequest(request, uiMetric); -}; - -/* Follower Index */ -export const loadFollowerIndices = (asSystemRequest) => - httpClient.get(`${API_BASE_PATH}/follower_indices`, { asSystemRequest }); - -export const getFollowerIndex = (id) => - httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); - -export const createFollowerIndex = (followerIndex) => { - const uiMetrics = [UIM_FOLLOWER_INDEX_CREATE]; - const isUsingAdvancedSettings = !areAllSettingsDefault(followerIndex); - if (isUsingAdvancedSettings) { - uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); - } - const request = httpClient.post(`${API_BASE_PATH}/follower_indices`, { - body: JSON.stringify(followerIndex), - }); - return trackUserRequest(request, uiMetrics); -}; - -export const pauseFollowerIndex = (id) => { - const ids = arrify(id); - const idString = createIdString(ids); - const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/pause`); - const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_PAUSE_MANY : UIM_FOLLOWER_INDEX_PAUSE; - return trackUserRequest(request, uiMetric); -}; - -export const resumeFollowerIndex = (id) => { - const ids = arrify(id); - const idString = createIdString(ids); - const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/resume`); - const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_RESUME_MANY : UIM_FOLLOWER_INDEX_RESUME; - return trackUserRequest(request, uiMetric); -}; - -export const unfollowLeaderIndex = (id) => { - const ids = arrify(id); - const idString = createIdString(ids); - const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${idString}/unfollow`); - const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_UNFOLLOW_MANY : UIM_FOLLOWER_INDEX_UNFOLLOW; - return trackUserRequest(request, uiMetric); -}; - -export const updateFollowerIndex = (id, followerIndex) => { - const uiMetrics = [UIM_FOLLOWER_INDEX_UPDATE]; - const isUsingAdvancedSettings = !areAllSettingsDefault(followerIndex); - if (isUsingAdvancedSettings) { - uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); - } - - const { - maxReadRequestOperationCount, - maxOutstandingReadRequests, - maxReadRequestSize, - maxWriteRequestOperationCount, - maxWriteRequestSize, - maxOutstandingWriteRequests, - maxWriteBufferCount, - maxWriteBufferSize, - maxRetryDelay, - readPollTimeout, - } = followerIndex; - - const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`, { - body: JSON.stringify({ - maxReadRequestOperationCount, - maxOutstandingReadRequests, - maxReadRequestSize, - maxWriteRequestOperationCount, - maxWriteRequestSize, - maxOutstandingWriteRequests, - maxWriteBufferCount, - maxWriteBufferSize, - maxRetryDelay, - readPollTimeout, - }), - }); - - return trackUserRequest(request, uiMetrics); -}; - -/* Stats */ -export const loadAutoFollowStats = () => httpClient.get(`${API_BASE_PATH}/stats/auto_follow`); - -/* Indices */ -let abortController = null; -export const loadIndices = () => { - if (abortController) { - abortController.abort(); - abortController = null; - } - abortController = new AbortController(); - const { signal } = abortController; - return httpClient - .get(`${API_INDEX_MANAGEMENT_BASE_PATH}/indices`, { signal }) - .then((response) => { - abortController = null; - return response; - }); -}; - -export const loadPermissions = () => httpClient.get(`${API_BASE_PATH}/permissions`); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/api.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/api.ts new file mode 100644 index 0000000000000..ebe5f178ff33f --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/api.ts @@ -0,0 +1,310 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup } from '@kbn/core/public'; +import type { AutoFollowPattern, FollowerIndex } from '../../../common/types'; +import { + API_BASE_PATH, + API_REMOTE_CLUSTERS_BASE_PATH, + API_INDEX_MANAGEMENT_BASE_PATH, +} from '../../../common/constants'; +import { arrify } from '../../../common/services/utils'; +import { + UIM_FOLLOWER_INDEX_CREATE, + UIM_FOLLOWER_INDEX_UPDATE, + UIM_FOLLOWER_INDEX_PAUSE, + UIM_FOLLOWER_INDEX_PAUSE_MANY, + UIM_FOLLOWER_INDEX_RESUME, + UIM_FOLLOWER_INDEX_RESUME_MANY, + UIM_FOLLOWER_INDEX_UNFOLLOW, + UIM_FOLLOWER_INDEX_UNFOLLOW_MANY, + UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS, + UIM_AUTO_FOLLOW_PATTERN_CREATE, + UIM_AUTO_FOLLOW_PATTERN_UPDATE, + UIM_AUTO_FOLLOW_PATTERN_DELETE, + UIM_AUTO_FOLLOW_PATTERN_DELETE_MANY, + UIM_AUTO_FOLLOW_PATTERN_PAUSE, + UIM_AUTO_FOLLOW_PATTERN_PAUSE_MANY, + UIM_AUTO_FOLLOW_PATTERN_RESUME, + UIM_AUTO_FOLLOW_PATTERN_RESUME_MANY, +} from '../constants'; +import { trackUserRequest } from './track_ui_metric'; +import { areAllSettingsDefault } from './follower_index_default_settings'; + +export type AutoFollowPatternCreateConfig = Pick< + AutoFollowPattern, + 'remoteCluster' | 'leaderIndexPatterns' | 'followIndexPattern' +>; +export type AutoFollowPatternConfig = AutoFollowPatternCreateConfig & + Pick; + +export interface CreateAutoFollowPatternRequest extends AutoFollowPatternCreateConfig { + id: string; +} + +export interface BulkIdError { + id: string; +} + +export interface DeleteAutoFollowPatternResponse { + errors: BulkIdError[]; + itemsDeleted: string[]; +} + +export interface PauseAutoFollowPatternResponse { + errors: BulkIdError[]; + itemsPaused: string[]; +} + +export interface ResumeAutoFollowPatternResponse { + errors: BulkIdError[]; + itemsResumed: string[]; +} + +export interface RemoteClusterRow { + name: string; + isConnected: boolean; +} + +export type FollowerIndexSaveBody = Omit; + +export interface PauseFollowerIndexResponse { + errors: BulkIdError[]; + itemsPaused: string[]; +} + +export interface ResumeFollowerIndexResponse { + errors: BulkIdError[]; + itemsResumed: string[]; +} + +export interface UnfollowLeaderIndexResponse { + errors: BulkIdError[]; + itemsUnfollowed: string[]; + itemsNotOpen: string[]; +} + +let httpClient: HttpSetup; + +export function setHttpClient(client: HttpSetup): void { + httpClient = client; +} + +export const getHttpClient = (): HttpSetup => { + return httpClient; +}; + +// --- + +const createIdString = (ids: string[]): string => ids.map((id) => encodeURIComponent(id)).join(','); + +/* Auto Follow Pattern */ +export const loadAutoFollowPatterns = ( + asSystemRequest: boolean +): Promise<{ patterns: AutoFollowPattern[] }> => + httpClient.get<{ patterns: AutoFollowPattern[] }>(`${API_BASE_PATH}/auto_follow_patterns`, { + asSystemRequest, + }); + +export const getAutoFollowPattern = (id: string): Promise => + httpClient.get( + `${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}` + ); + +export const loadRemoteClusters = (): Promise => + httpClient.get(API_REMOTE_CLUSTERS_BASE_PATH); + +export const createAutoFollowPattern = async ( + autoFollowPattern: CreateAutoFollowPatternRequest +): Promise => { + const request = httpClient.post(`${API_BASE_PATH}/auto_follow_patterns`, { + body: JSON.stringify(autoFollowPattern), + }); + await trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_CREATE); +}; + +export const updateAutoFollowPattern = async ( + id: string, + autoFollowPattern: AutoFollowPatternConfig +): Promise => { + const request = httpClient.put( + `${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`, + { + body: JSON.stringify(autoFollowPattern), + } + ); + await trackUserRequest(request, UIM_AUTO_FOLLOW_PATTERN_UPDATE); +}; + +export const deleteAutoFollowPattern = ( + id: string | string[] +): Promise => { + const ids = arrify(id); + const idString = ids.map((_id) => encodeURIComponent(_id)).join(','); + const request = httpClient.delete( + `${API_BASE_PATH}/auto_follow_patterns/${idString}` + ); + const uiMetric = + ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_DELETE_MANY : UIM_AUTO_FOLLOW_PATTERN_DELETE; + return trackUserRequest(request, uiMetric); +}; + +export const pauseAutoFollowPattern = ( + id: string | string[] +): Promise => { + const ids = arrify(id); + const idString = ids.map(encodeURIComponent).join(','); + const request = httpClient.post( + `${API_BASE_PATH}/auto_follow_patterns/${idString}/pause` + ); + + const uiMetric = + ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_PAUSE_MANY : UIM_AUTO_FOLLOW_PATTERN_PAUSE; + return trackUserRequest(request, uiMetric); +}; + +export const resumeAutoFollowPattern = ( + id: string | string[] +): Promise => { + const ids = arrify(id); + const idString = ids.map(encodeURIComponent).join(','); + const request = httpClient.post( + `${API_BASE_PATH}/auto_follow_patterns/${idString}/resume` + ); + + const uiMetric = + ids.length > 1 ? UIM_AUTO_FOLLOW_PATTERN_RESUME_MANY : UIM_AUTO_FOLLOW_PATTERN_RESUME; + return trackUserRequest(request, uiMetric); +}; + +/* Follower Index */ +export const loadFollowerIndices = ( + asSystemRequest: boolean +): Promise<{ indices: FollowerIndex[] }> => + httpClient.get<{ indices: FollowerIndex[] }>(`${API_BASE_PATH}/follower_indices`, { + asSystemRequest, + }); + +export const getFollowerIndex = (id: string): Promise => + httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); + +export const createFollowerIndex = async ( + followerIndex: FollowerIndexSaveBody & { name: string } +): Promise => { + const uiMetrics = [UIM_FOLLOWER_INDEX_CREATE]; + const isUsingAdvancedSettings = !areAllSettingsDefault(followerIndex); + if (isUsingAdvancedSettings) { + uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); + } + const request = httpClient.post(`${API_BASE_PATH}/follower_indices`, { + body: JSON.stringify(followerIndex), + }); + await trackUserRequest(request, uiMetrics); +}; + +export const pauseFollowerIndex = (id: string | string[]): Promise => { + const ids = arrify(id); + const idString = createIdString(ids); + const request = httpClient.put( + `${API_BASE_PATH}/follower_indices/${idString}/pause` + ); + const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_PAUSE_MANY : UIM_FOLLOWER_INDEX_PAUSE; + return trackUserRequest(request, uiMetric); +}; + +export const resumeFollowerIndex = ( + id: string | string[] +): Promise => { + const ids = arrify(id); + const idString = createIdString(ids); + const request = httpClient.put( + `${API_BASE_PATH}/follower_indices/${idString}/resume` + ); + const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_RESUME_MANY : UIM_FOLLOWER_INDEX_RESUME; + return trackUserRequest(request, uiMetric); +}; + +export const unfollowLeaderIndex = ( + id: string | string[] +): Promise => { + const ids = arrify(id); + const idString = createIdString(ids); + const request = httpClient.put( + `${API_BASE_PATH}/follower_indices/${idString}/unfollow` + ); + const uiMetric = ids.length > 1 ? UIM_FOLLOWER_INDEX_UNFOLLOW_MANY : UIM_FOLLOWER_INDEX_UNFOLLOW; + return trackUserRequest(request, uiMetric); +}; + +export const updateFollowerIndex = async ( + id: string, + followerIndex: FollowerIndexSaveBody +): Promise => { + const uiMetrics = [UIM_FOLLOWER_INDEX_UPDATE]; + const isUsingAdvancedSettings = !areAllSettingsDefault(followerIndex); + if (isUsingAdvancedSettings) { + uiMetrics.push(UIM_FOLLOWER_INDEX_USE_ADVANCED_OPTIONS); + } + + const { + maxReadRequestOperationCount, + maxOutstandingReadRequests, + maxReadRequestSize, + maxWriteRequestOperationCount, + maxWriteRequestSize, + maxOutstandingWriteRequests, + maxWriteBufferCount, + maxWriteBufferSize, + maxRetryDelay, + readPollTimeout, + } = followerIndex; + + const request = httpClient.put( + `${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`, + { + body: JSON.stringify({ + maxReadRequestOperationCount, + maxOutstandingReadRequests, + maxReadRequestSize, + maxWriteRequestOperationCount, + maxWriteRequestSize, + maxOutstandingWriteRequests, + maxWriteBufferCount, + maxWriteBufferSize, + maxRetryDelay, + readPollTimeout, + }), + } + ); + + await trackUserRequest(request, uiMetrics); +}; + +/* Stats */ +export const loadAutoFollowStats = () => httpClient.get(`${API_BASE_PATH}/stats/auto_follow`); + +/* Indices */ +let abortController: AbortController | null = null; +export const loadIndices = async (): Promise> => { + if (abortController) { + abortController.abort(); + abortController = null; + } + abortController = new AbortController(); + const { signal } = abortController; + const response = await httpClient.get>( + `${API_INDEX_MANAGEMENT_BASE_PATH}/indices`, + { signal } + ); + abortController = null; + return response; +}; + +export const loadPermissions = (): Promise<{ + hasPermission: boolean; + missingClusterPrivileges: string[]; +}> => httpClient.get(`${API_BASE_PATH}/permissions`); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.test.ts similarity index 57% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.test.ts index 488fbd399dba6..9041a8499d95d 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.test.ts @@ -5,20 +5,24 @@ * 2.0. */ +import type { RecentAutoFollowError } from '../../../common/types'; import { parseAutoFollowErrors } from './auto_follow_errors'; describe('Auto-follow pattern errors service', () => { it('should convert an array of error to an object where each key is an auto-follow pattern id', () => { - const esErrors = [ + const esErrors: RecentAutoFollowError[] = [ { + timestamp: 1, leaderIndex: 'some:id::kibana_sample_4', autoFollowException: { type: 'exception', reason: 'Error 1' }, }, { + timestamp: 2, leaderIndex: 'another-id:mock:kibana_sample_5', autoFollowException: { type: 'exception', reason: 'Error 2' }, }, { + timestamp: 3, leaderIndex: 'some:id::kibana_sample_5', autoFollowException: { type: 'exception', reason: 'Error 3' }, }, @@ -28,6 +32,7 @@ describe('Auto-follow pattern errors service', () => { 'another-id:mock': [ { id: 'another-id:mock', + timestamp: 2, leaderIndex: 'another-id:mock:kibana_sample_5', autoFollowException: { type: 'exception', reason: 'Error 2' }, }, @@ -35,11 +40,13 @@ describe('Auto-follow pattern errors service', () => { 'some:id:': [ { id: 'some:id:', + timestamp: 1, leaderIndex: 'some:id::kibana_sample_4', autoFollowException: { type: 'exception', reason: 'Error 1' }, }, { id: 'some:id:', + timestamp: 3, leaderIndex: 'some:id::kibana_sample_5', autoFollowException: { type: 'exception', reason: 'Error 3' }, }, @@ -50,14 +57,42 @@ describe('Auto-follow pattern errors service', () => { }); it('should limit the number of error to show for each pattern', () => { - const esErrors = [ - { leaderIndex: 'my-id:kibana-1' }, - { leaderIndex: 'my-id:kibana-2' }, - { leaderIndex: 'my-id:kibana-3' }, - { leaderIndex: 'my-id:kibana-4' }, - { leaderIndex: 'my-id:kibana-5' }, - { leaderIndex: 'my-id:kibana-6' }, - { leaderIndex: 'my-id:kibana-7' }, + const esErrors: RecentAutoFollowError[] = [ + { + timestamp: 1, + leaderIndex: 'my-id:kibana-1', + autoFollowException: { type: 'exception', reason: 'e' }, + }, + { + timestamp: 2, + leaderIndex: 'my-id:kibana-2', + autoFollowException: { type: 'exception', reason: 'e' }, + }, + { + timestamp: 3, + leaderIndex: 'my-id:kibana-3', + autoFollowException: { type: 'exception', reason: 'e' }, + }, + { + timestamp: 4, + leaderIndex: 'my-id:kibana-4', + autoFollowException: { type: 'exception', reason: 'e' }, + }, + { + timestamp: 5, + leaderIndex: 'my-id:kibana-5', + autoFollowException: { type: 'exception', reason: 'e' }, + }, + { + timestamp: 6, + leaderIndex: 'my-id:kibana-6', + autoFollowException: { type: 'exception', reason: 'e' }, + }, + { + timestamp: 7, + leaderIndex: 'my-id:kibana-7', + autoFollowException: { type: 'exception', reason: 'e' }, + }, ]; expect(parseAutoFollowErrors(esErrors)['my-id'].length).toEqual(5); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.ts similarity index 58% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.ts index 4458ef7f3a66b..8e45e26f8de2c 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_errors.ts @@ -5,7 +5,21 @@ * 2.0. */ -export const parseAutoFollowError = (error) => { +import type { RecentAutoFollowError } from '../../../common/types'; + +export interface ParsedAutoFollowError { + id: string; + timestamp: number; + leaderIndex: string; + autoFollowException: { + type: string; + reason: string; + }; +} + +export const parseAutoFollowError = ( + error: RecentAutoFollowError +): ParsedAutoFollowError | null => { if (!error.leaderIndex) { return null; } @@ -25,11 +39,14 @@ export const parseAutoFollowError = (error) => { * Parse an array of auto-follow pattern errors and returns * an object where each key is an auto-follow pattern id */ -export const parseAutoFollowErrors = (recentAutoFollowErrors, maxErrorsToShow = 5) => +export const parseAutoFollowErrors = ( + recentAutoFollowErrors: RecentAutoFollowError[], + maxErrorsToShow = 5 +): Record => recentAutoFollowErrors .map(parseAutoFollowError) - .filter((error) => error !== null) - .reduce((byId, error) => { + .filter((error): error is ParsedAutoFollowError => error !== null) + .reduce>((byId, error) => { if (!byId[error.id]) { byId[error.id] = []; } diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.test.ts similarity index 97% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.test.ts index b77aa8924f8b4..e42fe620fab43 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.test.ts @@ -13,9 +13,9 @@ import { describe('Auto-follo pattern service', () => { describe('getPreviewIndicesFromAutoFollowPattern()', () => { - let prefix; - let suffix; - let leaderIndexPatterns; + let prefix: string; + let suffix: string; + let leaderIndexPatterns: string[]; beforeEach(() => { prefix = 'prefix_'; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.ts similarity index 69% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.ts index b91ca413e02fd..a0f50d56f9f5f 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern.ts @@ -7,7 +7,20 @@ import moment from 'moment'; -const getFollowPattern = (prefix = '', suffix = '', template = '{{leader_index}}') => ({ +export interface FollowPattern { + followPattern: { + prefix: string; + suffix: string; + template: string; + }; + toString: string; +} + +const getFollowPattern = ( + prefix = '', + suffix = '', + template = '{{leader_index}}' +): FollowPattern => ({ followPattern: { prefix, suffix, @@ -16,6 +29,14 @@ const getFollowPattern = (prefix = '', suffix = '', template = '{{leader_index}} toString: prefix + template + suffix, }); +interface GetPreviewIndicesFromAutoFollowPatternParams { + prefix: string; + suffix: string; + leaderIndexPatterns: string[]; + limit?: number; + wildcardPlaceHolders?: string[]; +} + /** * Generate an array of indices preview that would be generated for an auto-follow pattern. * It concatenates the prefix + the leader index pattern populated with values + the suffix @@ -33,10 +54,13 @@ export const getPreviewIndicesFromAutoFollowPattern = ({ moment().add(1, 'days').format('YYYY-MM-DD'), moment().add(2, 'days').format('YYYY-MM-DD'), ], -}) => { - const indicesPreview = []; - let indexPreview; - let leaderIndexTemplate; +}: GetPreviewIndicesFromAutoFollowPatternParams): { + indicesPreview: FollowPattern[]; + hasMore: boolean; +} => { + const indicesPreview: FollowPattern[] = []; + let indexPreview: FollowPattern; + let leaderIndexTemplate: string; leaderIndexPatterns.forEach((leaderIndexPattern) => { wildcardPlaceHolders.forEach((placeHolder) => { @@ -57,7 +81,12 @@ export const getPreviewIndicesFromAutoFollowPattern = ({ }; }; -export const getPrefixSuffixFromFollowPattern = (followPattern) => { +export const getPrefixSuffixFromFollowPattern = ( + followPattern: string +): { + followIndexPatternPrefix: string | undefined; + followIndexPatternSuffix: string | undefined; +} => { let followIndexPatternPrefix; let followIndexPatternSuffix; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.ts similarity index 75% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.ts index 721fde01aa2b5..bd9c348d49a6b 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.ts @@ -14,6 +14,17 @@ describe('Auto-follow pattern validators', () => { expect(errors).toMatchSnapshot(); }); + it('does not validate fields whose values are undefined', () => { + const errors = validateAutoFollowPattern({ + name: undefined, + leaderIndexPatterns: undefined, + followIndexPatternPrefix: undefined, + followIndexPatternSuffix: undefined, + }); + + expect(errors).toEqual({}); + }); + it('should validate all props from auto-follow patten', () => { const autoFollowPattern = { name: '_wrong-name', diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.tsx similarity index 75% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.tsx index 17911c8ac960f..a8d4fe54376c6 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { @@ -18,7 +19,7 @@ import { indices } from '../../shared_imports'; const { indexNameBeginsWithPeriod, findIllegalCharactersInIndexName, indexNameContainsSpaces } = indices; -export const validateName = (name = '') => { +export const validateName = (name = ''): string | null => { let errorMsg = null; if (!name || !name.trim()) { @@ -53,7 +54,9 @@ export const validateName = (name = '') => { return errorMsg; }; -export const validateLeaderIndexPattern = (indexPattern) => { +export const validateLeaderIndexPattern = ( + indexPattern: string | undefined +): { message: ReactNode } | null => { if (indexPattern) { const errors = validateDataView(indexPattern); @@ -99,7 +102,9 @@ export const validateLeaderIndexPattern = (indexPattern) => { return null; }; -export const validateLeaderIndexPatterns = (indexPatterns) => { +export const validateLeaderIndexPatterns = ( + indexPatterns: string[] +): { message: ReactNode } | null => { // 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) { @@ -116,7 +121,7 @@ export const validateLeaderIndexPatterns = (indexPatterns) => { return null; }; -export const validatePrefix = (prefix) => { +export const validatePrefix = (prefix: string): ReactElement | null => { // If it's empty, it is valid if (!prefix || !prefix.trim()) { return null; @@ -160,7 +165,7 @@ export const validatePrefix = (prefix) => { return null; }; -export const validateSuffix = (suffix) => { +export const validateSuffix = (suffix: string): ReactElement | null => { // If it's empty, it is valid if (!suffix || !suffix.trim()) { return null; @@ -194,35 +199,47 @@ export const validateSuffix = (suffix) => { return null; }; -export const validateAutoFollowPattern = (autoFollowPattern = {}) => { - const errors = {}; - let error = null; - let fieldValue; - - Object.keys(autoFollowPattern).forEach((fieldName) => { - fieldValue = autoFollowPattern[fieldName]; - error = null; - - switch (fieldName) { - case 'name': - error = validateName(fieldValue); - break; - - case 'leaderIndexPatterns': - error = validateLeaderIndexPatterns(fieldValue); - break; +export interface AutoFollowPatternValidationFields { + name?: string; + leaderIndexPatterns?: string[]; + followIndexPatternPrefix?: string; + followIndexPatternSuffix?: string; + remoteCluster?: string; +} + +export interface MessageError { + message: ReactNode; + alwaysVisible?: boolean; +} + +export interface AutoFollowPatternValidationErrors { + name?: string | null; + leaderIndexPatterns?: MessageError | null; + followIndexPatternPrefix?: ReactElement | null; + followIndexPatternSuffix?: ReactElement | null; + remoteCluster?: MessageError | null; +} + +export const validateAutoFollowPattern = ( + autoFollowPattern: AutoFollowPatternValidationFields = {} +): AutoFollowPatternValidationErrors => { + const errors: AutoFollowPatternValidationErrors = {}; + + if (autoFollowPattern.name !== undefined) { + errors.name = validateName(autoFollowPattern.name); + } - case 'followIndexPatternPrefix': - error = validatePrefix(fieldValue); - break; + if (autoFollowPattern.leaderIndexPatterns !== undefined) { + errors.leaderIndexPatterns = validateLeaderIndexPatterns(autoFollowPattern.leaderIndexPatterns); + } - case 'followIndexPatternSuffix': - error = validateSuffix(fieldValue); - break; - } + if (autoFollowPattern.followIndexPatternPrefix !== undefined) { + errors.followIndexPatternPrefix = validatePrefix(autoFollowPattern.followIndexPatternPrefix); + } - errors[fieldName] = error; - }); + if (autoFollowPattern.followIndexPatternSuffix !== undefined) { + errors.followIndexPatternSuffix = validateSuffix(autoFollowPattern.followIndexPatternSuffix); + } return errors; }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/follower_index_default_settings.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/follower_index_default_settings.js deleted file mode 100644 index e571d2c6ef35d..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/follower_index_default_settings.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../common/constants'; - -export const getSettingDefault = (name) => { - if (!FOLLOWER_INDEX_ADVANCED_SETTINGS[name]) { - throw new Error(`Unknown setting ${name}`); - } - - return FOLLOWER_INDEX_ADVANCED_SETTINGS[name]; -}; - -export const isSettingDefault = (name, value) => { - return getSettingDefault(name) === value; -}; - -export const areAllSettingsDefault = (settings) => { - return Object.keys(FOLLOWER_INDEX_ADVANCED_SETTINGS).every((name) => - isSettingDefault(name, settings[name]) - ); -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/follower_index_default_settings.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/follower_index_default_settings.ts new file mode 100644 index 0000000000000..f5ac41cdbb82e --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/follower_index_default_settings.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FollowerIndexAdvancedSettings } from '../../../common/types'; +import { FOLLOWER_INDEX_ADVANCED_SETTINGS } from '../../../common/constants'; + +type FollowerIndexAdvancedSettingName = keyof typeof FOLLOWER_INDEX_ADVANCED_SETTINGS; +type SettingDefaultValue = + (typeof FOLLOWER_INDEX_ADVANCED_SETTINGS)[FollowerIndexAdvancedSettingName]; + +export const getSettingDefault = (name: FollowerIndexAdvancedSettingName): SettingDefaultValue => { + if (!Object.prototype.hasOwnProperty.call(FOLLOWER_INDEX_ADVANCED_SETTINGS, name)) { + throw new Error(`Unknown setting ${name}`); + } + + return FOLLOWER_INDEX_ADVANCED_SETTINGS[name]; +}; + +/** + * A setting is considered default when it is `undefined` (the user has not + * entered a value) or when the entered value equals the documented default. + */ +export const isSettingDefault = ( + name: FollowerIndexAdvancedSettingName, + value: SettingDefaultValue | undefined +): boolean => { + return value === undefined || getSettingDefault(name) === value; +}; + +export const areAllSettingsDefault = (settings: FollowerIndexAdvancedSettings): boolean => { + return ( + Object.keys(FOLLOWER_INDEX_ADVANCED_SETTINGS) as FollowerIndexAdvancedSettingName[] + ).every((name) => isSettingDefault(name, settings[name])); +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/get_remote_cluster_name.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/get_remote_cluster_name.js deleted file mode 100644 index 187811bf166d1..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/get_remote_cluster_name.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -const getFirstConnectedCluster = (clusters) => { - for (let i = 0; i < clusters.length; i++) { - if (clusters[i].isConnected) { - return clusters[i]; - } - } - - // No cluster connected, we return the first one in the list - return clusters.length ? clusters[0] : {}; -}; - -export const getRemoteClusterName = (remoteClusters, selected) => { - return selected && remoteClusters.some((c) => c.name === selected) - ? selected - : getFirstConnectedCluster(remoteClusters).name; -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/get_remote_cluster_name.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/get_remote_cluster_name.ts new file mode 100644 index 0000000000000..e85d63718679f --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/get_remote_cluster_name.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RemoteClusterRow } from './api'; + +const getFirstConnectedCluster = ( + clusters: RemoteClusterRow[] +): RemoteClusterRow | Record => { + for (let i = 0; i < clusters.length; i++) { + if (clusters[i].isConnected) { + return clusters[i]; + } + } + + // No cluster connected, we return the first one in the list + return clusters.length ? clusters[0] : {}; +}; + +export const getRemoteClusterName = ( + remoteClusters: RemoteClusterRow[], + selected: string | undefined +): string | undefined => { + if (selected && remoteClusters.some((c) => c.name === selected)) { + return selected; + } + const first = getFirstConnectedCluster(remoteClusters); + return 'name' in first ? first.name : undefined; +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/http_error.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/http_error.ts new file mode 100644 index 0000000000000..8c15891feb1bc --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/http_error.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; + +export type CcrApiError = Error | IHttpFetchError; + +export const toCcrApiError = (error: unknown): CcrApiError => { + if (error instanceof Error) { + return error; + } + + return new Error(String(error)); +}; + +export const isHttpFetchError = ( + error: CcrApiError +): error is IHttpFetchError => { + return 'request' in error; +}; + +export const getErrorStatus = (error: CcrApiError | null | undefined): number | undefined => { + if (!error) { + return; + } + + if (!isHttpFetchError(error)) { + return; + } + + return error.response?.status ?? error.body?.statusCode; +}; + +export const getErrorBody = ( + error: CcrApiError | null | undefined +): ResponseErrorBody | undefined => { + if (!error) { + return; + } + + return isHttpFetchError(error) ? error.body : undefined; +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/input_validation.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/input_validation.tsx similarity index 81% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/input_validation.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/input_validation.tsx index 143542fb2d59e..c0030821c9fc1 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/input_validation.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/input_validation.tsx @@ -6,22 +6,24 @@ */ import React from 'react'; +import type { ReactElement } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { indices } from '../../shared_imports'; -const isEmpty = (value) => { +const isEmpty = (value: string | undefined): boolean => { return !value || !value.trim().length; }; -const hasSpaces = (value) => (typeof value === 'string' ? value.includes(' ') : false); +const hasSpaces = (value: string): boolean => + typeof value === 'string' ? value.includes(' ') : false; -const beginsWithPeriod = (value) => { +const beginsWithPeriod = (value: string): boolean => { return value[0] === '.'; }; -const findIllegalCharacters = (value) => { - return indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { +const findIllegalCharacters = (value: string): string[] => { + return indices.INDEX_ILLEGAL_CHARACTERS_VISIBLE.reduce((chars, char) => { if (value.includes(char)) { chars.push(char); } @@ -30,7 +32,7 @@ const findIllegalCharacters = (value) => { }, []); }; -export const indexNameValidator = (value) => { +export const indexNameValidator = (value: string): ReactElement[] | undefined => { if (isEmpty(value)) { return [ { return undefined; }; -export const leaderIndexValidator = (value) => { +export const leaderIndexValidator = (value: string): ReactElement[] | undefined => { if (isEmpty(value)) { return [ { +export const init = (toasts: IToasts, fatalErrors: FatalErrorsSetup): void => { _toasts = toasts; _fatalErrors = fatalErrors; }; -export const getToasts = () => _toasts; -export const getFatalErrors = () => _fatalErrors; +export const getToasts = (): IToasts => { + if (_toasts === undefined) { + throw new Error('CCR notifications were used before init() was called'); + } + return _toasts; +}; + +export const getFatalErrors = (): FatalErrorsSetup => { + if (_fatalErrors === undefined) { + throw new Error('CCR notifications were used before init() was called'); + } + return _fatalErrors; +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.d.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.d.ts deleted file mode 100644 index 54ed44629ed78..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export declare const routing: any; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.js deleted file mode 100644 index 77b8ed6c58123..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * This file based on guidance from https://github.com/elastic/eui/blob/main/wiki/consuming-eui/react-router.md - */ - -import { stringify } from 'query-string'; -import { BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants'; - -const queryParamsFromObject = (params, encodeParams = false) => { - if (!params) { - return; - } - - const paramsStr = stringify(params, { sort: false, encode: encodeParams }); - return `?${paramsStr}`; -}; - -class Routing { - _reactRouter = null; - - getHrefToRemoteClusters(route = '/', params, encodeParams = false) { - const search = queryParamsFromObject(params, encodeParams) || ''; - return this._reactRouter.getUrlForApp('management', { - path: `${BASE_PATH_REMOTE_CLUSTERS}${route}${search}`, - }); - } - - navigate(route = '/home', params, encodeParams = false) { - const search = queryParamsFromObject(params, encodeParams); - - this._reactRouter.history.push({ - pathname: encodeURI(route), - search, - }); - } - - getAutoFollowPatternPath = (name, section = '/edit') => { - return encodeURI(`/auto_follow_patterns${section}/${encodeURIComponent(name)}`); - }; - - getFollowerIndexPath = (name, section = '/edit') => { - return encodeURI(`/follower_indices${section}/${encodeURIComponent(name)}`); - }; - - get reactRouter() { - return this._reactRouter; - } - - set reactRouter(router) { - this._reactRouter = router; - } -} - -export const routing = new Routing(); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.ts new file mode 100644 index 0000000000000..0325d87117c09 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/routing.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * This file based on guidance from https://github.com/elastic/eui/blob/main/wiki/consuming-eui/react-router.md + */ + +import type { History } from 'history'; +import type { ApplicationStart } from '@kbn/core/public'; +import { stringify } from 'query-string'; +import { BASE_PATH_REMOTE_CLUSTERS } from '../../../common/constants'; + +export type QueryParamValue = string | number | boolean | null; +export type QueryParams = Record; + +const queryParamsFromObject = ( + params: QueryParams | undefined | null, + encodeParams = false +): string | undefined => { + if (!params) { + return; + } + + const paramsStr = stringify(params, { sort: false, encode: encodeParams }); + return `?${paramsStr}`; +}; + +export interface CcrReactRouter { + history: History; + route: { + location: History['location']; + }; + getUrlForApp: ApplicationStart['getUrlForApp']; +} + +class Routing { + #reactRouter: CcrReactRouter | null = null; + + getHrefToRemoteClusters(route = '/', params?: QueryParams | null, encodeParams = false): string { + const search = queryParamsFromObject(params, encodeParams) || ''; + return this.reactRouterOrThrow.getUrlForApp('management', { + path: `${BASE_PATH_REMOTE_CLUSTERS}${route}${search}`, + }); + } + + navigate(route = '/home', params?: QueryParams | null, encodeParams = false): void { + const search = queryParamsFromObject(params, encodeParams); + + this.reactRouterOrThrow.history.push({ + pathname: encodeURI(route), + search, + }); + } + + getAutoFollowPatternPath = (name: string, section = '/edit'): string => { + return encodeURI(`/auto_follow_patterns${section}/${encodeURIComponent(name)}`); + }; + + getFollowerIndexPath = (name: string, section = '/edit'): string => { + return encodeURI(`/follower_indices${section}/${encodeURIComponent(name)}`); + }; + + public get reactRouter(): CcrReactRouter | null { + return this.#reactRouter; + } + + public set reactRouter(router: CcrReactRouter) { + this.#reactRouter = router; + } + + public get reactRouterOrThrow(): CcrReactRouter { + if (!this.#reactRouter) { + throw new Error('CCR routing was used before reactRouter was set'); + } + return this.#reactRouter; + } +} + +export const routing = new Routing(); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/track_ui_metric.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/track_ui_metric.ts index 2fd2b23f045c2..3de9a5f8f2677 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/track_ui_metric.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/track_ui_metric.ts @@ -14,7 +14,7 @@ import { UIM_APP_NAME } from '../constants'; export { METRIC_TYPE }; // usageCollection is an optional dependency, so we default to a no-op. -export let trackUiMetric = (metricType: UiCounterMetricType, eventName: string) => {}; +export let trackUiMetric = (metricType: UiCounterMetricType, eventName: string | string[]) => {}; export function init(usageCollection: UsageCollectionSetup): void { trackUiMetric = usageCollection.reportUiCounter.bind(usageCollection, UIM_APP_NAME); @@ -24,7 +24,10 @@ export function init(usageCollection: UsageCollectionSetup): void { * Transparently return provided request Promise, while allowing us to track * a successful completion of the request. */ -export function trackUserRequest(request: Promise, actionType: string) { +export function trackUserRequest( + request: Promise, + actionType: string | string[] +): Promise { // Only track successful actions. return request.then((response) => { // It looks like we're using the wrong type here, added via diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.js deleted file mode 100644 index 1d24b68713dc0..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.js +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const objectToArray = (obj) => Object.keys(obj).map((k) => ({ ...obj[k], __id__: k })); - -export const arrayToObject = (array, keyProp = 'id') => - array.reduce((acc, item) => { - acc[item[keyProp]] = item; - return acc; - }, {}); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.test.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.test.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.ts new file mode 100644 index 0000000000000..1be7c85af3097 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/services/utils.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const objectToArray = ( + obj: Record +): Array => Object.keys(obj).map((k) => ({ ...obj[k], __id__: k })); + +export const arrayToObject = (array: T[], keyProp: K): Record => + array.reduce>((acc, item) => { + acc[String(item[keyProp])] = item; + return acc; + }, {}); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/action_types.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/action_types.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/action_types.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/action_types.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/api.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/api.js deleted file mode 100644 index 32379cb9a2cc8..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/api.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from '../action_types'; -import { API_STATUS } from '../../constants'; - -export const apiRequestStart = ({ label, scope, status = API_STATUS.LOADING }) => ({ - type: t.API_REQUEST_START, - payload: { label, scope, status }, -}); - -export const apiRequestEnd = ({ label, scope }) => ({ - type: t.API_REQUEST_END, - payload: { label, scope }, -}); - -export const setApiError = ({ error, scope }) => ({ - type: t.API_ERROR_SET, - payload: { error, scope }, -}); - -export const clearApiError = (scope) => ({ - type: t.API_ERROR_SET, - payload: { error: null, scope }, -}); - -export const sendApiRequest = - ({ label, scope, status, handler, onSuccess = () => undefined, onError = () => undefined }) => - async (dispatch, getState) => { - dispatch(clearApiError(scope)); - dispatch(apiRequestStart({ label, scope, status })); - - try { - const response = await handler(dispatch); - - dispatch(apiRequestEnd({ label, scope })); - dispatch({ type: `${label}_SUCCESS`, payload: response }); - - onSuccess(response, dispatch, getState); - } catch (error) { - dispatch(apiRequestEnd({ label, scope })); - dispatch(setApiError({ error, scope })); - dispatch({ type: `${label}_FAILURE`, payload: error }); - - onError(error, dispatch, getState); - } - }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/api.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/api.ts new file mode 100644 index 0000000000000..552418ca487e4 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/api.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AnyAction } from 'redux'; +import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; +import type { ApiStatus } from '../../../../common/types'; +import * as t from '../action_types'; +import { API_STATUS } from '../../constants'; +import type { CcrState } from '../reducers'; +import type { CcrApiError } from '../../services/http_error'; +import { toCcrApiError } from '../../services/http_error'; + +export interface ApiRequestStartParams { + label: string; + scope: string; + status?: ApiStatus; +} + +export const apiRequestStart = ({ + label, + scope, + status = API_STATUS.LOADING, +}: ApiRequestStartParams) => ({ + type: t.API_REQUEST_START, + payload: { label, scope, status }, +}); + +export interface ApiRequestEndParams { + label: string; + scope: string; +} + +export const apiRequestEnd = ({ label, scope }: ApiRequestEndParams) => ({ + type: t.API_REQUEST_END, + payload: { label, scope }, +}); + +export interface SetApiErrorParams { + error: CcrApiError; + scope: string; +} + +export const setApiError = ({ error, scope }: SetApiErrorParams) => ({ + type: t.API_ERROR_SET, + payload: { error, scope }, +}); + +export const clearApiError = (scope: string) => ({ + type: t.API_ERROR_SET, + payload: { error: null, scope }, +}); + +type AppDispatch = ThunkDispatch; + +export interface SendApiRequestParams { + label: string; + scope: string; + status?: ApiStatus; + handler: (dispatch: AppDispatch) => Promise; + onSuccess?: (response: TResponse, dispatch: AppDispatch, getState: () => CcrState) => void; + onError?: (error: CcrApiError, dispatch: AppDispatch, getState: () => CcrState) => void; +} + +export const sendApiRequest = + ({ + label, + scope, + status, + handler, + onSuccess = () => undefined, + onError = () => undefined, + }: SendApiRequestParams): ThunkAction, CcrState, undefined, AnyAction> => + async (dispatch, getState) => { + dispatch(clearApiError(scope)); + dispatch(apiRequestStart({ label, scope, status })); + + try { + const response = await handler(dispatch); + + dispatch(apiRequestEnd({ label, scope })); + dispatch({ type: `${label}_SUCCESS`, payload: response }); + + onSuccess(response, dispatch, getState); + } catch (error) { + const apiError = toCcrApiError(error); + dispatch(apiRequestEnd({ label, scope })); + dispatch(setApiError({ error: apiError, scope })); + dispatch({ type: `${label}_FAILURE`, payload: apiError }); + + onError(apiError, dispatch, getState); + } + }; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.ts similarity index 72% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.ts index 9ebe1d57a1826..80e04e99b5177 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.ts @@ -5,10 +5,17 @@ * 2.0. */ +import type { AnyAction } from 'redux'; +import type { ThunkAction } from 'redux-thunk'; import { i18n } from '@kbn/i18n'; import { getToasts } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { + type AutoFollowPatternCreateConfig, + type AutoFollowPatternConfig, + type DeleteAutoFollowPatternResponse, + type PauseAutoFollowPatternResponse, + type ResumeAutoFollowPatternResponse, loadAutoFollowPatterns as loadAutoFollowPatternsRequest, getAutoFollowPattern as getAutoFollowPatternRequest, createAutoFollowPattern as createAutoFollowPatternRequest, @@ -20,21 +27,24 @@ import { import { routing } from '../../services/routing'; import * as t from '../action_types'; import { sendApiRequest } from './api'; +import type { CcrState } from '../reducers'; import { getSelectedAutoFollowPatternId } from '../selectors'; const { AUTO_FOLLOW_PATTERN: scope } = SECTIONS; -export const selectDetailAutoFollowPattern = (id) => ({ +export const selectDetailAutoFollowPattern = (id: string | null) => ({ type: t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL, payload: id, }); -export const selectEditAutoFollowPattern = (id) => ({ +export const selectEditAutoFollowPattern = (id: string | null) => ({ type: t.AUTO_FOLLOW_PATTERN_SELECT_EDIT, payload: id, }); -export const loadAutoFollowPatterns = (isUpdating = false) => +export const loadAutoFollowPatterns = ( + isUpdating = false +): ThunkAction, CcrState, undefined, AnyAction> => sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_LOAD, scope, @@ -42,54 +52,77 @@ export const loadAutoFollowPatterns = (isUpdating = false) => handler: async () => await loadAutoFollowPatternsRequest(isUpdating), }); -export const getAutoFollowPattern = (id) => +export const getAutoFollowPattern = ( + id: string +): ThunkAction, CcrState, undefined, AnyAction> => sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_GET, scope: `${scope}-get`, handler: async () => await getAutoFollowPatternRequest(id), }); -export const saveAutoFollowPattern = (id, autoFollowPattern, isUpdating = false) => - sendApiRequest({ - label: isUpdating ? t.AUTO_FOLLOW_PATTERN_UPDATE : t.AUTO_FOLLOW_PATTERN_CREATE, +export const createAutoFollowPattern = ( + id: string, + autoFollowPattern: AutoFollowPatternCreateConfig +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ + label: t.AUTO_FOLLOW_PATTERN_CREATE, status: API_STATUS.SAVING, scope: `${scope}-save`, - handler: async () => { - if (isUpdating) { - return await updateAutoFollowPatternRequest(id, autoFollowPattern); - } - return await createAutoFollowPatternRequest({ id, ...autoFollowPattern }); - }, + handler: async () => + await createAutoFollowPatternRequest({ + id, + ...autoFollowPattern, + }), onSuccess() { - const successMessage = isUpdating - ? i18n.translate( - 'xpack.crossClusterReplication.autoFollowPattern.updateAction.successNotificationTitle', - { - defaultMessage: `Auto-follow pattern ''{name}'' updated successfully`, - values: { name: id }, - } - ) - : i18n.translate( - 'xpack.crossClusterReplication.autoFollowPattern.addAction.successNotificationTitle', - { - defaultMessage: `Added auto-follow pattern ''{name}''`, - values: { name: id }, - } - ); + getToasts().addSuccess( + i18n.translate( + 'xpack.crossClusterReplication.autoFollowPattern.addAction.successNotificationTitle', + { + defaultMessage: `Added auto-follow pattern ''{name}''`, + values: { name: id }, + } + ) + ); + routing.navigate(`/auto_follow_patterns`, { + pattern: encodeURIComponent(id), + }); + }, + }); - getToasts().addSuccess(successMessage); +export const updateAutoFollowPattern = ( + id: string, + autoFollowPattern: AutoFollowPatternConfig +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ + label: t.AUTO_FOLLOW_PATTERN_UPDATE, + status: API_STATUS.SAVING, + scope: `${scope}-save`, + handler: async () => await updateAutoFollowPatternRequest(id, autoFollowPattern), + onSuccess() { + getToasts().addSuccess( + i18n.translate( + 'xpack.crossClusterReplication.autoFollowPattern.updateAction.successNotificationTitle', + { + defaultMessage: `Auto-follow pattern ''{name}'' updated successfully`, + values: { name: id }, + } + ) + ); routing.navigate(`/auto_follow_patterns`, { pattern: encodeURIComponent(id), }); }, }); -export const deleteAutoFollowPattern = (id) => - sendApiRequest({ +export const deleteAutoFollowPattern = ( + id: string | string[] +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_DELETE, scope: `${scope}-delete`, status: API_STATUS.DELETING, - handler: async () => deleteAutoFollowPatternRequest(id), + handler: async () => await deleteAutoFollowPatternRequest(id), onSuccess(response, dispatch, getState) { /** * We can have 1 or more auto-follow pattern delete operation @@ -139,19 +172,21 @@ export const deleteAutoFollowPattern = (id) => // If we've just deleted a pattern we were looking at, we need to close the panel. const autoFollowPatternId = getSelectedAutoFollowPatternId('detail')(getState()); - if (response.itemsDeleted.includes(autoFollowPatternId)) { + if (autoFollowPatternId != null && response.itemsDeleted.includes(autoFollowPatternId)) { dispatch(selectDetailAutoFollowPattern(null)); } } }, }); -export const pauseAutoFollowPattern = (id) => - sendApiRequest({ +export const pauseAutoFollowPattern = ( + id: string | string[] +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_PAUSE, scope: `${scope}-pause`, status: API_STATUS.UPDATING, - handler: () => pauseAutoFollowPatternRequest(id), + handler: async () => await pauseAutoFollowPatternRequest(id), onSuccess: (response) => { /** * We can have 1 or more auto-follow pattern pause operations @@ -202,12 +237,14 @@ export const pauseAutoFollowPattern = (id) => }, }); -export const resumeAutoFollowPattern = (id) => - sendApiRequest({ +export const resumeAutoFollowPattern = ( + id: string | string[] +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ label: t.AUTO_FOLLOW_PATTERN_RESUME, scope: `${scope}-resume`, status: API_STATUS.UPDATING, - handler: () => resumeAutoFollowPatternRequest(id), + handler: async () => await resumeAutoFollowPatternRequest(id), onSuccess: (response) => { /** * We can have 1 or more auto-follow pattern resume operations diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/ccr.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/ccr.ts similarity index 73% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/ccr.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/ccr.ts index 39cf8345315f0..fbd827bef4b7e 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/ccr.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/ccr.ts @@ -5,14 +5,17 @@ * 2.0. */ +import type { AnyAction } from 'redux'; +import type { ThunkAction } from 'redux-thunk'; import { SECTIONS } from '../../constants'; import { loadAutoFollowStats as loadAutoFollowStatsRequest } from '../../services/api'; import * as t from '../action_types'; import { sendApiRequest } from './api'; +import type { CcrState } from '../reducers'; const { CCR_STATS: scope } = SECTIONS; -export const loadAutoFollowStats = () => +export const loadAutoFollowStats = (): ThunkAction, CcrState, undefined, AnyAction> => sendApiRequest({ label: t.AUTO_FOLLOW_STATS_LOAD, scope, diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/follower_index.ts similarity index 85% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/follower_index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/follower_index.ts index 0242874ec026b..74a0ce40941e0 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/follower_index.ts @@ -5,12 +5,17 @@ * 2.0. */ +import type { AnyAction } from 'redux'; +import type { ThunkAction } from 'redux-thunk'; import { i18n } from '@kbn/i18n'; - import { routing } from '../../services/routing'; import { getToasts } from '../../services/notifications'; import { SECTIONS, API_STATUS } from '../../constants'; import { + type FollowerIndexSaveBody, + type PauseFollowerIndexResponse, + type ResumeFollowerIndexResponse, + type UnfollowLeaderIndexResponse, loadFollowerIndices as loadFollowerIndicesRequest, getFollowerIndex as getFollowerIndexRequest, createFollowerIndex as createFollowerIndexRequest, @@ -21,21 +26,24 @@ import { } from '../../services/api'; import * as t from '../action_types'; import { sendApiRequest } from './api'; +import type { CcrState } from '../reducers'; import { getSelectedFollowerIndexId } from '../selectors'; const { FOLLOWER_INDEX: scope } = SECTIONS; -export const selectDetailFollowerIndex = (id) => ({ +export const selectDetailFollowerIndex = (id: string | null) => ({ type: t.FOLLOWER_INDEX_SELECT_DETAIL, payload: id, }); -export const selectEditFollowerIndex = (id) => ({ +export const selectEditFollowerIndex = (id: string | null) => ({ type: t.FOLLOWER_INDEX_SELECT_EDIT, payload: id, }); -export const loadFollowerIndices = (isUpdating = false) => +export const loadFollowerIndices = ( + isUpdating = false +): ThunkAction, CcrState, undefined, AnyAction> => sendApiRequest({ label: t.FOLLOWER_INDEX_LOAD, scope, @@ -43,15 +51,21 @@ export const loadFollowerIndices = (isUpdating = false) => handler: async () => await loadFollowerIndicesRequest(isUpdating), }); -export const getFollowerIndex = (id) => +export const getFollowerIndex = ( + id: string +): ThunkAction, CcrState, undefined, AnyAction> => sendApiRequest({ label: t.FOLLOWER_INDEX_GET, scope: `${scope}-get`, handler: async () => await getFollowerIndexRequest(id), }); -export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => - sendApiRequest({ +export const saveFollowerIndex = ( + name: string, + followerIndex: FollowerIndexSaveBody, + isUpdating = false +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ label: t.FOLLOWER_INDEX_CREATE, status: API_STATUS.SAVING, scope: `${scope}-save`, @@ -86,12 +100,14 @@ export const saveFollowerIndex = (name, followerIndex, isUpdating = false) => }, }); -export const pauseFollowerIndex = (id) => - sendApiRequest({ +export const pauseFollowerIndex = ( + id: string | string[] +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ label: t.FOLLOWER_INDEX_PAUSE, status: API_STATUS.SAVING, scope, - handler: async () => pauseFollowerIndexRequest(id), + handler: async () => await pauseFollowerIndexRequest(id), onSuccess(response, dispatch) { /** * We can have 1 or more follower index pause operation @@ -145,12 +161,14 @@ export const pauseFollowerIndex = (id) => }, }); -export const resumeFollowerIndex = (id) => - sendApiRequest({ +export const resumeFollowerIndex = ( + id: string | string[] +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ label: t.FOLLOWER_INDEX_RESUME, status: API_STATUS.SAVING, scope, - handler: async () => resumeFollowerIndexRequest(id), + handler: async () => await resumeFollowerIndexRequest(id), onSuccess(response, dispatch) { /** * We can have 1 or more follower index resume operation @@ -204,12 +222,14 @@ export const resumeFollowerIndex = (id) => }, }); -export const unfollowLeaderIndex = (id) => - sendApiRequest({ +export const unfollowLeaderIndex = ( + id: string | string[] +): ThunkAction, CcrState, undefined, AnyAction> => + sendApiRequest({ label: t.FOLLOWER_INDEX_UNFOLLOW, status: API_STATUS.DELETING, scope: `${scope}-delete`, - handler: async () => unfollowLeaderIndexRequest(id), + handler: async () => await unfollowLeaderIndexRequest(id), onSuccess(response, dispatch, getState) { /** * We can have 1 or more follower index unfollow operation @@ -282,7 +302,7 @@ export const unfollowLeaderIndex = (id) => // If we've just unfollowed a follower index we were looking at, we need to close the panel. const followerIndexId = getSelectedFollowerIndexId('detail')(getState()); - if (response.itemsUnfollowed.includes(followerIndexId)) { + if (followerIndexId != null && response.itemsUnfollowed.includes(followerIndexId)) { dispatch(selectDetailFollowerIndex(null)); } }, diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/index.ts similarity index 100% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/actions/index.ts diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.d.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.d.ts deleted file mode 100644 index 2e4db093be0f2..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export declare const ccrStore: any; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.ts similarity index 88% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.ts index 2e8f132f4034f..889811e761916 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/index.ts @@ -5,4 +5,5 @@ * 2.0. */ +export type { CcrState } from './reducers'; export { ccrStore, createCrossClusterReplicationStore } from './store'; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.js deleted file mode 100644 index 71a32be25c1fa..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SECTIONS, API_STATUS } from '../../constants'; -import * as t from '../action_types'; - -export const initialState = { - status: { - [SECTIONS.AUTO_FOLLOW_PATTERN]: API_STATUS.IDLE, - [SECTIONS.FOLLOWER_INDEX]: API_STATUS.IDLE, - }, - error: { - [SECTIONS.AUTO_FOLLOW_PATTERN]: null, - [SECTIONS.FOLLOWER_INDEX]: null, - }, -}; - -export const reducer = (state = initialState, action) => { - const payload = action.payload || {}; - const { scope, status, error } = payload; - - switch (action.type) { - case t.API_REQUEST_START: { - return { ...state, status: { ...state.status, [scope]: status } }; - } - case t.API_REQUEST_END: { - return { ...state, status: { ...state.status, [scope]: API_STATUS.IDLE } }; - } - case t.API_ERROR_SET: { - return { ...state, error: { ...state.error, [scope]: error } }; - } - default: - return state; - } -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.test.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.test.ts similarity index 84% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.test.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.test.ts index 0dce96e21ccb0..b512abcada539 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.test.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.test.ts @@ -24,20 +24,20 @@ describe('CCR Api reducers', () => { const scope = 'testSection'; it('API_REQUEST_START should set the Api status to "loading" on scope', () => { - const result = reducer(initialState, apiRequestStart({ scope })); + const result = reducer(initialState, apiRequestStart({ label: 'test', scope })); expect(result.status[scope]).toEqual(API_STATUS.LOADING); }); it('API_END should set the Api status to "idle" on scope', () => { - const updatedState = reducer(initialState, apiRequestStart({ scope })); - const result = reducer(updatedState, apiRequestEnd({ scope })); + const updatedState = reducer(initialState, apiRequestStart({ label: 'test', scope })); + const result = reducer(updatedState, apiRequestEnd({ label: 'test', scope })); expect(result.status[scope]).toEqual(API_STATUS.IDLE); }); it('API_ERROR_SET should set the Api error on scope', () => { - const error = { foo: 'bar' }; + const error = new Error('boom'); const result = reducer(initialState, setApiError({ error, scope })); expect(result.error[scope]).toBe(error); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.ts new file mode 100644 index 0000000000000..38c7f2cfb441d --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/api.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ApiStatus } from '../../../../common/types'; +import { SECTIONS, API_STATUS } from '../../constants'; +import * as t from '../action_types'; +import type { CcrApiError } from '../../services/http_error'; + +export interface ApiState { + status: Record; + error: Record; +} + +export interface ApiReducerAction { + type: string; + payload?: { + scope?: string; + status?: ApiStatus; + error?: CcrApiError | null; + label?: string; + }; +} + +export const initialState = { + status: { + [SECTIONS.AUTO_FOLLOW_PATTERN]: API_STATUS.IDLE, + [SECTIONS.FOLLOWER_INDEX]: API_STATUS.IDLE, + }, + error: { + [SECTIONS.AUTO_FOLLOW_PATTERN]: null, + [SECTIONS.FOLLOWER_INDEX]: null, + }, +} satisfies ApiState; + +export const reducer = (state: ApiState = initialState, action: ApiReducerAction): ApiState => { + const payload = action.payload; + const scope = payload?.scope; + + switch (action.type) { + case t.API_REQUEST_START: { + if (!scope) { + return state; + } + const status = payload?.status ?? API_STATUS.LOADING; + return { + ...state, + status: { ...state.status, [scope]: status }, + }; + } + case t.API_REQUEST_END: { + if (!scope) { + return state; + } + return { ...state, status: { ...state.status, [scope]: API_STATUS.IDLE } }; + } + case t.API_ERROR_SET: { + if (!scope) { + return state; + } + const error = payload?.error ?? null; + return { ...state, error: { ...state.error, [scope]: error } }; + } + default: + return state; + } +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js deleted file mode 100644 index 60e811d424cc8..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from '../action_types'; -import { arrayToObject } from '../../services/utils'; -import { getPrefixSuffixFromFollowPattern } from '../../services/auto_follow_pattern'; - -const initialState = { - byId: {}, - selectedDetailId: null, - selectedEditId: null, -}; - -const success = (action) => `${action}_SUCCESS`; - -const setActiveForIds = (ids, byId, active) => { - const shallowCopyByIds = { ...byId }; - ids.forEach((id) => { - shallowCopyByIds[id].active = active; - }); - return shallowCopyByIds; -}; - -const parseAutoFollowPattern = (autoFollowPattern) => { - // Extract prefix and suffix from follow index pattern - const { followIndexPatternPrefix, followIndexPatternSuffix } = getPrefixSuffixFromFollowPattern( - autoFollowPattern.followIndexPattern - ); - - return { ...autoFollowPattern, followIndexPatternPrefix, followIndexPatternSuffix }; -}; - -export const reducer = (state = initialState, action) => { - switch (action.type) { - case success(t.AUTO_FOLLOW_PATTERN_LOAD): { - return { - ...state, - byId: arrayToObject(action.payload.patterns.map(parseAutoFollowPattern), 'name'), - }; - } - case success(t.AUTO_FOLLOW_PATTERN_GET): { - return { - ...state, - byId: { ...state.byId, [action.payload.name]: parseAutoFollowPattern(action.payload) }, - }; - } - case t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL: { - return { ...state, selectedDetailId: action.payload }; - } - case t.AUTO_FOLLOW_PATTERN_SELECT_EDIT: { - return { ...state, selectedEditId: action.payload }; - } - case success(t.AUTO_FOLLOW_PATTERN_DELETE): { - const byId = { ...state.byId }; - const { itemsDeleted } = action.payload; - itemsDeleted.forEach((id) => delete byId[id]); - return { ...state, byId }; - } - case success(t.AUTO_FOLLOW_PATTERN_PAUSE): { - const { itemsPaused } = action.payload; - return { ...state, byId: setActiveForIds(itemsPaused, state.byId, false) }; - } - case success(t.AUTO_FOLLOW_PATTERN_RESUME): { - const { itemsResumed } = action.payload; - return { ...state, byId: setActiveForIds(itemsResumed, state.byId, true) }; - } - default: - return state; - } -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.ts new file mode 100644 index 0000000000000..236c3658333b8 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/auto_follow_pattern.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AutoFollowPattern } from '../../../../common/types'; +import * as t from '../action_types'; +import { arrayToObject } from '../../services/utils'; +import { getPrefixSuffixFromFollowPattern } from '../../services/auto_follow_pattern'; +import type { + DeleteAutoFollowPatternResponse, + PauseAutoFollowPatternResponse, + ResumeAutoFollowPatternResponse, +} from '../../services/api'; + +export interface ParsedAutoFollowPattern extends AutoFollowPattern { + followIndexPatternPrefix?: string; + followIndexPatternSuffix?: string; +} + +export interface AutoFollowPatternState { + byId: Record; + selectedDetailId: string | null; + selectedEditId: string | null; +} + +const initialState: AutoFollowPatternState = { + byId: {}, + selectedDetailId: null, + selectedEditId: null, +}; + +const AUTO_FOLLOW_PATTERN_LOAD_SUCCESS: `${typeof t.AUTO_FOLLOW_PATTERN_LOAD}_SUCCESS` = `${t.AUTO_FOLLOW_PATTERN_LOAD}_SUCCESS`; +const AUTO_FOLLOW_PATTERN_GET_SUCCESS: `${typeof t.AUTO_FOLLOW_PATTERN_GET}_SUCCESS` = `${t.AUTO_FOLLOW_PATTERN_GET}_SUCCESS`; +const AUTO_FOLLOW_PATTERN_DELETE_SUCCESS: `${typeof t.AUTO_FOLLOW_PATTERN_DELETE}_SUCCESS` = `${t.AUTO_FOLLOW_PATTERN_DELETE}_SUCCESS`; +const AUTO_FOLLOW_PATTERN_PAUSE_SUCCESS: `${typeof t.AUTO_FOLLOW_PATTERN_PAUSE}_SUCCESS` = `${t.AUTO_FOLLOW_PATTERN_PAUSE}_SUCCESS`; +const AUTO_FOLLOW_PATTERN_RESUME_SUCCESS: `${typeof t.AUTO_FOLLOW_PATTERN_RESUME}_SUCCESS` = `${t.AUTO_FOLLOW_PATTERN_RESUME}_SUCCESS`; + +interface LoadAutoFollowPatternsSuccessAction { + type: typeof AUTO_FOLLOW_PATTERN_LOAD_SUCCESS; + payload: { patterns: AutoFollowPattern[] }; +} + +interface GetAutoFollowPatternSuccessAction { + type: typeof AUTO_FOLLOW_PATTERN_GET_SUCCESS; + payload: AutoFollowPattern; +} + +interface SelectAutoFollowPatternDetailAction { + type: typeof t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL; + payload: string | null; +} + +interface SelectAutoFollowPatternEditAction { + type: typeof t.AUTO_FOLLOW_PATTERN_SELECT_EDIT; + payload: string | null; +} + +interface DeleteAutoFollowPatternSuccessAction { + type: typeof AUTO_FOLLOW_PATTERN_DELETE_SUCCESS; + payload: DeleteAutoFollowPatternResponse; +} + +interface PauseAutoFollowPatternSuccessAction { + type: typeof AUTO_FOLLOW_PATTERN_PAUSE_SUCCESS; + payload: PauseAutoFollowPatternResponse; +} + +interface ResumeAutoFollowPatternSuccessAction { + type: typeof AUTO_FOLLOW_PATTERN_RESUME_SUCCESS; + payload: ResumeAutoFollowPatternResponse; +} + +export type AutoFollowPatternReducerAction = + | LoadAutoFollowPatternsSuccessAction + | GetAutoFollowPatternSuccessAction + | SelectAutoFollowPatternDetailAction + | SelectAutoFollowPatternEditAction + | DeleteAutoFollowPatternSuccessAction + | PauseAutoFollowPatternSuccessAction + | ResumeAutoFollowPatternSuccessAction; + +const setActiveForIds = ( + ids: string[], + byId: Record, + active: boolean +): Record => { + const shallowCopyByIds = { ...byId }; + ids.forEach((id) => { + const pattern = byId[id]; + if (!pattern) { + return; + } + shallowCopyByIds[id] = { ...pattern, active }; + }); + return shallowCopyByIds; +}; + +const parseAutoFollowPattern = (autoFollowPattern: AutoFollowPattern): ParsedAutoFollowPattern => { + // Extract prefix and suffix from follow index pattern + const { followIndexPatternPrefix, followIndexPatternSuffix } = getPrefixSuffixFromFollowPattern( + autoFollowPattern.followIndexPattern + ); + + return { ...autoFollowPattern, followIndexPatternPrefix, followIndexPatternSuffix }; +}; + +export const reducer = ( + state: AutoFollowPatternState = initialState, + action: AutoFollowPatternReducerAction +): AutoFollowPatternState => { + switch (action.type) { + case AUTO_FOLLOW_PATTERN_LOAD_SUCCESS: { + const payload = action.payload; + return { + ...state, + byId: arrayToObject(payload.patterns.map(parseAutoFollowPattern), 'name'), + }; + } + case AUTO_FOLLOW_PATTERN_GET_SUCCESS: { + const payload = action.payload; + return { + ...state, + byId: { ...state.byId, [payload.name]: parseAutoFollowPattern(payload) }, + }; + } + case t.AUTO_FOLLOW_PATTERN_SELECT_DETAIL: { + return { ...state, selectedDetailId: action.payload }; + } + case t.AUTO_FOLLOW_PATTERN_SELECT_EDIT: { + return { ...state, selectedEditId: action.payload }; + } + case AUTO_FOLLOW_PATTERN_DELETE_SUCCESS: { + const payload = action.payload; + const byId = { ...state.byId }; + payload.itemsDeleted.forEach((id) => delete byId[id]); + return { ...state, byId }; + } + case AUTO_FOLLOW_PATTERN_PAUSE_SUCCESS: { + const payload = action.payload; + return { ...state, byId: setActiveForIds(payload.itemsPaused, state.byId, false) }; + } + case AUTO_FOLLOW_PATTERN_RESUME_SUCCESS: { + const payload = action.payload; + return { ...state, byId: setActiveForIds(payload.itemsResumed, state.byId, true) }; + } + default: + return state; + } +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/follower_index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/follower_index.js deleted file mode 100644 index b60ebffb34b35..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/follower_index.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from '../action_types'; -import { arrayToObject } from '../../services/utils'; - -const initialState = { - byId: {}, - selectedDetailId: null, - selectedEditId: null, -}; - -const success = (action) => `${action}_SUCCESS`; - -const parseFollowerIndex = (followerIndex) => { - // Extract status into boolean - return { ...followerIndex, isPaused: followerIndex.status === 'paused' }; -}; -export const reducer = (state = initialState, action) => { - switch (action.type) { - case success(t.FOLLOWER_INDEX_LOAD): { - return { - ...state, - byId: arrayToObject(action.payload.indices.map(parseFollowerIndex), 'name'), - }; - } - case success(t.FOLLOWER_INDEX_GET): { - return { - ...state, - byId: { ...state.byId, [action.payload.name]: parseFollowerIndex(action.payload) }, - }; - } - case t.FOLLOWER_INDEX_SELECT_DETAIL: { - return { ...state, selectedDetailId: action.payload }; - } - case t.FOLLOWER_INDEX_SELECT_EDIT: { - return { ...state, selectedEditId: action.payload }; - } - case success(t.FOLLOWER_INDEX_UNFOLLOW): { - const byId = { ...state.byId }; - const { itemsUnfollowed } = action.payload; - itemsUnfollowed.forEach((id) => delete byId[id]); - return { ...state, byId }; - } - default: - return state; - } -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/follower_index.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/follower_index.ts new file mode 100644 index 0000000000000..a161f96c6b8bd --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/follower_index.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FollowerIndex, FollowerIndexWithPausedStatus } from '../../../../common/types'; +import * as t from '../action_types'; +import { arrayToObject } from '../../services/utils'; +import type { UnfollowLeaderIndexResponse } from '../../services/api'; + +export interface FollowerIndexState { + byId: Record; + selectedDetailId: string | null; + selectedEditId: string | null; +} + +const initialState: FollowerIndexState = { + byId: {}, + selectedDetailId: null, + selectedEditId: null, +}; + +const FOLLOWER_INDEX_LOAD_SUCCESS: `${typeof t.FOLLOWER_INDEX_LOAD}_SUCCESS` = `${t.FOLLOWER_INDEX_LOAD}_SUCCESS`; +const FOLLOWER_INDEX_GET_SUCCESS: `${typeof t.FOLLOWER_INDEX_GET}_SUCCESS` = `${t.FOLLOWER_INDEX_GET}_SUCCESS`; +const FOLLOWER_INDEX_UNFOLLOW_SUCCESS: `${typeof t.FOLLOWER_INDEX_UNFOLLOW}_SUCCESS` = `${t.FOLLOWER_INDEX_UNFOLLOW}_SUCCESS`; + +interface LoadFollowerIndicesSuccessAction { + type: typeof FOLLOWER_INDEX_LOAD_SUCCESS; + payload: { indices: FollowerIndex[] }; +} + +interface GetFollowerIndexSuccessAction { + type: typeof FOLLOWER_INDEX_GET_SUCCESS; + payload: FollowerIndex; +} + +interface SelectFollowerIndexDetailAction { + type: typeof t.FOLLOWER_INDEX_SELECT_DETAIL; + payload: string | null; +} + +interface SelectFollowerIndexEditAction { + type: typeof t.FOLLOWER_INDEX_SELECT_EDIT; + payload: string | null; +} + +interface UnfollowLeaderIndexSuccessAction { + type: typeof FOLLOWER_INDEX_UNFOLLOW_SUCCESS; + payload: UnfollowLeaderIndexResponse; +} + +export type FollowerIndexReducerAction = + | LoadFollowerIndicesSuccessAction + | GetFollowerIndexSuccessAction + | SelectFollowerIndexDetailAction + | SelectFollowerIndexEditAction + | UnfollowLeaderIndexSuccessAction; + +const parseFollowerIndex = (followerIndex: FollowerIndex): FollowerIndexWithPausedStatus => { + // Extract status into boolean + return { ...followerIndex, isPaused: followerIndex.status === 'paused' }; +}; + +export const reducer = ( + state: FollowerIndexState = initialState, + action: FollowerIndexReducerAction +): FollowerIndexState => { + switch (action.type) { + case FOLLOWER_INDEX_LOAD_SUCCESS: { + const payload = action.payload; + return { + ...state, + byId: arrayToObject(payload.indices.map(parseFollowerIndex), 'name'), + }; + } + case FOLLOWER_INDEX_GET_SUCCESS: { + const payload = action.payload; + return { + ...state, + byId: { ...state.byId, [payload.name]: parseFollowerIndex(payload) }, + }; + } + case t.FOLLOWER_INDEX_SELECT_DETAIL: { + return { ...state, selectedDetailId: action.payload }; + } + case t.FOLLOWER_INDEX_SELECT_EDIT: { + return { ...state, selectedEditId: action.payload }; + } + case FOLLOWER_INDEX_UNFOLLOW_SUCCESS: { + const payload = action.payload; + const byId = { ...state.byId }; + payload.itemsUnfollowed.forEach((id) => delete byId[id]); + return { ...state, byId }; + } + default: + return state; + } +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/index.ts similarity index 92% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/index.ts index 206c19564d0ba..d688e61ed10c2 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/index.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/index.ts @@ -17,3 +17,5 @@ export const ccr = combineReducers({ api, stats, }); + +export type CcrState = ReturnType; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/stats.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/stats.js deleted file mode 100644 index 41d6fb2823ff3..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/stats.js +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from '../action_types'; -import { parseAutoFollowErrors } from '../../services/auto_follow_errors'; - -const initialState = { - autoFollow: null, -}; - -const success = (action) => `${action}_SUCCESS`; - -export const reducer = (state = initialState, action) => { - switch (action.type) { - case success(t.AUTO_FOLLOW_STATS_LOAD): { - const { recentAutoFollowErrors, ...rest } = action.payload; - return { - ...state, - autoFollow: { - ...rest, - recentAutoFollowErrors: parseAutoFollowErrors(recentAutoFollowErrors), - }, - }; - } - default: - return state; - } -}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/stats.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/stats.ts new file mode 100644 index 0000000000000..c72ca86534864 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/reducers/stats.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AutoFollowStats } from '../../../../common/types'; +import * as t from '../action_types'; +import type { ParsedAutoFollowError } from '../../services/auto_follow_errors'; +import { parseAutoFollowErrors } from '../../services/auto_follow_errors'; + +export type AutoFollowStatsWithParsedErrors = Omit & { + recentAutoFollowErrors: Record; +}; + +export interface StatsState { + autoFollow: AutoFollowStatsWithParsedErrors | null; +} + +const initialState: StatsState = { + autoFollow: null, +}; + +const AUTO_FOLLOW_STATS_LOAD_SUCCESS: `${typeof t.AUTO_FOLLOW_STATS_LOAD}_SUCCESS` = `${t.AUTO_FOLLOW_STATS_LOAD}_SUCCESS`; + +export interface LoadAutoFollowStatsSuccessAction { + type: typeof AUTO_FOLLOW_STATS_LOAD_SUCCESS; + payload: AutoFollowStats; +} + +export type StatsReducerAction = LoadAutoFollowStatsSuccessAction; + +export const reducer = ( + state: StatsState = initialState, + action: StatsReducerAction +): StatsState => { + switch (action.type) { + case AUTO_FOLLOW_STATS_LOAD_SUCCESS: { + const { recentAutoFollowErrors, ...rest } = action.payload; + return { + ...state, + autoFollow: { + ...rest, + recentAutoFollowErrors: parseAutoFollowErrors(recentAutoFollowErrors), + }, + }; + } + default: + return state; + } +}; diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.test.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.test.ts new file mode 100644 index 0000000000000..5b66ea31d20f4 --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser'; +import { isApiAuthorized, getApiError } from '.'; +import { API_STATUS } from '../../constants'; +import type { CcrState } from '../reducers'; +import type { CcrApiError } from '../../services/http_error'; + +const makeHttpFetchError = ({ + statusCode, + responseStatus, + message = 'boom', +}: { + statusCode?: number; + responseStatus?: number; + message?: string; +}): IHttpFetchError => { + const body = { + message, + ...(statusCode !== undefined ? { statusCode } : {}), + } as unknown as ResponseErrorBody; + const error = Object.assign(new Error(message), { + body, + request: { path: '/fake' } as unknown as IHttpFetchError['request'], + ...(responseStatus !== undefined + ? { + response: { + status: responseStatus, + } as unknown as IHttpFetchError['response'], + } + : {}), + }); + return error as unknown as IHttpFetchError; +}; + +const makeState = (scope: string, error: CcrApiError | null): CcrState => + ({ + api: { + status: {}, + error: error ? { [scope]: error } : {}, + }, + } as unknown as CcrState); + +describe('store/selectors/isApiAuthorized', () => { + const scope = 'autoFollowPattern'; + + it('returns true when there is no error for the scope', () => { + const state = makeState(scope, null); + expect(isApiAuthorized(scope)(state)).toBe(true); + }); + + it('returns false for a 403 HttpFetchError exposed via response.status', () => { + const state = makeState(scope, makeHttpFetchError({ responseStatus: 403 })); + expect(isApiAuthorized(scope)(state)).toBe(false); + }); + + it('returns false for a 403 HttpFetchError exposed via body.statusCode', () => { + const state = makeState(scope, makeHttpFetchError({ statusCode: 403 })); + expect(isApiAuthorized(scope)(state)).toBe(false); + }); + + it('returns true for non-403 HttpFetchErrors (e.g. 404, 500)', () => { + const state404 = makeState(scope, makeHttpFetchError({ statusCode: 404 })); + const state500 = makeState(scope, makeHttpFetchError({ responseStatus: 500 })); + expect(isApiAuthorized(scope)(state404)).toBe(true); + expect(isApiAuthorized(scope)(state500)).toBe(true); + }); + + it('returns true for a plain Error without a status (treats as non-auth error)', () => { + const state = makeState(scope, new Error('unexpected')); + expect(isApiAuthorized(scope)(state)).toBe(true); + }); + + // `HttpFetchError` exposes status via `error.response.status` or + // `error.body.statusCode`, not via a top-level `error.status`. The selector + // must read from those fields — reading a top-level `error.status` would + // always be `undefined`, leaving the unauthorized UI branch unreachable. + it('does not read a non-existent top-level error.status', () => { + const error = makeHttpFetchError({ responseStatus: 403 }); + // Even if future code accidentally attaches a misleading top-level `status`, + // the selector must keep using the real HttpFetchError fields. + Object.assign(error, { status: 200 }); + const state = makeState(scope, error as unknown as CcrApiError); + expect(isApiAuthorized(scope)(state)).toBe(false); + }); +}); + +describe('store/selectors/getApiError', () => { + it('returns null when there is no error for the scope', () => { + const state = makeState('other', null); + expect(getApiError('absent')(state)).toBeNull(); + }); + + it('returns the stored error for the scope', () => { + const error = new Error('boom'); + const state = makeState('autoFollowPattern', error); + expect(getApiError('autoFollowPattern')(state)).toBe(error); + }); +}); + +describe('store/selectors smoke', () => { + it('API_STATUS constants are importable (sanity check for reducer wiring)', () => { + expect(API_STATUS.IDLE).toBeDefined(); + }); +}); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.ts similarity index 51% rename from x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.js rename to x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.ts index b907999f7be56..4d82f74507132 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.js +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/selectors/index.ts @@ -6,53 +6,62 @@ */ import { createSelector } from 'reselect'; +import type { FollowerIndexWithPausedStatus } from '../../../../common/types'; import { objectToArray } from '../../services/utils'; import { API_STATUS } from '../../constants'; +import type { ParsedAutoFollowError } from '../../services/auto_follow_errors'; +import type { CcrApiError } from '../../services/http_error'; +import { getErrorStatus } from '../../services/http_error'; +import type { CcrState } from '../reducers'; +import type { ParsedAutoFollowPattern } from '../reducers/auto_follow_pattern'; +import type { AutoFollowStatsWithParsedErrors } from '../reducers/stats'; + +export type AutoFollowPatternWithErrors = ParsedAutoFollowPattern & { + errors: ParsedAutoFollowError[]; +}; // Api -export const getApiState = (state) => state.api; -export const getApiStatus = (scope) => - createSelector(getApiState, (apiState) => apiState.status[scope] || API_STATUS.IDLE); -export const getApiError = (scope) => - createSelector(getApiState, (apiState) => apiState.error[scope]); -export const isApiAuthorized = (scope) => - createSelector(getApiError(scope), (error) => { - if (!error) { - return true; - } - return error.status !== 403; +export const getApiState = (state: CcrState) => state.api; +export const getApiStatus = (scope: string) => + createSelector(getApiState, (apiState) => apiState.status[scope] ?? API_STATUS.IDLE); +export const getApiError = (scope: string) => + createSelector(getApiState, (apiState) => apiState.error[scope] ?? null); +export const isApiAuthorized = (scope: string) => + createSelector(getApiError(scope), (error: CcrApiError | null | undefined) => { + const status = getErrorStatus(error); + return status !== 403; }); // Stats -export const getStatsState = (state) => state.stats; +export const getStatsState = (state: CcrState) => state.stats; export const getAutoFollowStats = createSelector( getStatsState, - (statsState) => statsState.autoFollow + (statsState): AutoFollowStatsWithParsedErrors | null => statsState.autoFollow ); // Auto-follow pattern -export const getAutoFollowPatternState = (state) => state.autoFollowPattern; +export const getAutoFollowPatternState = (state: CcrState) => state.autoFollowPattern; export const getAutoFollowPatterns = createSelector( getAutoFollowPatternState, (autoFollowPatternsState) => autoFollowPatternsState.byId ); -export const getSelectedAutoFollowPatternId = (view = 'detail') => +export const getSelectedAutoFollowPatternId = (view: 'detail' | 'edit' = 'detail') => createSelector(getAutoFollowPatternState, (autoFollowPatternsState) => view === 'detail' ? autoFollowPatternsState.selectedDetailId : autoFollowPatternsState.selectedEditId ); -export const getSelectedAutoFollowPattern = (view = 'detail') => +export const getSelectedAutoFollowPattern = (view: 'detail' | 'edit' = 'detail') => createSelector( getAutoFollowPatternState, getAutoFollowStats, - (autoFollowPatternsState, autoFollowStatsState) => { + (autoFollowPatternsState, autoFollowStatsState): AutoFollowPatternWithErrors | null => { const propId = view === 'detail' ? 'selectedDetailId' : 'selectedEditId'; - if (!autoFollowPatternsState[propId]) { + const id = autoFollowPatternsState[propId]; + if (!id) { return null; } - const id = autoFollowPatternsState[propId]; const autoFollowPattern = autoFollowPatternsState.byId[id]; // Check if any error and merge them on the auto-follow pattern @@ -67,24 +76,28 @@ export const getListAutoFollowPatterns = createSelector( ); // Follower index -export const getFollowerIndexState = (state) => state.followerIndex; +export const getFollowerIndexState = (state: CcrState) => state.followerIndex; export const getFollowerIndices = createSelector( getFollowerIndexState, (followerIndexState) => followerIndexState.byId ); -export const getSelectedFollowerIndexId = (view = 'detail') => +export const getSelectedFollowerIndexId = (view: 'detail' | 'edit' = 'detail') => createSelector(getFollowerIndexState, (followerIndexState) => view === 'detail' ? followerIndexState.selectedDetailId : followerIndexState.selectedEditId ); -export const getSelectedFollowerIndex = (view = 'detail') => - createSelector(getFollowerIndexState, (followerIndexState) => { - const propId = view === 'detail' ? 'selectedDetailId' : 'selectedEditId'; +export const getSelectedFollowerIndex = (view: 'detail' | 'edit' = 'detail') => + createSelector( + getFollowerIndexState, + (followerIndexState): FollowerIndexWithPausedStatus | null => { + const propId = view === 'detail' ? 'selectedDetailId' : 'selectedEditId'; - if (!followerIndexState[propId]) { - return null; + const id = followerIndexState[propId]; + if (!id) { + return null; + } + return followerIndexState.byId[id]; } - return followerIndexState.byId[followerIndexState[propId]]; - }); + ); export const getListFollowerIndices = createSelector(getFollowerIndices, (followerIndices) => objectToArray(followerIndices) ); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/store.js b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/store.js deleted file mode 100644 index 2467aec43318a..0000000000000 --- a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/store.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { applyMiddleware, compose, createStore } from 'redux'; -import thunk from 'redux-thunk'; - -import { ccr } from './reducers'; - -export function createCrossClusterReplicationStore(initialState = {}) { - const enhancers = [applyMiddleware(thunk)]; - - if (window.__REDUX_DEVTOOLS_EXTENSION__) { - enhancers.push(window.__REDUX_DEVTOOLS_EXTENSION__()); - } - return createStore(ccr, initialState, compose(...enhancers)); -} - -// Singleton for production use -export const ccrStore = createCrossClusterReplicationStore(); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/store.ts b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/store.ts new file mode 100644 index 0000000000000..f4c92daa552bc --- /dev/null +++ b/x-pack/platform/plugins/private/cross_cluster_replication/public/app/store/store.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Store, StoreEnhancer } from 'redux'; +import { applyMiddleware, compose, createStore } from 'redux'; +import thunk from 'redux-thunk'; + +import { ccr, type CcrState } from './reducers'; + +declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION__?: () => StoreEnhancer; + } +} + +export function createCrossClusterReplicationStore( + initialState: Partial = {} +): Store { + const middleware = applyMiddleware(thunk); + const devtools = + typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION__ + ? window.__REDUX_DEVTOOLS_EXTENSION__() + : undefined; + const enhancer = devtools ? compose(middleware, devtools) : middleware; + + return createStore(ccr, initialState, enhancer); +} + +// Singleton for production use +export const ccrStore = createCrossClusterReplicationStore(); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_create_route.ts b/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_create_route.ts index caf8408d5b0e0..c88642a9b631a 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_create_route.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_create_route.ts @@ -8,7 +8,6 @@ import { schema } from '@kbn/config-schema'; import type { CcrFollowRequest } from '@elastic/elasticsearch/lib/api/types'; import { serializeFollowerIndex } from '../../../../common/services/follower_index_serialization'; -import type { FollowerIndex } from '../../../../common/types'; import { addBasePath } from '../../../services'; import { removeEmptyFields } from '../../../../common/services/utils'; import type { RouteDependencies } from '../../../types'; @@ -52,8 +51,8 @@ export const registerCreateRoute = ({ }, license.guardApiRoute(async (context, request, response) => { const { client } = (await context.core).elasticsearch; - const { name, ...rest } = request.body; - const body = removeEmptyFields(serializeFollowerIndex(rest as FollowerIndex)); + const { name, ...followerIndex } = request.body; + const body = removeEmptyFields(serializeFollowerIndex(followerIndex)); try { const responseBody = await client.asCurrentUser.ccr.follow({ diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.test.ts b/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.test.ts index ffb7890cb4493..26981f5dd3f96 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.test.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.test.ts @@ -61,18 +61,16 @@ describe('[CCR API] Update follower index', () => { expect(response.payload).toEqual({ index: 'foo', - body: { - max_outstanding_read_requests: 1, - max_outstanding_write_requests: 1, - max_read_request_operation_count: 1, - max_read_request_size: '1b', - max_retry_delay: '1s', - max_write_buffer_count: 1, - max_write_buffer_size: '1b', - max_write_request_operation_count: 1, - max_write_request_size: '1b', - read_poll_timeout: '1s', - }, + max_outstanding_read_requests: 1, + max_outstanding_write_requests: 1, + max_read_request_operation_count: 1, + max_read_request_size: '1b', + max_retry_delay: '1s', + max_write_buffer_count: 1, + max_write_buffer_size: '1b', + max_write_request_operation_count: 1, + max_write_request_size: '1b', + read_poll_timeout: '1s', }); }); }); diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.ts b/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.ts index b488d760ede3c..62e5d64d3b8ac 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.ts +++ b/x-pack/platform/plugins/private/cross_cluster_replication/server/routes/api/follower_index/register_update_route.ts @@ -74,13 +74,13 @@ export const registerUpdateRoute = ({ } // Resume follower - const body = removeEmptyFields( + const resumeParams = removeEmptyFields( serializeAdvancedSettings(request.body as FollowerIndexAdvancedSettings) ); const responseBody = await client.asCurrentUser.ccr.resumeFollow({ index: id, - body, + ...resumeParams, }); return response.ok({ diff --git a/x-pack/platform/plugins/private/cross_cluster_replication/tsconfig.json b/x-pack/platform/plugins/private/cross_cluster_replication/tsconfig.json index 383c6df2a9f6f..d8f484ed21302 100644 --- a/x-pack/platform/plugins/private/cross_cluster_replication/tsconfig.json +++ b/x-pack/platform/plugins/private/cross_cluster_replication/tsconfig.json @@ -33,6 +33,7 @@ "@kbn/licensing-types", "@kbn/data-views-plugin", "@kbn/index-management-shared-types", + "@kbn/core-http-browser", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/test/accessibility/apps/group3/cross_cluster_replication.ts b/x-pack/platform/test/accessibility/apps/group3/cross_cluster_replication.ts index 9e41487c8d189..6ea06c4249cdb 100644 --- a/x-pack/platform/test/accessibility/apps/group3/cross_cluster_replication.ts +++ b/x-pack/platform/test/accessibility/apps/group3/cross_cluster_replication.ts @@ -82,10 +82,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('auto follower index page ', async () => { await PageObjects.crossClusterReplication.clickAutoFollowerPatternButton(); await a11y.testAppSnapshot(); - await PageObjects.crossClusterReplication.createAutoFollowerPattern( - autoFollower, - 'logstash*' - ); + await es.transport.request({ + method: 'PUT', + path: `/_ccr/auto_follow/${encodeURIComponent(autoFollower)}`, + body: { + remote_cluster: remoteName, + leader_index_patterns: ['logstash*'], + follow_index_pattern: '{{leader_index}}', + }, + }); + await PageObjects.common.navigateToApp('crossClusterReplication'); + await PageObjects.crossClusterReplication.clickAutoFollowerTab(); + await PageObjects.crossClusterReplication.openAutoFollowerPatternDetails(autoFollower); }); it('auto follower index flyout', async () => { await a11y.testAppSnapshot(); diff --git a/x-pack/platform/test/functional/page_objects/cross_cluster_replication_page.ts b/x-pack/platform/test/functional/page_objects/cross_cluster_replication_page.ts index 8daf4d0ac5398..cfe2be55d4c40 100644 --- a/x-pack/platform/test/functional/page_objects/cross_cluster_replication_page.ts +++ b/x-pack/platform/test/functional/page_objects/cross_cluster_replication_page.ts @@ -11,6 +11,7 @@ export function CrossClusterReplicationPageProvider({ getService }: FtrProviderC const testSubjects = getService('testSubjects'); const retry = getService('retry'); const comboBox = getService('comboBox'); + const find = getService('find'); return { async appTitleText() { @@ -69,6 +70,22 @@ export function CrossClusterReplicationPageProvider({ getService }: FtrProviderC return await testSubjects.isDisplayed('settingsValues'); }); }, + async openAutoFollowerPatternDetails(name: string) { + await retry.waitForWithTimeout('auto-follow pattern to be listed', 20000, async () => { + const links = await find.allByCssSelector('[data-test-subj="autoFollowPatternLink"]'); + for (const link of links) { + if ((await link.getVisibleText()) === name) { + await link.click(); + return true; + } + } + return false; + }); + + await retry.waitForWithTimeout('auto-follow pattern details to show up', 20000, async () => { + return await testSubjects.isDisplayed('settingsValues'); + }); + }, async clickAdvancedSettingsToggle() { await testSubjects.click('advancedSettingsToggle'); },