From fd8011284eace6a63e4eb743a205cd7a76f75926 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Wed, 14 Aug 2019 10:20:19 +0200 Subject: [PATCH] [ML] Data frame analytics: Hook unit tests. (#43199) - moves use_create_analytics_form.ts inside a use_create_analytics_form directory to be able to split up the large file - moves code related to the plain reducer function from ``use_create_analytics_form.tsto its own filereducer.ts` - adds unit tests for use_create_analytics_form.ts and reducer.ts. - Changes the button and modal header text from 'Create data frame analytics job' to 'Create outlier detection job'. --- .../create_analytics_button.tsx | 2 +- .../create_analytics_modal.tsx | 2 +- .../hooks/use_create_analytics_form/index.ts | 7 + .../use_create_analytics_form/reducer.test.ts | 62 ++++++ .../use_create_analytics_form/reducer.ts | 179 +++++++++++++++++ .../use_create_analytics_form.test.tsx | 77 +++++++ .../use_create_analytics_form.ts | 190 +----------------- 7 files changed, 338 insertions(+), 181 deletions(-) create mode 100644 x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts create mode 100644 x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts create mode 100644 x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts create mode 100644 x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx rename x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/{ => use_create_analytics_form}/use_create_analytics_form.ts (60%) diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx index 4a6f025669acf..20b9df34b4aaf 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx @@ -32,7 +32,7 @@ export const CreateAnalyticsButton: FC = () => { data-test-subj="mlDataFrameAnalyticsButtonCreate" > {i18n.translate('xpack.ml.dataframe.analyticsList.createDataFrameAnalyticsButton', { - defaultMessage: 'Create data frame analytics job', + defaultMessage: 'Create outlier detection job', })} ); diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_modal/create_analytics_modal.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_modal/create_analytics_modal.tsx index 0c905b6797652..2f5f9c944ef65 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_modal/create_analytics_modal.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/create_analytics_modal/create_analytics_modal.tsx @@ -35,7 +35,7 @@ export const CreateAnalyticsModal: FC = ({ {i18n.translate('xpack.ml.dataframe.analytics.create.modalHeaderTitle', { - defaultMessage: 'Create data frame analytics job', + defaultMessage: 'Create outlier detection job', })} diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts new file mode 100644 index 0000000000000..9df0b542f50a1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useCreateAnalyticsForm, CreateAnalyticsFormProps } from './use_create_analytics_form'; diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts new file mode 100644 index 0000000000000..8324f41872d4d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getInitialState, reducer, ACTION } from './reducer'; + +describe('useCreateAnalyticsForm', () => { + test('reducer(): provide a minimum required valid job config, then reset.', () => { + const initialState = getInitialState(); + expect(initialState.isValid).toBe(false); + + const updatedState = reducer(initialState, { + type: ACTION.SET_FORM_STATE, + payload: { + destinationIndex: 'the-destination-index', + jobId: 'the-analytics-job-id', + sourceIndex: 'the-source-index', + }, + }); + expect(updatedState.isValid).toBe(true); + + const resettedState = reducer(updatedState, { + type: ACTION.RESET_FORM, + }); + expect(resettedState).toEqual(initialState); + }); + + test('reducer(): open/close the modal', () => { + const initialState = getInitialState(); + expect(initialState.isModalVisible).toBe(false); + + const openModalState = reducer(initialState, { + type: ACTION.OPEN_MODAL, + }); + expect(openModalState.isModalVisible).toBe(true); + + const closedModalState = reducer(openModalState, { + type: ACTION.CLOSE_MODAL, + }); + expect(closedModalState.isModalVisible).toBe(false); + }); + + test('reducer(): add/reset request messages', () => { + const initialState = getInitialState(); + expect(initialState.requestMessages).toHaveLength(0); + + const requestMessageState = reducer(initialState, { + type: ACTION.ADD_REQUEST_MESSAGE, + requestMessage: { + message: 'the-message', + }, + }); + expect(requestMessageState.requestMessages).toHaveLength(1); + + const resetMessageState = reducer(requestMessageState, { + type: ACTION.RESET_REQUEST_MESSAGES, + }); + expect(resetMessageState.requestMessages).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts new file mode 100644 index 0000000000000..68fd46a69fee5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isValidIndexName } from '../../../../../../common/util/es_utils'; +import { checkPermission } from '../../../../../privilege/check_privilege'; + +import { isAnalyticsIdValid, DataFrameAnalyticsId } from '../../../../common'; + +export type EsIndexName = string; +export type IndexPatternTitle = string; + +export interface RequestMessage { + error?: string; + message: string; +} + +export interface State { + createIndexPattern: boolean; + destinationIndex: EsIndexName; + destinationIndexNameExists: boolean; + destinationIndexNameEmpty: boolean; + destinationIndexNameValid: boolean; + destinationIndexPatternTitleExists: boolean; + disabled: boolean; + indexNames: EsIndexName[]; + indexPatternTitles: IndexPatternTitle[]; + indexPatternsWithNumericFields: IndexPatternTitle[]; + isJobCreated: boolean; + isJobStarted: boolean; + isModalButtonDisabled: boolean; + isModalVisible: boolean; + isValid: boolean; + jobId: DataFrameAnalyticsId; + jobIds: DataFrameAnalyticsId[]; + jobIdExists: boolean; + jobIdEmpty: boolean; + jobIdValid: boolean; + requestMessages: RequestMessage[]; + sourceIndex: EsIndexName; + sourceIndexNameExists: boolean; + sourceIndexNameEmpty: boolean; + sourceIndexNameValid: boolean; +} + +export const getInitialState = (): State => ({ + createIndexPattern: false, + destinationIndex: '', + destinationIndexNameExists: false, + destinationIndexNameEmpty: true, + destinationIndexNameValid: false, + destinationIndexPatternTitleExists: false, + disabled: + !checkPermission('canCreateDataFrameAnalytics') || + !checkPermission('canStartStopDataFrameAnalytics'), + indexNames: [], + indexPatternTitles: [], + indexPatternsWithNumericFields: [], + isJobCreated: false, + isJobStarted: false, + isModalVisible: false, + isModalButtonDisabled: false, + isValid: false, + jobId: '', + jobIds: [], + jobIdExists: false, + jobIdEmpty: true, + jobIdValid: false, + requestMessages: [], + sourceIndex: '', + sourceIndexNameExists: false, + sourceIndexNameEmpty: true, + sourceIndexNameValid: false, +}); + +const validate = (state: State): State => { + state.isValid = + !state.jobIdEmpty && + state.jobIdValid && + !state.jobIdExists && + !state.sourceIndexNameEmpty && + state.sourceIndexNameValid && + !state.destinationIndexNameEmpty && + state.destinationIndexNameValid && + (!state.destinationIndexPatternTitleExists || !state.createIndexPattern); + + return state; +}; + +export enum ACTION { + ADD_REQUEST_MESSAGE = 'add_request_message', + RESET_REQUEST_MESSAGES = 'reset_request_messages', + CLOSE_MODAL = 'close_modal', + OPEN_MODAL = 'open_modal', + RESET_FORM = 'reset_form', + SET_FORM_STATE = 'set_form_state', +} + +export type Action = + | { type: ACTION.ADD_REQUEST_MESSAGE; requestMessage: RequestMessage } + | { type: ACTION.RESET_REQUEST_MESSAGES } + | { type: ACTION.CLOSE_MODAL } + | { type: ACTION.OPEN_MODAL } + | { type: ACTION.RESET_FORM } + | { type: ACTION.SET_FORM_STATE; payload: Partial }; + +export function reducer(state: State, action: Action): State { + switch (action.type) { + case ACTION.ADD_REQUEST_MESSAGE: + state.requestMessages.push(action.requestMessage); + return state; + + case ACTION.RESET_REQUEST_MESSAGES: + return { ...state, requestMessages: [] }; + + case ACTION.CLOSE_MODAL: + return { ...state, isModalVisible: false }; + + case ACTION.OPEN_MODAL: + return { ...state, isModalVisible: true }; + + case ACTION.RESET_FORM: + return getInitialState(); + + case ACTION.SET_FORM_STATE: + const newState = { ...state, ...action.payload }; + + // update state attributes which are derived from other state attributes. + if (action.payload.destinationIndex !== undefined) { + newState.destinationIndexNameExists = newState.indexNames.some( + name => newState.destinationIndex === name + ); + newState.destinationIndexNameEmpty = newState.destinationIndex === ''; + newState.destinationIndexNameValid = isValidIndexName(newState.destinationIndex); + newState.destinationIndexPatternTitleExists = newState.indexPatternTitles.some( + name => newState.destinationIndex === name + ); + } + + if (action.payload.indexNames !== undefined) { + newState.destinationIndexNameExists = newState.indexNames.some( + name => newState.destinationIndex === name + ); + newState.sourceIndexNameExists = newState.indexNames.some( + name => newState.sourceIndex === name + ); + } + + if (action.payload.indexPatternTitles !== undefined) { + newState.destinationIndexPatternTitleExists = newState.indexPatternTitles.some( + name => newState.destinationIndex === name + ); + } + + if (action.payload.jobId !== undefined) { + newState.jobIdExists = newState.jobIds.some(id => newState.jobId === id); + newState.jobIdEmpty = newState.jobId === ''; + newState.jobIdValid = isAnalyticsIdValid(newState.jobId); + } + + if (action.payload.jobIds !== undefined) { + newState.jobIdExists = newState.jobIds.some(id => newState.jobId === id); + } + + if (action.payload.sourceIndex !== undefined) { + newState.sourceIndexNameExists = newState.indexNames.some( + name => newState.sourceIndex === name + ); + newState.sourceIndexNameEmpty = newState.sourceIndex === ''; + newState.sourceIndexNameValid = isValidIndexName(newState.sourceIndex); + } + + return validate(newState); + } + + return state; +} diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx new file mode 100644 index 0000000000000..4985c369ea2c6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountHook } from 'test_utils/enzyme_helpers'; + +import { KibanaContext } from '../../../../../contexts/kibana'; +import { kibanaContextValueMock } from '../../../../../contexts/kibana/__mocks__/kibana_context_value'; + +import { getErrorMessage, useCreateAnalyticsForm } from './use_create_analytics_form'; + +const getMountedHook = () => + mountHook( + () => useCreateAnalyticsForm(), + ({ children }) => ( + {children} + ) + ); + +describe('getErrorMessage()', () => { + test('verify error message response formats', () => { + const errorMessage = getErrorMessage(new Error('the-error-message')); + expect(errorMessage).toBe('the-error-message'); + + const customError1 = { customErrorMessage: 'the-error-message' }; + const errorMessageMessage1 = getErrorMessage(customError1); + expect(errorMessageMessage1).toBe('{"customErrorMessage":"the-error-message"}'); + + const customError2 = { message: 'the-error-message' }; + const errorMessageMessage2 = getErrorMessage(customError2); + expect(errorMessageMessage2).toBe('the-error-message'); + }); +}); + +describe('useCreateAnalyticsForm()', () => { + test('initialization', () => { + const { getLastHookValue } = getMountedHook(); + const { state, actions } = getLastHookValue(); + + expect(state.isModalVisible).toBe(false); + expect(typeof actions.closeModal).toBe('function'); + expect(typeof actions.createAnalyticsJob).toBe('function'); + expect(typeof actions.openModal).toBe('function'); + expect(typeof actions.startAnalyticsJob).toBe('function'); + expect(typeof actions.setFormState).toBe('function'); + }); + + test('open/close modal', () => { + const { act, getLastHookValue } = getMountedHook(); + const { state, actions } = getLastHookValue(); + + expect(state.isModalVisible).toBe(false); + + act(() => { + // this should be actions.openModal(), but that doesn't work yet because act() doesn't support async yet. + // we need to wait for an update to React 16.9 + actions.setFormState({ isModalVisible: true }); + }); + const { state: stateModalOpen } = getLastHookValue(); + expect(stateModalOpen.isModalVisible).toBe(true); + + act(() => { + // this should be actions.closeModal(), but that doesn't work yet because act() doesn't support async yet. + // we need to wait for an update to React 16.9 + actions.setFormState({ isModalVisible: false }); + }); + const { state: stateModalClosed } = getLastHookValue(); + expect(stateModalClosed.isModalVisible).toBe(false); + }); + + // TODO + // add tests for createAnalyticsJob() and startAnalyticsJob() + // once React 16.9 with support for async act() is available. +}); diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form.ts b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts similarity index 60% rename from x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form.ts rename to x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index e443187e5e8fd..1205b9650be87 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -8,115 +8,19 @@ import { useReducer } from 'react'; import { i18n } from '@kbn/i18n'; -import { checkPermission } from '../../../../privilege/check_privilege'; -import { ml } from '../../../../services/ml_api_service'; -import { useKibanaContext } from '../../../../contexts/kibana'; +import { ml } from '../../../../../services/ml_api_service'; +import { useKibanaContext } from '../../../../../contexts/kibana'; -import { isValidIndexName } from '../../../../../common/util/es_utils'; +import { useRefreshAnalyticsList, DataFrameAnalyticsOutlierConfig } from '../../../../common'; import { - isAnalyticsIdValid, - useRefreshAnalyticsList, - DataFrameAnalyticsId, - DataFrameAnalyticsOutlierConfig, -} from '../../../common'; - -export type EsIndexName = string; -export type IndexPatternTitle = string; - -interface RequestMessage { - error?: string; - message: string; -} - -interface State { - createIndexPattern: boolean; - destinationIndex: EsIndexName; - destinationIndexNameExists: boolean; - destinationIndexNameEmpty: boolean; - destinationIndexNameValid: boolean; - destinationIndexPatternTitleExists: boolean; - disabled: boolean; - indexNames: EsIndexName[]; - indexPatternTitles: IndexPatternTitle[]; - indexPatternsWithNumericFields: IndexPatternTitle[]; - isJobCreated: boolean; - isJobStarted: boolean; - isModalButtonDisabled: boolean; - isModalVisible: boolean; - isValid: boolean; - jobId: DataFrameAnalyticsId; - jobIds: DataFrameAnalyticsId[]; - jobIdExists: boolean; - jobIdEmpty: boolean; - jobIdValid: boolean; - requestMessages: RequestMessage[]; - sourceIndex: EsIndexName; - sourceIndexNameExists: boolean; - sourceIndexNameEmpty: boolean; - sourceIndexNameValid: boolean; -} - -export const getInitialState = (): State => ({ - createIndexPattern: false, - destinationIndex: '', - destinationIndexNameExists: false, - destinationIndexNameEmpty: true, - destinationIndexNameValid: false, - destinationIndexPatternTitleExists: false, - disabled: - !checkPermission('canCreateDataFrameAnalytics') || - !checkPermission('canStartStopDataFrameAnalytics'), - indexNames: [], - indexPatternTitles: [], - indexPatternsWithNumericFields: [], - isJobCreated: false, - isJobStarted: false, - isModalVisible: false, - isModalButtonDisabled: false, - isValid: false, - jobId: '', - jobIds: [], - jobIdExists: false, - jobIdEmpty: true, - jobIdValid: false, - requestMessages: [], - sourceIndex: '', - sourceIndexNameExists: false, - sourceIndexNameEmpty: true, - sourceIndexNameValid: false, -}); - -const validate = (state: State): State => { - state.isValid = - !state.jobIdEmpty && - state.jobIdValid && - !state.jobIdExists && - !state.sourceIndexNameEmpty && - state.sourceIndexNameValid && - !state.destinationIndexNameEmpty && - state.destinationIndexNameValid && - (!state.destinationIndexPatternTitleExists || !state.createIndexPattern); - - return state; -}; - -enum ACTION { - ADD_REQUEST_MESSAGE = 'add_request_message', - RESET_REQUEST_MESSAGES = 'reset_request_messages', - CLOSE_MODAL = 'close_modal', - OPEN_MODAL = 'open_modal', - RESET_FORM = 'reset_form', - SET_FORM_STATE = 'set_form_state', -} - -type Action = - | { type: ACTION.ADD_REQUEST_MESSAGE; requestMessage: RequestMessage } - | { type: ACTION.RESET_REQUEST_MESSAGES } - | { type: ACTION.CLOSE_MODAL } - | { type: ACTION.OPEN_MODAL } - | { type: ACTION.RESET_FORM } - | { type: ACTION.SET_FORM_STATE; payload: Partial }; + getInitialState, + reducer, + IndexPatternTitle, + RequestMessage, + State, + ACTION, +} from './reducer'; export interface Actions { closeModal: () => void; @@ -131,82 +35,10 @@ export interface CreateAnalyticsFormProps { formState: State; } -export function reducer(state: State, action: Action): State { - switch (action.type) { - case ACTION.ADD_REQUEST_MESSAGE: - state.requestMessages.push(action.requestMessage); - return state; - - case ACTION.RESET_REQUEST_MESSAGES: - return { ...state, requestMessages: [] }; - - case ACTION.CLOSE_MODAL: - return { ...state, isModalVisible: false }; - - case ACTION.OPEN_MODAL: - return { ...state, isModalVisible: true }; - - case ACTION.RESET_FORM: - return getInitialState(); - - case ACTION.SET_FORM_STATE: - const newState = { ...state, ...action.payload }; - - // update state attributes which are derived from other state attributes. - if (action.payload.destinationIndex !== undefined) { - newState.destinationIndexNameExists = newState.indexNames.some( - name => newState.destinationIndex === name - ); - newState.destinationIndexNameEmpty = newState.destinationIndex === ''; - newState.destinationIndexNameValid = isValidIndexName(newState.destinationIndex); - newState.destinationIndexPatternTitleExists = newState.indexPatternTitles.some( - name => newState.destinationIndex === name - ); - } - - if (action.payload.indexNames !== undefined) { - newState.destinationIndexNameExists = newState.indexNames.some( - name => newState.destinationIndex === name - ); - newState.sourceIndexNameExists = newState.indexNames.some( - name => newState.sourceIndex === name - ); - } - - if (action.payload.indexPatternTitles !== undefined) { - newState.destinationIndexPatternTitleExists = newState.indexPatternTitles.some( - name => newState.destinationIndex === name - ); - } - - if (action.payload.jobId !== undefined) { - newState.jobIdExists = newState.jobIds.some(id => newState.jobId === id); - newState.jobIdEmpty = newState.jobId === ''; - newState.jobIdValid = isAnalyticsIdValid(newState.jobId); - } - - if (action.payload.jobIds !== undefined) { - newState.jobIdExists = newState.jobIds.some(id => newState.jobId === id); - } - - if (action.payload.sourceIndex !== undefined) { - newState.sourceIndexNameExists = newState.indexNames.some( - name => newState.sourceIndex === name - ); - newState.sourceIndexNameEmpty = newState.sourceIndex === ''; - newState.sourceIndexNameValid = isValidIndexName(newState.sourceIndex); - } - - return validate(newState); - } - - return state; -} - // List of system fields we want to ignore for the numeric field check. const OMIT_FIELDS: string[] = ['_source', '_type', '_index', '_id', '_version', '_score']; -function getErrorMessage(error: any) { +export function getErrorMessage(error: any) { if (typeof error === 'object' && typeof error.message === 'string') { return error.message; }