Skip to content

Commit

Permalink
[ML] Data frame analytics: Advanced editor. (#43989) (#44223)
Browse files Browse the repository at this point in the history
Adds an option to switch to an advanced (JSON) editor when creating an analytics job.

This builds upon the previous work for the modal for analytics job creation and the use of useReducer():

- The files of the custom hook useCreateAnalyticsForm() have been further split up and there's now separate actions.ts and state.ts files.
- To only allow updating what's really related to the form value's state via setFormState, the state structure has been updated and more fine grained actions have been added.
- The user can enabled the advanced editor, but cannot move back to the original form (there's a help text in the UI about that).
- The advanced editor component's (CreateAnalyticsAdvancedEditor) structure is based on the regular form, it still has an input field for the job ID and the toggle for optionally creating an index pattern. The fields for source/destination index are replaced by an editable JSON textarea input.
- The advanced editor features mostly the same validation like the regular form. For example, if the source index isn't valid, an error will be shown in a CallOut below the editable area.
  • Loading branch information
walterra authored Aug 29, 2019
1 parent ace71bd commit 45d12e0
Show file tree
Hide file tree
Showing 18 changed files with 750 additions and 231 deletions.
6 changes: 6 additions & 0 deletions x-pack/legacy/plugins/ml/common/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ export interface Dictionary<TValue> {
export function dictionaryToArray<TValue>(dict: Dictionary<TValue>): TValue[] {
return Object.keys(dict).map(key => dict[key]);
}

// A recursive partial type to allow passing nested partial attributes.
// Used for example for the optional `jobConfig.dest.results_field` property.
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,17 @@ export type IndexName = string;
export type IndexPattern = string;
export type DataFrameAnalyticsId = string;

export interface CreateRequestBody {
export interface DataFrameAnalyticsOutlierConfig {
id: DataFrameAnalyticsId;
// Description attribute is not supported yet
// description?: string;
dest: {
index: IndexName;
results_field: string;
};
}

export interface DataFrameAnalyticsOutlierConfig extends CreateRequestBody {
id: DataFrameAnalyticsId;
source: {
index: IndexName;
};
analysis: {
outlier_detection: {};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export {
moveToAnalyticsWizard,
refreshAnalyticsList$,
useRefreshAnalyticsList,
CreateRequestBody,
DataFrameAnalyticsId,
DataFrameAnalyticsOutlierConfig,
IndexName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const ExplorationTitle: React.SFC<{ jobId: string }> = ({ jobId }) => (
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.analytics.exploration.jobIdTitle', {
defaultMessage: 'Job id {jobId}',
defaultMessage: 'Job ID {jobId}',
values: { jobId },
})}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,7 @@ import {
SortDirection,
} from '@elastic/eui';

import {
DataFrameAnalyticsId,
moveToAnalyticsWizard,
useRefreshAnalyticsList,
} from '../../../../common';
import { DataFrameAnalyticsId, useRefreshAnalyticsList } from '../../../../common';
import { checkPermission } from '../../../../../privilege/check_privilege';
import { getTaskStateBadge } from './columns';

Expand All @@ -33,6 +29,7 @@ import {
Query,
Clause,
} from './common';
import { ActionDispatchers } from '../../hooks/use_create_analytics_form/actions';
import { getAnalyticsFactory } from '../../services/analytics_service';
import { getColumns } from './columns';
import { ExpandedRow } from './expanded_row';
Expand Down Expand Up @@ -65,11 +62,13 @@ function stringMatch(str: string | undefined, substr: string) {
interface Props {
isManagementTable?: boolean;
blockRefresh?: boolean;
openCreateJobModal?: ActionDispatchers['openModal'];
}
// isManagementTable - for use in Kibana managagement ML section
export const DataFrameAnalyticsList: FC<Props> = ({
isManagementTable = false,
blockRefresh = false,
openCreateJobModal,
}) => {
const [isInitialized, setIsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -208,21 +207,21 @@ export const DataFrameAnalyticsList: FC<Props> = ({
title={
<h2>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', {
defaultMessage: 'No data frame analytics found',
defaultMessage: 'No data frame analytics jobs found',
})}
</h2>
}
actions={[
<EuiButtonEmpty
onClick={moveToAnalyticsWizard}
isDisabled={disabled}
style={{ display: 'none' }}
>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', {
defaultMessage: 'Create your first data frame analytics',
})}
</EuiButtonEmpty>,
]}
actions={
!isManagementTable && openCreateJobModal !== undefined
? [
<EuiButtonEmpty onClick={openCreateJobModal} isDisabled={disabled}>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', {
defaultMessage: 'Create your first data frame analytics job',
})}
</EuiButtonEmpty>,
]
: []
}
data-test-subj="mlNoDataFrameAnalyticsFound"
/>
</Fragment>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* 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, { FC, Fragment } from 'react';

import {
EuiCallOut,
EuiCodeEditor,
EuiFieldText,
EuiForm,
EuiFormRow,
EuiSpacer,
EuiSwitch,
} from '@elastic/eui';

import { i18n } from '@kbn/i18n';

import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form';

export const CreateAnalyticsAdvancedEditor: FC<CreateAnalyticsFormProps> = ({ actions, state }) => {
const {
resetAdvancedEditorMessages,
setAdvancedEditorRawString,
setFormState,
setJobConfig,
} = actions;

const { advancedEditorMessages, advancedEditorRawString, isJobCreated, requestMessages } = state;

const {
createIndexPattern,
destinationIndexPatternTitleExists,
jobId,
jobIdEmpty,
jobIdExists,
jobIdValid,
} = state.form;

const onChange = (str: string) => {
setAdvancedEditorRawString(str);
try {
setJobConfig(JSON.parse(str));
} catch (e) {
resetAdvancedEditorMessages();
}
};

return (
<EuiForm>
{requestMessages.map((requestMessage, i) => (
<Fragment key={i}>
<EuiCallOut
title={requestMessage.message}
color={requestMessage.error !== undefined ? 'danger' : 'primary'}
iconType={requestMessage.error !== undefined ? 'alert' : 'checkInCircleFilled'}
size="s"
>
{requestMessage.error !== undefined ? <p>{requestMessage.error}</p> : null}
</EuiCallOut>
<EuiSpacer size="s" />
</Fragment>
))}
{!isJobCreated && (
<Fragment>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.analytics.create.advancedEditor.jobIdLabel', {
defaultMessage: 'Analytics job ID',
})}
isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists}
error={[
...(!jobIdEmpty && !jobIdValid
? [
i18n.translate(
'xpack.ml.dataframe.analytics.create.advancedEditor.jobIdInvalidError',
{
defaultMessage:
'Must contain lowercase alphanumeric characters (a-z and 0-9), hyphens, and underscores only and must start and end with alphanumeric characters.',
}
),
]
: []),
...(jobIdExists
? [
i18n.translate(
'xpack.ml.dataframe.analytics.create.advancedEditor.jobIdExistsError',
{
defaultMessage: 'An analytics job with this ID already exists.',
}
),
]
: []),
]}
>
<EuiFieldText
disabled={isJobCreated}
placeholder="analytics job ID"
value={jobId}
onChange={e => setFormState({ jobId: e.target.value })}
aria-label={i18n.translate(
'xpack.ml.dataframe.analytics.create.advancedEditor.jobIdInputAriaLabel',
{
defaultMessage: 'Choose a unique analytics job ID.',
}
)}
isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists}
/>
</EuiFormRow>

