From 6b7780df2ca197296245385a2d224e204de0e013 Mon Sep 17 00:00:00 2001 From: Ilya Nikokoshev Date: Wed, 11 Dec 2024 16:04:45 +0100 Subject: [PATCH] [Automatic Import] Fix the enter bug (#199894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Release Notes Fixes the bug where pressing Enter reloaded the Automatic Import. ## Summary - Fixes #198238 - Adds/fixes telemetry for CEL events. - Refactors navigation functionality. - Adds extensive unit tests and a Cypress test for it. ## Details When the user presses the Enter inside our input field, the expected action is to send the form, in this case completing the step. However, previously the form submission would instead lead to reloading the whole Automatic Import page. In this PR we capture the form submission event and bubble it up as `completeStep` to the main component. We also move the implementation from the `Footer` up to this main component `CreateIntegrationAssistant`. This helps collect all the details about the step order in one place and refactor this logic. As a result, pressing `Enter` in any field now either - Is processed by the field itself (in case of multi-line fields); - Leads to the same action as pressing the "Next" button (desired result); or - Does nothing (e.g. in the inputs in the "Define data stream and upload logs" group – the reason for this is unclear). We add CEL-specific telemetry identifiers so that telemetry for step 5 is not always reported as `Deploy Step`. We also rename a bunch of stuff that was named `...StepReady` into `...StepReadyToComplete` as the previous name was ambiguous. To demonstrate this ambiguity we've enlisted the help of GPT 4o: SCR-20241125-tiaa ## Testing We provide a Cypress test for Enter behavior: pressing it on the "integration title" input should let the flow proceed to the next step. This test fails on `main`. We also provide unit tests for all steps of navigation functionality in `x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx`: Co-authored-by: Elastic Machine (cherry picked from commit d8bb72ebfdb1514f1fc03857d4c4e02f70fb7403) --- .../create_integration_assistant.test.tsx | 678 +++++++++++++++++- .../create_integration_assistant.tsx | 138 ++-- .../footer/footer.test.tsx | 241 +------ .../footer/footer.tsx | 87 +-- .../mocks/state.ts | 1 + .../create_integration_assistant/state.ts | 1 + .../steps/cel_input_step/cel_input_step.tsx | 11 +- .../steps/cel_input_step/is_step_ready.ts | 2 +- .../connector_step/connector_selector.tsx | 100 +-- .../steps/connector_step/connector_step.tsx | 81 ++- .../steps/connector_step/is_step_ready.ts | 2 +- .../data_stream_step/data_stream_step.tsx | 79 +- .../steps/data_stream_step/is_step_ready.ts | 2 +- .../integration_step/integration_step.tsx | 11 +- .../steps/integration_step/is_step_ready.ts | 2 +- .../steps/review_cel_step/is_step_ready.ts | 2 +- .../steps/review_cel_step/review_cel_step.tsx | 17 +- .../steps/review_step/is_step_ready.ts | 2 +- .../create_integration/telemetry.tsx | 14 +- 19 files changed, 976 insertions(+), 495 deletions(-) diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx index ca4d50958005d..133da383e4e99 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.test.tsx @@ -6,11 +6,13 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, act } from '@testing-library/react'; import { TestProvider } from '../../../mocks/test_provider'; import { CreateIntegrationAssistant } from './create_integration_assistant'; import type { State } from './state'; import { ExperimentalFeaturesService } from '../../../services'; +import { mockReportEvent } from '../../../services/telemetry/mocks/service'; +import { TelemetryEventType } from '../../../services/telemetry/types'; export const defaultInitialState: State = { step: 1, @@ -20,6 +22,7 @@ export const defaultInitialState: State = { hasCelInput: false, result: undefined, }; + const mockInitialState = jest.fn((): State => defaultInitialState); jest.mock('./state', () => ({ ...jest.requireActual('./state'), @@ -39,39 +42,45 @@ const mockCelInputStep = jest.fn(() =>
const mockReviewCelStep = jest.fn(() =>
); const mockDeployStep = jest.fn(() =>
); -const mockIsConnectorStepReady = jest.fn(); -const mockIsIntegrationStepReady = jest.fn(); -const mockIsDataStreamStepReady = jest.fn(); -const mockIsReviewStepReady = jest.fn(); -const mockIsCelInputStepReady = jest.fn(); -const mockIsCelReviewStepReady = jest.fn(); +const mockIsConnectorStepReadyToComplete = jest.fn(); +const mockIsIntegrationStepReadyToComplete = jest.fn(); +const mockIsDataStreamStepReadyToComplete = jest.fn(); +const mockIsReviewStepReadyToComplete = jest.fn(); +const mockIsCelInputStepReadyToComplete = jest.fn(); +const mockIsCelReviewStepReadyToComplete = jest.fn(); jest.mock('./steps/connector_step', () => ({ ConnectorStep: () => mockConnectorStep(), - isConnectorStepReady: () => mockIsConnectorStepReady(), + isConnectorStepReadyToComplete: () => mockIsConnectorStepReadyToComplete(), })); jest.mock('./steps/integration_step', () => ({ IntegrationStep: () => mockIntegrationStep(), - isIntegrationStepReady: () => mockIsIntegrationStepReady(), + isIntegrationStepReadyToComplete: () => mockIsIntegrationStepReadyToComplete(), })); jest.mock('./steps/data_stream_step', () => ({ DataStreamStep: () => mockDataStreamStep(), - isDataStreamStepReady: () => mockIsDataStreamStepReady(), + isDataStreamStepReadyToComplete: () => mockIsDataStreamStepReadyToComplete(), })); jest.mock('./steps/review_step', () => ({ ReviewStep: () => mockReviewStep(), - isReviewStepReady: () => mockIsReviewStepReady(), + isReviewStepReadyToComplete: () => mockIsReviewStepReadyToComplete(), })); jest.mock('./steps/cel_input_step', () => ({ CelInputStep: () => mockCelInputStep(), - isCelInputStepReady: () => mockIsCelInputStepReady(), + isCelInputStepReadyToComplete: () => mockIsCelInputStepReadyToComplete(), })); jest.mock('./steps/review_cel_step', () => ({ ReviewCelStep: () => mockReviewCelStep(), - isCelReviewStepReady: () => mockIsCelReviewStepReady(), + isCelReviewStepReadyToComplete: () => mockIsCelReviewStepReadyToComplete(), })); jest.mock('./steps/deploy_step', () => ({ DeployStep: () => mockDeployStep() })); +const mockNavigate = jest.fn(); +jest.mock('../../../common/hooks/use_navigate', () => ({ + ...jest.requireActual('../../../common/hooks/use_navigate'), + useNavigate: () => mockNavigate, +})); + const renderIntegrationAssistant = () => render(, { wrapper: TestProvider }); @@ -89,19 +98,116 @@ describe('CreateIntegration', () => { mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 1 }); }); - it('should render connector', () => { + it('shoud report telemetry for assistant open', () => { + renderIntegrationAssistant(); + expect(mockReportEvent).toHaveBeenCalledWith(TelemetryEventType.IntegrationAssistantOpen, { + sessionId: expect.any(String), + }); + }); + + it('should render connector step', () => { const result = renderIntegrationAssistant(); expect(result.queryByTestId('connectorStepMock')).toBeInTheDocument(); }); - it('should call isConnectorStepReady', () => { + it('should call isConnectorStepReadyToComplete', () => { renderIntegrationAssistant(); - expect(mockIsConnectorStepReady).toHaveBeenCalled(); + expect(mockIsConnectorStepReadyToComplete).toHaveBeenCalled(); + }); + + it('should show "Next" on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent('Next'); + }); + + describe('when connector step is not done', () => { + beforeEach(() => { + mockIsConnectorStepReadyToComplete.mockReturnValue(false); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeDisabled(); + }); + + it('should still enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should still enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + }); + + describe('when connector step is done', () => { + beforeEach(() => { + mockIsConnectorStepReadyToComplete.mockReturnValue(true); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + + it('should enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + describe('when next button is clicked', () => { + beforeEach(() => { + const result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-nextButton').click(); + }); + }); + + it('should report telemetry for connector step completion', () => { + expect(mockReportEvent).toHaveBeenCalledWith( + TelemetryEventType.IntegrationAssistantStepComplete, + { + sessionId: expect.any(String), + step: 1, + stepName: 'Connector Step', + durationMs: expect.any(Number), + sessionElapsedTime: expect.any(Number), + } + ); + }); + }); + }); + + describe('when back button is clicked', () => { + let result: ReturnType; + beforeEach(() => { + result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-backButton').click(); + }); + }); + + it('should not report telemetry', () => { + expect(mockReportEvent).not.toHaveBeenCalled(); + }); + + it('should navigate to the landing page', () => { + expect(mockNavigate).toHaveBeenCalledWith('landing'); + }); }); }); describe('when step is 2', () => { beforeEach(() => { + mockIsConnectorStepReadyToComplete.mockReturnValue(true); mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 2 }); }); @@ -110,14 +216,109 @@ describe('CreateIntegration', () => { expect(result.queryByTestId('integrationStepMock')).toBeInTheDocument(); }); - it('should call isIntegrationStepReady', () => { + it('should call isIntegrationStepReadyToComplete', () => { renderIntegrationAssistant(); - expect(mockIsIntegrationStepReady).toHaveBeenCalled(); + expect(mockIsIntegrationStepReadyToComplete).toHaveBeenCalled(); + }); + + it('should show "Next" on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent('Next'); + }); + + describe('when integration step is not done', () => { + beforeEach(() => { + mockIsIntegrationStepReadyToComplete.mockReturnValue(false); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeDisabled(); + }); + + it('should still enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should still enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + }); + + describe('when integration step is done', () => { + beforeEach(() => { + mockIsIntegrationStepReadyToComplete.mockReturnValue(true); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + + it('should enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + describe('when next button is clicked', () => { + beforeEach(() => { + const result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-nextButton').click(); + }); + }); + + it('should report telemetry for integration step completion', () => { + expect(mockReportEvent).toHaveBeenCalledWith( + TelemetryEventType.IntegrationAssistantStepComplete, + { + sessionId: expect.any(String), + step: 2, + stepName: 'Integration Step', + durationMs: expect.any(Number), + sessionElapsedTime: expect.any(Number), + } + ); + }); + }); + }); + + describe('when back button is clicked', () => { + let result: ReturnType; + beforeEach(() => { + result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-backButton').click(); + }); + }); + + it('should not report telemetry', () => { + expect(mockReportEvent).not.toHaveBeenCalled(); + }); + + it('should show connector step', () => { + expect(result.queryByTestId('connectorStepMock')).toBeInTheDocument(); + }); + + it('should enable the next button', () => { + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); }); }); describe('when step is 3', () => { beforeEach(() => { + mockIsConnectorStepReadyToComplete.mockReturnValue(true); + mockIsIntegrationStepReadyToComplete.mockReturnValue(true); mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 3 }); }); @@ -126,9 +327,116 @@ describe('CreateIntegration', () => { expect(result.queryByTestId('dataStreamStepMock')).toBeInTheDocument(); }); - it('should call isDataStreamStepReady', () => { + it('should call isDataStreamStepReadyToComplete', () => { renderIntegrationAssistant(); - expect(mockIsDataStreamStepReady).toHaveBeenCalled(); + expect(mockIsDataStreamStepReadyToComplete).toHaveBeenCalled(); + }); + + it('should show "Analyze logs" on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent('Analyze logs'); + }); + + describe('when data stream step is not done', () => { + beforeEach(() => { + mockIsDataStreamStepReadyToComplete.mockReturnValue(false); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeDisabled(); + }); + + it('should still enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should still enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + }); + + describe('when data stream step is done', () => { + beforeEach(() => { + mockIsDataStreamStepReadyToComplete.mockReturnValue(true); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + + it('should enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + describe('when next button is clicked', () => { + beforeEach(() => { + const result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-nextButton').click(); + }); + }); + + it('should report telemetry for data stream step completion', () => { + expect(mockReportEvent).toHaveBeenCalledWith( + TelemetryEventType.IntegrationAssistantStepComplete, + { + sessionId: expect.any(String), + step: 3, + stepName: 'DataStream Step', + durationMs: expect.any(Number), + sessionElapsedTime: expect.any(Number), + } + ); + }); + + it('should show loader on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('generatingLoader')).toBeInTheDocument(); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + // Not sure why there are two buttons when testing. + const nextButton = result + .getAllByTestId('buttonsFooter-nextButton') + .filter((button) => button.textContent !== 'Next')[0]; + expect(nextButton).toBeDisabled(); + }); + }); + }); + + describe('when back button is clicked', () => { + let result: ReturnType; + beforeEach(() => { + result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-backButton').click(); + }); + }); + + it('should not report telemetry', () => { + expect(mockReportEvent).not.toHaveBeenCalled(); + }); + + it('should show integration step', () => { + expect(result.queryByTestId('integrationStepMock')).toBeInTheDocument(); + }); + + it('should enable the next button', () => { + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); }); }); @@ -142,9 +450,89 @@ describe('CreateIntegration', () => { expect(result.queryByTestId('reviewStepMock')).toBeInTheDocument(); }); - it('should call isReviewStepReady', () => { + it('should call isReviewStepReadyToComplete', () => { renderIntegrationAssistant(); - expect(mockIsReviewStepReady).toHaveBeenCalled(); + expect(mockIsReviewStepReadyToComplete).toHaveBeenCalled(); + }); + + it('should show the "Add to Elastic" on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent('Add to Elastic'); + }); + + describe('when review step is not done', () => { + beforeEach(() => { + mockIsReviewStepReadyToComplete.mockReturnValue(false); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeDisabled(); + }); + + it('should still enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should still enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + }); + + describe('when review step is done', () => { + beforeEach(() => { + mockIsReviewStepReadyToComplete.mockReturnValue(true); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + + it('should enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + describe('when next button is clicked', () => { + beforeEach(() => { + const result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-nextButton').click(); + }); + }); + + it('should report telemetry for review step completion', () => { + expect(mockReportEvent).toHaveBeenCalledWith( + TelemetryEventType.IntegrationAssistantStepComplete, + { + sessionId: expect.any(String), + step: 4, + stepName: 'Review Step', + durationMs: expect.any(Number), + sessionElapsedTime: expect.any(Number), + } + ); + }); + + it('should show deploy step', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('deployStepMock')).toBeInTheDocument(); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + }); }); }); @@ -157,6 +545,26 @@ describe('CreateIntegration', () => { const result = renderIntegrationAssistant(); expect(result.queryByTestId('deployStepMock')).toBeInTheDocument(); }); + + it('should hide the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null); + }); + + it('should hide the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + it('should show "Close" on the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toHaveTextContent('Close'); + }); }); }); @@ -179,9 +587,107 @@ describe('CreateIntegration with generateCel enabled', () => { expect(result.queryByTestId('celInputStepMock')).toBeInTheDocument(); }); - it('should call isCelInputStepReady', () => { + it('should call isCelInputStepReadyToComplete', () => { renderIntegrationAssistant(); - expect(mockIsCelInputStepReady).toHaveBeenCalled(); + expect(mockIsCelInputStepReadyToComplete).toHaveBeenCalled(); + }); + + it('should show "Generate CEL input configuration" on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent( + 'Generate CEL input configuration' + ); + }); + + it('should enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + describe('when cel input step is not done', () => { + beforeEach(() => { + mockIsCelInputStepReadyToComplete.mockReturnValue(false); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + // Not sure why there are two buttons when testing. + const nextButton = result + .getAllByTestId('buttonsFooter-nextButton') + .filter((button) => button.textContent !== 'Next')[0]; + expect(nextButton).toBeDisabled(); + }); + }); + + describe('when cel input step is done', () => { + beforeEach(() => { + mockIsCelInputStepReadyToComplete.mockReturnValue(true); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + + describe('when next button is clicked', () => { + beforeEach(() => { + const result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-nextButton').click(); + }); + }); + + it('should report telemetry for cel input step completion', () => { + expect(mockReportEvent).toHaveBeenCalledWith( + TelemetryEventType.IntegrationAssistantStepComplete, + { + sessionId: expect.any(String), + step: 5, + stepName: 'CEL Input Step', + durationMs: expect.any(Number), + sessionElapsedTime: expect.any(Number), + } + ); + }); + + it('should show loader on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('generatingLoader')).toBeInTheDocument(); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + // Not sure why there are two buttons when testing. + const nextButton = result + .getAllByTestId('buttonsFooter-nextButton') + .filter((button) => button.textContent !== 'Next')[0]; + expect(nextButton).toBeDisabled(); + }); + }); + }); + + describe('when back button is clicked', () => { + let result: ReturnType; + beforeEach(() => { + result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-backButton').click(); + }); + }); + + it('should not report telemetry', () => { + expect(mockReportEvent).not.toHaveBeenCalled(); + }); + + it('should show review step', () => { + expect(result.queryByTestId('reviewStepMock')).toBeInTheDocument(); + }); + + it('should enable the next button', () => { + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); }); }); @@ -194,6 +700,26 @@ describe('CreateIntegration with generateCel enabled', () => { const result = renderIntegrationAssistant(); expect(result.queryByTestId('deployStepMock')).toBeInTheDocument(); }); + + it('should hide the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null); + }); + + it('should hide the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + it('should show "Close" on the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toHaveTextContent('Close'); + }); }); describe('when step is 6', () => { @@ -210,9 +736,89 @@ describe('CreateIntegration with generateCel enabled', () => { expect(result.queryByTestId('reviewCelStepMock')).toBeInTheDocument(); }); - it('should call isReviewCelStepReady', () => { + it('should call isReviewCelStepReadyToComplete', () => { renderIntegrationAssistant(); - expect(mockIsCelReviewStepReady).toHaveBeenCalled(); + expect(mockIsCelReviewStepReadyToComplete).toHaveBeenCalled(); + }); + + it('should show the "Add to Elastic" on the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toHaveTextContent('Add to Elastic'); + }); + + describe('when cel review step is not done', () => { + beforeEach(() => { + mockIsCelReviewStepReadyToComplete.mockReturnValue(false); + }); + + it('should disable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeDisabled(); + }); + + it('should still enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should still enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + }); + + describe('when cel review step is done', () => { + beforeEach(() => { + mockIsCelReviewStepReadyToComplete.mockReturnValue(true); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + + it('should enable the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-backButton')).toBeEnabled(); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + describe('when next button is clicked', () => { + beforeEach(() => { + const result = renderIntegrationAssistant(); + mockReportEvent.mockClear(); + act(() => { + result.getByTestId('buttonsFooter-nextButton').click(); + }); + }); + + it('should report telemetry for review step completion', () => { + expect(mockReportEvent).toHaveBeenCalledWith( + TelemetryEventType.IntegrationAssistantStepComplete, + { + sessionId: expect.any(String), + step: 6, + stepName: 'CEL Review Step', + durationMs: expect.any(Number), + sessionElapsedTime: expect.any(Number), + } + ); + }); + + it('should show deploy step', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('deployStepMock')).toBeInTheDocument(); + }); + + it('should enable the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-nextButton')).toBeEnabled(); + }); + }); }); }); @@ -225,5 +831,25 @@ describe('CreateIntegration with generateCel enabled', () => { const result = renderIntegrationAssistant(); expect(result.queryByTestId('deployStepMock')).toBeInTheDocument(); }); + + it('should hide the back button', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null); + }); + + it('should hide the next button', () => { + const result = renderIntegrationAssistant(); + expect(result.queryByTestId('buttonsFooter-backButton')).toBe(null); + }); + + it('should enable the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toBeEnabled(); + }); + + it('should show "Close" on the cancel button', () => { + const result = renderIntegrationAssistant(); + expect(result.getByTestId('buttonsFooter-cancelButton')).toHaveTextContent('Close'); + }); }); }); diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx index 72e085e19920a..81cb5a9b3b137 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration_assistant/create_integration_assistant.tsx @@ -5,31 +5,96 @@ * 2.0. */ -import React, { useReducer, useMemo, useEffect } from 'react'; +import React, { useReducer, useMemo, useEffect, useCallback } from 'react'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { Header } from './header'; import { Footer } from './footer'; -import { ConnectorStep, isConnectorStepReady } from './steps/connector_step'; -import { IntegrationStep, isIntegrationStepReady } from './steps/integration_step'; -import { DataStreamStep, isDataStreamStepReady } from './steps/data_stream_step'; -import { ReviewStep, isReviewStepReady } from './steps/review_step'; -import { CelInputStep, isCelInputStepReady } from './steps/cel_input_step'; -import { ReviewCelStep, isCelReviewStepReady } from './steps/review_cel_step'; +import { useNavigate, Page } from '../../../common/hooks/use_navigate'; +import { ConnectorStep, isConnectorStepReadyToComplete } from './steps/connector_step'; +import { IntegrationStep, isIntegrationStepReadyToComplete } from './steps/integration_step'; +import { DataStreamStep, isDataStreamStepReadyToComplete } from './steps/data_stream_step'; +import { ReviewStep, isReviewStepReadyToComplete } from './steps/review_step'; +import { CelInputStep, isCelInputStepReadyToComplete } from './steps/cel_input_step'; +import { ReviewCelStep, isCelReviewStepReadyToComplete } from './steps/review_cel_step'; import { DeployStep } from './steps/deploy_step'; import { reducer, initialState, ActionsProvider, type Actions } from './state'; import { useTelemetry } from '../telemetry'; import { ExperimentalFeaturesService } from '../../../services'; +const stepNames: Record = { + 1: 'Connector Step', + 2: 'Integration Step', + 3: 'DataStream Step', + 4: 'Review Step', + cel_input: 'CEL Input Step', + cel_review: 'CEL Review Step', + deploy: 'Deploy Step', +}; + export const CreateIntegrationAssistant = React.memo(() => { const [state, dispatch] = useReducer(reducer, initialState); - + const navigate = useNavigate(); const { generateCel: isGenerateCelEnabled } = ExperimentalFeaturesService.get(); + const celInputStepIndex = isGenerateCelEnabled && state.hasCelInput ? 5 : null; + const celReviewStepIndex = isGenerateCelEnabled && state.celInputResult ? 6 : null; + const deployStepIndex = + celInputStepIndex !== null || celReviewStepIndex !== null || state.step === 7 ? 7 : 5; + + const stepName = + state.step === deployStepIndex + ? stepNames.deploy + : state.step === celReviewStepIndex + ? stepNames.cel_review + : state.step === celInputStepIndex + ? stepNames.cel_input + : state.step in stepNames + ? stepNames[state.step] + : 'Unknown Step'; + const telemetry = useTelemetry(); useEffect(() => { telemetry.reportAssistantOpen(); }, [telemetry]); + const isThisStepReadyToComplete = useMemo(() => { + if (state.step === 1) { + return isConnectorStepReadyToComplete(state); + } else if (state.step === 2) { + return isIntegrationStepReadyToComplete(state); + } else if (state.step === 3) { + return isDataStreamStepReadyToComplete(state); + } else if (state.step === 4) { + return isReviewStepReadyToComplete(state); + } else if (isGenerateCelEnabled && state.step === 5) { + return isCelInputStepReadyToComplete(state); + } else if (isGenerateCelEnabled && state.step === 6) { + return isCelReviewStepReadyToComplete(state); + } + return false; + }, [state, isGenerateCelEnabled]); + + const goBackStep = useCallback(() => { + if (state.step === 1) { + navigate(Page.landing); + } else { + dispatch({ type: 'SET_STEP', payload: state.step - 1 }); + } + }, [navigate, dispatch, state.step]); + + const completeStep = useCallback(() => { + if (!isThisStepReadyToComplete) { + // If the user tries to navigate to the next step without completing the current step. + return; + } + telemetry.reportAssistantStepComplete({ step: state.step, stepName }); + if (state.step === 3 || state.step === celInputStepIndex) { + dispatch({ type: 'SET_IS_GENERATING', payload: true }); + } else { + dispatch({ type: 'SET_STEP', payload: state.step + 1 }); + } + }, [telemetry, state.step, stepName, celInputStepIndex, isThisStepReadyToComplete]); + const actions = useMemo( () => ({ setStep: (payload) => { @@ -53,27 +118,11 @@ export const CreateIntegrationAssistant = React.memo(() => { setCelInputResult: (payload) => { dispatch({ type: 'SET_CEL_INPUT_RESULT', payload }); }, + completeStep, }), - [] + [completeStep] ); - const isNextStepEnabled = useMemo(() => { - if (state.step === 1) { - return isConnectorStepReady(state); - } else if (state.step === 2) { - return isIntegrationStepReady(state); - } else if (state.step === 3) { - return isDataStreamStepReady(state); - } else if (state.step === 4) { - return isReviewStepReady(state); - } else if (isGenerateCelEnabled && state.step === 5) { - return isCelInputStepReady(state); - } else if (isGenerateCelEnabled && state.step === 6) { - return isCelReviewStepReady(state); - } - return false; - }, [state, isGenerateCelEnabled]); - return ( @@ -95,28 +144,21 @@ export const CreateIntegrationAssistant = React.memo(() => { result={state.result} /> )} - {state.step === 5 && - (isGenerateCelEnabled && state.hasCelInput ? ( - - ) : ( - - ))} - - {isGenerateCelEnabled && state.celInputResult && state.step === 6 && ( + {state.step === celInputStepIndex && ( + + )} + {state.step === celReviewStepIndex && ( )} - {isGenerateCelEnabled && state.step === 7 && ( + + {state.step === deployStepIndex && ( { )}