Skip to content

Commit

Permalink
[ML] Data frame analytics: Hook unit tests. (elastic#43199)
Browse files Browse the repository at this point in the history
- 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'.
  • Loading branch information
walterra authored Aug 14, 2019
1 parent 627bc54 commit fd80112
Show file tree
Hide file tree
Showing 7 changed files with 338 additions and 181 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})}
</EuiButton>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const CreateAnalyticsModal: FC<CreateAnalyticsFormProps> = ({
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('xpack.ml.dataframe.analytics.create.modalHeaderTitle', {
defaultMessage: 'Create data frame analytics job',
defaultMessage: 'Create outlier detection job',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<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;
}
Original file line number Diff line number Diff line change
@@ -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 }) => (
<KibanaContext.Provider value={kibanaContextValueMock}>{children}</KibanaContext.Provider>
)
);

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.
});
Loading

0 comments on commit fd80112

Please sign in to comment.