<EuiFormRow
label={i18n.translate(
'xpack.ml.dataframe.analytics.create.advancedEditor.configRequestBody',
{
defaultMessage: 'Configuration request body',
}
)}
style={{ maxWidth: '100%' }}
>
<EuiCodeEditor
mode="json"
width="100%"
value={advancedEditorRawString}
onChange={onChange}
setOptions={{
fontSize: '12px',
}}
aria-label={i18n.translate(
'xpack.ml.dataframe.analytics.create.advancedEditor.codeEditorAriaLabel',
{
defaultMessage: 'Advanced analytics job editor',
}
)}
/>
</EuiFormRow>
{advancedEditorMessages.map((advancedEditorMessage, i) => (
<Fragment key={i}>
<EuiCallOut
title={
advancedEditorMessage.message !== ''
? advancedEditorMessage.message
: advancedEditorMessage.error
}
color={advancedEditorMessage.error !== undefined ? 'danger' : 'primary'}
iconType={
advancedEditorMessage.error !== undefined ? 'alert' : 'checkInCircleFilled'
}
size="s"
>
{advancedEditorMessage.message !== '' &&
advancedEditorMessage.error !== undefined ? (
<p>{advancedEditorMessage.error}</p>
) : null}
</EuiCallOut>
<EuiSpacer size="s" />
</Fragment>
))}
<EuiFormRow
isInvalid={createIndexPattern && destinationIndexPatternTitleExists}
error={
createIndexPattern &&
destinationIndexPatternTitleExists && [
i18n.translate('xpack.ml.dataframe.analytics.create.indexPatternTitleError', {
defaultMessage: 'An index pattern with this title already exists.',
}),
]
}
>
<EuiSwitch
disabled={isJobCreated}
name="mlDataFrameAnalyticsCreateIndexPattern"
label={i18n.translate('xpack.ml.dataframe.analytics.create.createIndexPatternLabel', {
defaultMessage: 'Create index pattern',
})}
checked={createIndexPattern === true}
onChange={() => setFormState({ createIndexPattern: !createIndexPattern })}
/>
</EuiFormRow>
</Fragment>
)}
</EuiForm>
);
};
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 { CreateAnalyticsAdvancedEditor } from './create_analytics_advanced_editor';
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import { i18n } from '@kbn/i18n';

import { createPermissionFailureMessage } from '../../../../../privilege/check_privilege';

import { useCreateAnalyticsForm } from '../../hooks/use_create_analytics_form';
import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form';

import { CreateAnalyticsAdvancedEditor } from '../create_analytics_advanced_editor';
import { CreateAnalyticsForm } from '../create_analytics_form';
import { CreateAnalyticsModal } from '../create_analytics_modal';

export const CreateAnalyticsButton: FC = () => {
const { state, actions } = useCreateAnalyticsForm();
const { disabled, isModalVisible } = state;
const { openModal } = actions;
export const CreateAnalyticsButton: FC<CreateAnalyticsFormProps> = props => {
const { disabled, isAdvancedEditorEnabled, isModalVisible } = props.state;
const { openModal } = props.actions;

const button = (
<EuiButton
Expand Down Expand Up @@ -52,8 +52,9 @@ export const CreateAnalyticsButton: FC = () => {
<Fragment>
{button}
{isModalVisible && (
<CreateAnalyticsModal actions={actions} formState={state}>
<CreateAnalyticsForm actions={actions} formState={state} />
<CreateAnalyticsModal {...props}>
{isAdvancedEditorEnabled === false && <CreateAnalyticsForm {...props} />}
{isAdvancedEditorEnabled === true && <CreateAnalyticsAdvancedEditor {...props} />}
</CreateAnalyticsModal>
)}
</Fragment>
Expand Down
Loading

0 comments on commit 45d12e0

Please sign in to comment.