mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Improves the error handling of the form elements and requests in the data frame pivot wizard.
This commit is contained in:
parent
534d8c37ea
commit
5d5bfced0c
9 changed files with 527 additions and 116 deletions
|
@ -94,6 +94,10 @@ export interface DataFrameRequest extends DataFramePreviewRequest {
|
|||
};
|
||||
}
|
||||
|
||||
export interface DataFrameJobConfig extends DataFrameRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const pivotSupportedAggs = [
|
||||
PIVOT_SUPPORTED_AGGS.AVG,
|
||||
PIVOT_SUPPORTED_AGGS.CARDINALITY,
|
||||
|
|
|
@ -4,20 +4,31 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { EuiInMemoryTable, EuiPanel, EuiProgress, EuiTitle, SortDirection } from '@elastic/eui';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
|
||||
import {
|
||||
getDataFramePreviewRequest,
|
||||
IndexPatternContext,
|
||||
OptionsDataElement,
|
||||
SimpleQuery,
|
||||
} from '../../common';
|
||||
EuiCallOut,
|
||||
EuiInMemoryTable,
|
||||
EuiPanel,
|
||||
EuiProgress,
|
||||
EuiTitle,
|
||||
SortDirection,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { IndexPatternContext, OptionsDataElement, SimpleQuery } from '../../common';
|
||||
import { PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data';
|
||||
|
||||
const PreviewTitle = () => (
|
||||
<EuiTitle size="xs">
|
||||
<span>
|
||||
{i18n.translate('xpack.ml.dataframe.pivotPreview.dataFramePivotPreviewTitle', {
|
||||
defaultMessage: 'Data frame pivot preview',
|
||||
})}
|
||||
</span>
|
||||
</EuiTitle>
|
||||
);
|
||||
|
||||
interface Props {
|
||||
aggs: OptionsDataElement[];
|
||||
|
@ -32,36 +43,57 @@ export const PivotPreview: React.SFC<Props> = React.memo(({ aggs, groupBy, query
|
|||
return null;
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataFramePreviewData, setDataFramePreviewData] = useState([]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (aggs.length === 0) {
|
||||
setDataFramePreviewData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const request = getDataFramePreviewRequest(indexPattern.title, query, groupBy, aggs);
|
||||
|
||||
ml.dataFrame
|
||||
.getDataFrameTransformsPreview(request)
|
||||
.then((resp: any) => {
|
||||
setDataFramePreviewData(resp.preview);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((resp: any) => {
|
||||
setDataFramePreviewData([]);
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[indexPattern.title, aggs, groupBy, query]
|
||||
const { dataFramePreviewData, errorMessage, status } = usePivotPreviewData(
|
||||
indexPattern,
|
||||
query,
|
||||
aggs,
|
||||
groupBy
|
||||
);
|
||||
|
||||
if (status === PIVOT_PREVIEW_STATUS.ERROR) {
|
||||
return (
|
||||
<EuiPanel grow={false}>
|
||||
<PreviewTitle />
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.ml.dataframe.sourceIndexPreview.dataFramePivotPreviewError',
|
||||
{
|
||||
defaultMessage: 'An error occurred loading the pivot preview.',
|
||||
}
|
||||
)}
|
||||
color="danger"
|
||||
iconType="cross"
|
||||
>
|
||||
<p>{errorMessage}</p>
|
||||
</EuiCallOut>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (dataFramePreviewData.length === 0) {
|
||||
return null;
|
||||
return (
|
||||
<EuiPanel grow={false}>
|
||||
<PreviewTitle />
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.ml.dataframe.sourceIndexPreview.dataFramePivotPreviewNoDataCalloutTitle',
|
||||
{
|
||||
defaultMessage: 'Pivot preview not available',
|
||||
}
|
||||
)}
|
||||
color="primary"
|
||||
>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.ml.dataframe.sourceIndexPreview.dataFramePivotPreviewNoDataCalloutBody',
|
||||
{
|
||||
defaultMessage: 'Please choose at least one group-by field and aggregation.',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const columnKeys = Object.keys(dataFramePreviewData[0]);
|
||||
|
@ -97,15 +129,11 @@ export const PivotPreview: React.SFC<Props> = React.memo(({ aggs, groupBy, query
|
|||
|
||||
return (
|
||||
<EuiPanel>
|
||||
<EuiTitle size="xs">
|
||||
<span>
|
||||
{i18n.translate('xpack.ml.dataframe.pivotPreview.dataFramePivotPreviewTitle', {
|
||||
defaultMessage: 'Data Frame Pivot Preview',
|
||||
})}
|
||||
</span>
|
||||
</EuiTitle>
|
||||
{loading && <EuiProgress size="xs" color="accent" />}
|
||||
{!loading && <EuiProgress size="xs" color="accent" max={1} value={0} />}
|
||||
<PreviewTitle />
|
||||
{status === PIVOT_PREVIEW_STATUS.LOADING && <EuiProgress size="xs" color="accent" />}
|
||||
{status !== PIVOT_PREVIEW_STATUS.LOADING && (
|
||||
<EuiProgress size="xs" color="accent" max={1} value={0} />
|
||||
)}
|
||||
{dataFramePreviewData.length > 0 && (
|
||||
<EuiInMemoryTable
|
||||
items={dataFramePreviewData}
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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, { SFC } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
import { SimpleQuery } from '../../common';
|
||||
import {
|
||||
PIVOT_PREVIEW_STATUS,
|
||||
usePivotPreviewData,
|
||||
UsePivotPreviewDataReturnType,
|
||||
} from './use_pivot_preview_data';
|
||||
|
||||
jest.mock('../../../services/ml_api_service');
|
||||
|
||||
type Callback = () => void;
|
||||
interface TestHookProps {
|
||||
callback: Callback;
|
||||
}
|
||||
|
||||
const TestHook: SFC<TestHookProps> = ({ callback }) => {
|
||||
callback();
|
||||
return null;
|
||||
};
|
||||
|
||||
const testHook = (callback: Callback) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
ReactDOM.render(<TestHook callback={callback} />, container);
|
||||
};
|
||||
|
||||
const query: SimpleQuery = {
|
||||
query_string: {
|
||||
query: '*',
|
||||
default_operator: 'AND',
|
||||
},
|
||||
};
|
||||
|
||||
let pivotPreviewObj: UsePivotPreviewDataReturnType;
|
||||
|
||||
describe('usePivotPreviewData', () => {
|
||||
test('indexPattern not defined', () => {
|
||||
testHook(() => {
|
||||
pivotPreviewObj = usePivotPreviewData(null, query, [], []);
|
||||
});
|
||||
|
||||
expect(pivotPreviewObj.errorMessage).toBe('');
|
||||
expect(pivotPreviewObj.status).toBe(PIVOT_PREVIEW_STATUS.UNUSED);
|
||||
expect(pivotPreviewObj.dataFramePreviewData).toEqual([]);
|
||||
expect(ml.dataFrame.getDataFrameTransformsPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('indexPattern set triggers loading', () => {
|
||||
testHook(() => {
|
||||
pivotPreviewObj = usePivotPreviewData({ title: 'lorem', fields: [] }, query, [], []);
|
||||
});
|
||||
|
||||
expect(pivotPreviewObj.errorMessage).toBe('');
|
||||
// ideally this should be LOADING instead of UNUSED but jest/enzyme/hooks doesn't
|
||||
// trigger that state upate yet.
|
||||
expect(pivotPreviewObj.status).toBe(PIVOT_PREVIEW_STATUS.UNUSED);
|
||||
expect(pivotPreviewObj.dataFramePreviewData).toEqual([]);
|
||||
// ideally this should be 1 instead of 0 but jest/enzyme/hooks doesn't
|
||||
// trigger that state upate yet.
|
||||
expect(ml.dataFrame.getDataFrameTransformsPreview).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
// TODO add more tests to check data retrieved via `ml.esSearch()`.
|
||||
// This needs more investigation in regards to jest/enzyme's React Hooks support.
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
|
||||
import { Dictionary } from '../../../../common/types/common';
|
||||
import { getDataFramePreviewRequest, OptionsDataElement, SimpleQuery } from '../../common';
|
||||
import { IndexPatternContextValue } from '../../common/index_pattern_context';
|
||||
|
||||
export enum PIVOT_PREVIEW_STATUS {
|
||||
UNUSED,
|
||||
LOADING,
|
||||
LOADED,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
export interface UsePivotPreviewDataReturnType {
|
||||
errorMessage: string;
|
||||
status: PIVOT_PREVIEW_STATUS;
|
||||
dataFramePreviewData: Array<Dictionary<any>>;
|
||||
}
|
||||
|
||||
export const usePivotPreviewData = (
|
||||
indexPattern: IndexPatternContextValue,
|
||||
query: SimpleQuery,
|
||||
aggs: OptionsDataElement[],
|
||||
groupBy: string[]
|
||||
): UsePivotPreviewDataReturnType => {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [status, setStatus] = useState(PIVOT_PREVIEW_STATUS.UNUSED);
|
||||
const [dataFramePreviewData, setDataFramePreviewData] = useState([]);
|
||||
|
||||
if (indexPattern !== null) {
|
||||
const getDataFramePreviewData = async () => {
|
||||
if (aggs.length === 0 || groupBy.length === 0) {
|
||||
setDataFramePreviewData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
setStatus(PIVOT_PREVIEW_STATUS.LOADING);
|
||||
|
||||
const request = getDataFramePreviewRequest(indexPattern.title, query, groupBy, aggs);
|
||||
|
||||
try {
|
||||
const resp: any = await ml.dataFrame.getDataFrameTransformsPreview(request);
|
||||
setDataFramePreviewData(resp.preview);
|
||||
setStatus(PIVOT_PREVIEW_STATUS.LOADED);
|
||||
} catch (e) {
|
||||
setErrorMessage(JSON.stringify(e));
|
||||
setDataFramePreviewData([]);
|
||||
setStatus(PIVOT_PREVIEW_STATUS.ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
getDataFramePreviewData();
|
||||
},
|
||||
[indexPattern.title, aggs, groupBy, query]
|
||||
);
|
||||
}
|
||||
return { errorMessage, status, dataFramePreviewData };
|
||||
};
|
|
@ -4,12 +4,16 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { Fragment, SFC, useEffect, useState } from 'react';
|
||||
import React, { SFC, useEffect, useState } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
|
||||
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
|
||||
import { EuiFieldText, EuiForm, EuiFormRow } from '@elastic/eui';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
|
||||
import { DataFrameJobConfig } from '../../common';
|
||||
import { JobId, TargetIndex } from './common';
|
||||
|
||||
export interface JobDetailsExposedState {
|
||||
|
@ -38,21 +42,67 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
|
|||
|
||||
const [jobId, setJobId] = useState(defaults.jobId);
|
||||
const [targetIndex, setTargetIndex] = useState(defaults.targetIndex);
|
||||
const [jobIds, setJobIds] = useState([]);
|
||||
const [indexNames, setIndexNames] = useState([] as string[]);
|
||||
|
||||
// fetch existing job IDs and indices once for form validation
|
||||
useEffect(() => {
|
||||
// use an IIFE to avoid returning a Promise to useEffect.
|
||||
(async function() {
|
||||
try {
|
||||
setJobIds(
|
||||
(await ml.dataFrame.getDataFrameTransforms()).transforms.map(
|
||||
(job: DataFrameJobConfig) => job.id
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
toastNotifications.addDanger(
|
||||
i18n.translate('xpack.ml.dataframe.jobDetailsForm.errorGettingDataFrameJobsList', {
|
||||
defaultMessage: 'An error occurred getting the existing data frame job Ids: {error}',
|
||||
values: { error: JSON.stringify(e) },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
setIndexNames((await ml.getIndices()).map(index => index.name));
|
||||
} catch (e) {
|
||||
toastNotifications.addDanger(
|
||||
i18n.translate('xpack.ml.dataframe.jobDetailsForm.errorGettingDataFrameIndexNames', {
|
||||
defaultMessage: 'An error occurred getting the existing index names: {error}',
|
||||
values: { error: JSON.stringify(e) },
|
||||
})
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const jobIdExists = jobIds.some(id => jobId === id);
|
||||
const indexNameExists = indexNames.some(name => targetIndex === name);
|
||||
const valid = jobId !== '' && targetIndex !== '' && !jobIdExists && !indexNameExists;
|
||||
|
||||
// expose state to wizard
|
||||
useEffect(
|
||||
() => {
|
||||
const valid = jobId !== '' && targetIndex !== '';
|
||||
onChange({ jobId, targetIndex, touched: true, valid });
|
||||
},
|
||||
[jobId, targetIndex]
|
||||
[jobId, targetIndex, valid]
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdLabel', {
|
||||
defaultMessage: 'Job id',
|
||||
})}
|
||||
isInvalid={jobIdExists}
|
||||
error={
|
||||
jobIdExists && [
|
||||
i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdError', {
|
||||
defaultMessage: 'A job with this id already exists.',
|
||||
}),
|
||||
]
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
placeholder="job id"
|
||||
|
@ -61,12 +111,21 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
|
|||
aria-label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdInputAriaLabel', {
|
||||
defaultMessage: 'Choose a unique job id.',
|
||||
})}
|
||||
isInvalid={jobIdExists}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.targetIndexLabel', {
|
||||
defaultMessage: 'Target index',
|
||||
})}
|
||||
isInvalid={indexNameExists}
|
||||
error={
|
||||
indexNameExists && [
|
||||
i18n.translate('xpack.ml.dataframe.jobDetailsForm.targetIndexError', {
|
||||
defaultMessage: 'An index with this name already exists.',
|
||||
}),
|
||||
]
|
||||
}
|
||||
>
|
||||
<EuiFieldText
|
||||
placeholder="target index"
|
||||
|
@ -78,8 +137,9 @@ export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange
|
|||
defaultMessage: 'Choose a unique target index name.',
|
||||
}
|
||||
)}
|
||||
isInvalid={indexNameExists}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</Fragment>
|
||||
</EuiForm>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -4,17 +4,15 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FunctionComponent, useContext, useEffect, useState } from 'react';
|
||||
import React, { FunctionComponent, useContext, useState } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { SearchResponse } from 'elasticsearch';
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiCheckbox,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiInMemoryTable,
|
||||
|
@ -36,8 +34,6 @@ interface ExpandableTableProps extends EuiInMemoryTableProps {
|
|||
|
||||
const ExpandableTable = (EuiInMemoryTable as any) as FunctionComponent<ExpandableTableProps>;
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
|
||||
import { Dictionary } from '../../../../common/types/common';
|
||||
|
||||
import { IndexPatternContext, SimpleQuery } from '../../common';
|
||||
|
@ -45,12 +41,12 @@ import { IndexPatternContext, SimpleQuery } from '../../common';
|
|||
import {
|
||||
EsDoc,
|
||||
EsFieldName,
|
||||
getDefaultSelectableFields,
|
||||
getSelectableFields,
|
||||
MAX_COLUMNS,
|
||||
toggleSelectedField,
|
||||
} from './common';
|
||||
import { ExpandedRow } from './expanded_row';
|
||||
import { SOURCE_INDEX_STATUS, useSourceIndexData } from './use_source_index_data';
|
||||
|
||||
type ItemIdToExpandedRowMap = Dictionary<JSX.Element>;
|
||||
|
||||
|
@ -71,13 +67,25 @@ interface Sorting {
|
|||
|
||||
type TableSorting = Sorting | boolean;
|
||||
|
||||
interface SourceIndexPreviewTitle {
|
||||
indexPatternTitle: string;
|
||||
}
|
||||
const SourceIndexPreviewTitle: React.SFC<SourceIndexPreviewTitle> = ({ indexPatternTitle }) => (
|
||||
<EuiTitle size="xs">
|
||||
<span>
|
||||
{i18n.translate('xpack.ml.dataframe.sourceIndexPreview.sourceIndexPatternTitle', {
|
||||
defaultMessage: 'Source index {indexPatternTitle}',
|
||||
values: { indexPatternTitle },
|
||||
})}
|
||||
</span>
|
||||
</EuiTitle>
|
||||
);
|
||||
|
||||
interface Props {
|
||||
query: SimpleQuery;
|
||||
cellClick?(search: string): void;
|
||||
}
|
||||
|
||||
const SEARCH_SIZE = 1000;
|
||||
|
||||
export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, query }) => {
|
||||
const indexPattern = useContext(IndexPatternContext);
|
||||
|
||||
|
@ -85,9 +93,6 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
|
|||
return null;
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [tableItems, setTableItems] = useState([] as EsDoc[]);
|
||||
const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
|
||||
const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false);
|
||||
|
||||
|
@ -104,14 +109,6 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
|
|||
setSelectedFields([...toggleSelectedField(selectedFields, column)]);
|
||||
}
|
||||
|
||||
let docFields: EsFieldName[] = [];
|
||||
let docFieldsCount = 0;
|
||||
if (tableItems.length > 0) {
|
||||
docFields = getSelectableFields(tableItems);
|
||||
docFields.sort();
|
||||
docFieldsCount = docFields.length;
|
||||
}
|
||||
|
||||
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState(
|
||||
{} as ItemIdToExpandedRowMap
|
||||
);
|
||||
|
@ -126,35 +123,65 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
|
|||
setItemIdToExpandedRowMap({ ...itemIdToExpandedRowMap });
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
setLoading(true);
|
||||
|
||||
ml.esSearch({
|
||||
index: indexPattern.title,
|
||||
rest_total_hits_as_int: true,
|
||||
size: SEARCH_SIZE,
|
||||
body: { query },
|
||||
})
|
||||
.then((resp: SearchResponse<any>) => {
|
||||
const docs = resp.hits.hits;
|
||||
|
||||
if (selectedFields.length === 0) {
|
||||
const newSelectedFields = getDefaultSelectableFields(docs);
|
||||
setSelectedFields(newSelectedFields);
|
||||
}
|
||||
|
||||
setTableItems(docs as EsDoc[]);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((resp: any) => {
|
||||
setTableItems([] as EsDoc[]);
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[indexPattern.title, query.query_string.query]
|
||||
const { errorMessage, status, tableItems } = useSourceIndexData(
|
||||
indexPattern,
|
||||
query,
|
||||
selectedFields,
|
||||
setSelectedFields
|
||||
);
|
||||
|
||||
if (status === SOURCE_INDEX_STATUS.ERROR) {
|
||||
return (
|
||||
<EuiPanel grow={false}>
|
||||
<SourceIndexPreviewTitle indexPatternTitle={indexPattern.title} />
|
||||
<EuiCallOut
|
||||
title={i18n.translate('xpack.ml.dataframe.sourceIndexPreview.sourceIndexPatternError', {
|
||||
defaultMessage: 'An error occurred loading the source index data.',
|
||||
})}
|
||||
color="danger"
|
||||
iconType="cross"
|
||||
>
|
||||
<p>{errorMessage}</p>
|
||||
</EuiCallOut>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === SOURCE_INDEX_STATUS.LOADED && tableItems.length === 0) {
|
||||
return (
|
||||
<EuiPanel grow={false}>
|
||||
<SourceIndexPreviewTitle indexPatternTitle={indexPattern.title} />
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.ml.dataframe.sourceIndexPreview.dataFrameSourceIndexNoDataCalloutTitle',
|
||||
{
|
||||
defaultMessage: 'Empty source index query result.',
|
||||
}
|
||||
)}
|
||||
color="primary"
|
||||
>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'xpack.ml.dataframe.sourceIndexPreview.dataFrameSourceIndexNoDataCalloutBody',
|
||||
{
|
||||
defaultMessage:
|
||||
'The query for the source index returned no results. Please make sure the index contains documents and your query is not too restrictive.',
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
let docFields: EsFieldName[] = [];
|
||||
let docFieldsCount = 0;
|
||||
if (tableItems.length > 0) {
|
||||
docFields = getSelectableFields(tableItems);
|
||||
docFields.sort();
|
||||
docFieldsCount = docFields.length;
|
||||
}
|
||||
|
||||
const columns = selectedFields.map(k => {
|
||||
const column = {
|
||||
field: `_source.${k}`,
|
||||
|
@ -209,24 +236,11 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
|
|||
});
|
||||
}
|
||||
|
||||
if (!loading && tableItems.length === 0) {
|
||||
return (
|
||||
<EuiEmptyPrompt title={<h2>No results</h2>} body={<p>Check the syntax of your query.</p>} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiPanel>
|
||||
<EuiPanel grow={false}>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<span>
|
||||
{i18n.translate('xpack.ml.dataframe.sourceIndexPreview.sourceIndexPatternTitle', {
|
||||
defaultMessage: 'Source Index {indexPatternTitle}',
|
||||
values: { indexPatternTitle: indexPattern.title },
|
||||
})}
|
||||
</span>
|
||||
</EuiTitle>
|
||||
<SourceIndexPreviewTitle indexPatternTitle={indexPattern.title} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup alignItems="center">
|
||||
|
@ -287,8 +301,10 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
|
|||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{loading && <EuiProgress size="xs" color="accent" />}
|
||||
{!loading && <EuiProgress size="xs" color="accent" max={1} value={0} />}
|
||||
{status === SOURCE_INDEX_STATUS.LOADING && <EuiProgress size="xs" color="accent" />}
|
||||
{status !== SOURCE_INDEX_STATUS.LOADING && (
|
||||
<EuiProgress size="xs" color="accent" max={1} value={0} />
|
||||
)}
|
||||
<ExpandableTable
|
||||
items={tableItems}
|
||||
columns={columns}
|
||||
|
|
|
@ -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, { SFC } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
import { SimpleQuery } from '../../common';
|
||||
import {
|
||||
SOURCE_INDEX_STATUS,
|
||||
useSourceIndexData,
|
||||
UseSourceIndexDataReturnType,
|
||||
} from './use_source_index_data';
|
||||
|
||||
jest.mock('../../../services/ml_api_service');
|
||||
|
||||
type Callback = () => void;
|
||||
interface TestHookProps {
|
||||
callback: Callback;
|
||||
}
|
||||
|
||||
const TestHook: SFC<TestHookProps> = ({ callback }) => {
|
||||
callback();
|
||||
return null;
|
||||
};
|
||||
|
||||
const testHook = (callback: Callback) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
ReactDOM.render(<TestHook callback={callback} />, container);
|
||||
});
|
||||
};
|
||||
|
||||
const query: SimpleQuery = {
|
||||
query_string: {
|
||||
query: '*',
|
||||
default_operator: 'AND',
|
||||
},
|
||||
};
|
||||
|
||||
let sourceIndexObj: UseSourceIndexDataReturnType;
|
||||
|
||||
describe('useSourceIndexData', () => {
|
||||
test('indexPattern not defined', () => {
|
||||
testHook(() => {
|
||||
act(() => {
|
||||
sourceIndexObj = useSourceIndexData(null, query, [], () => {});
|
||||
});
|
||||
});
|
||||
|
||||
expect(sourceIndexObj.errorMessage).toBe('');
|
||||
expect(sourceIndexObj.status).toBe(SOURCE_INDEX_STATUS.UNUSED);
|
||||
expect(sourceIndexObj.tableItems).toEqual([]);
|
||||
expect(ml.esSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('indexPattern set triggers loading', () => {
|
||||
testHook(() => {
|
||||
act(() => {
|
||||
sourceIndexObj = useSourceIndexData({ title: 'lorem', fields: [] }, query, [], () => {});
|
||||
});
|
||||
});
|
||||
|
||||
expect(sourceIndexObj.errorMessage).toBe('');
|
||||
expect(sourceIndexObj.status).toBe(SOURCE_INDEX_STATUS.LOADING);
|
||||
expect(sourceIndexObj.tableItems).toEqual([]);
|
||||
expect(ml.esSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// TODO add more tests to check data retrieved via `ml.esSearch()`.
|
||||
// This needs more investigation in regards to jest/enzyme's React Hooks support.
|
||||
});
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { SearchResponse } from 'elasticsearch';
|
||||
|
||||
import { ml } from '../../../services/ml_api_service';
|
||||
|
||||
import { SimpleQuery } from '../../common';
|
||||
import { IndexPatternContextValue } from '../../common/index_pattern_context';
|
||||
import { EsDoc, EsFieldName, getDefaultSelectableFields } from './common';
|
||||
|
||||
const SEARCH_SIZE = 1000;
|
||||
|
||||
export enum SOURCE_INDEX_STATUS {
|
||||
UNUSED,
|
||||
LOADING,
|
||||
LOADED,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
export interface UseSourceIndexDataReturnType {
|
||||
errorMessage: string;
|
||||
status: SOURCE_INDEX_STATUS;
|
||||
tableItems: EsDoc[];
|
||||
}
|
||||
|
||||
export const useSourceIndexData = (
|
||||
indexPattern: IndexPatternContextValue,
|
||||
query: SimpleQuery,
|
||||
selectedFields: EsFieldName[],
|
||||
setSelectedFields: React.Dispatch<React.SetStateAction<EsFieldName[]>>
|
||||
): UseSourceIndexDataReturnType => {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [status, setStatus] = useState(SOURCE_INDEX_STATUS.UNUSED);
|
||||
const [tableItems, setTableItems] = useState([] as EsDoc[]);
|
||||
|
||||
if (indexPattern !== null) {
|
||||
const getSourceIndexData = async function() {
|
||||
setErrorMessage('');
|
||||
setStatus(SOURCE_INDEX_STATUS.LOADING);
|
||||
|
||||
try {
|
||||
const resp: SearchResponse<any> = await ml.esSearch({
|
||||
index: indexPattern.title,
|
||||
size: SEARCH_SIZE,
|
||||
body: { query },
|
||||
});
|
||||
|
||||
const docs = resp.hits.hits;
|
||||
|
||||
if (selectedFields.length === 0) {
|
||||
const newSelectedFields = getDefaultSelectableFields(docs);
|
||||
setSelectedFields(newSelectedFields);
|
||||
}
|
||||
|
||||
setTableItems(docs as EsDoc[]);
|
||||
setStatus(SOURCE_INDEX_STATUS.LOADED);
|
||||
} catch (e) {
|
||||
setErrorMessage(JSON.stringify(e));
|
||||
setTableItems([] as EsDoc[]);
|
||||
setStatus(SOURCE_INDEX_STATUS.ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
getSourceIndexData();
|
||||
},
|
||||
[indexPattern.title, query.query_string.query]
|
||||
);
|
||||
}
|
||||
return { errorMessage, status, tableItems };
|
||||
};
|
|
@ -10,6 +10,10 @@ import { Annotation } from '../../../common/types/annotations';
|
|||
// It just satisfies needs for other parts of the code area which use
|
||||
// TypeScript and rely on the methods typed in here.
|
||||
// This allows the import of `ml` into TypeScript code.
|
||||
interface EsIndex {
|
||||
name: string;
|
||||
}
|
||||
|
||||
declare interface Ml {
|
||||
annotations: {
|
||||
deleteAnnotation(id: string | undefined): Promise<any>;
|
||||
|
@ -26,6 +30,7 @@ declare interface Ml {
|
|||
stopDataFrameTransformsJob(jobId: string): Promise<any>;
|
||||
};
|
||||
esSearch: any;
|
||||
getIndices(): Promise<EsIndex[]>;
|
||||
|
||||
getTimeFieldRange(obj: object): Promise<any>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue