Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Data Frame Analytics: Don't allow user to pick an index pattern or saved search based on CCS. #96555

Merged
merged 2 commits into from Apr 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,216 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';

import { IntlProvider } from 'react-intl';

import {
getIndexPatternAndSavedSearch,
IndexPatternAndSavedSearch,
} from '../../../../../util/index_utils';

import { SourceSelection } from './source_selection';

jest.mock('../../../../../../../../../../src/plugins/saved_objects/public', () => {
const SavedObjectFinderUi = ({
onChoose,
}: {
onChoose: (id: string, type: string, fullName: string, savedObject: object) => void;
}) => {
return (
<>
<button
onClick={() =>
onChoose('the-remote-index-pattern-id', 'index-pattern', 'the-full-name', {
attributes: { title: 'my_remote_cluster:index-pattern-title' },
})
}
>
RemoteIndexPattern
</button>
<button
onClick={() =>
onChoose('the-plain-index-pattern-id', 'index-pattern', 'the-full-name', {
attributes: { title: 'index-pattern-title' },
})
}
>
PlainIndexPattern
</button>
<button
onClick={() =>
onChoose('the-remote-saved-search-id', 'search', 'the-full-name', {
attributes: { title: 'the-remote-saved-search-title' },
})
}
>
RemoteSavedSearch
</button>
<button
onClick={() =>
onChoose('the-plain-saved-search-id', 'search', 'the-full-name', {
attributes: { title: 'the-plain-saved-search-title' },
})
}
>
PlainSavedSearch
</button>
</>
);
};

return {
SavedObjectFinderUi,
};
});

const mockNavigateToPath = jest.fn();
jest.mock('../../../../../contexts/kibana', () => ({
useMlKibana: () => ({
services: {
savedObjects: {},
uiSettings: {},
},
}),
useNavigateToPath: () => mockNavigateToPath,
}));

jest.mock('../../../../../util/index_utils', () => {
return {
getIndexPatternAndSavedSearch: jest.fn(
async (id: string): Promise<IndexPatternAndSavedSearch> => {
return {
indexPattern: {
fields: [],
title:
id === 'the-remote-saved-search-id'
? 'my_remote_cluster:index-pattern-title'
: 'index-pattern-title',
},
savedSearch: null,
};
}
),
};
});

const mockOnClose = jest.fn();
const mockGetIndexPatternAndSavedSearch = getIndexPatternAndSavedSearch as jest.Mock;

describe('Data Frame Analytics: <SourceSelection />', () => {
afterEach(() => {
mockNavigateToPath.mockClear();
mockGetIndexPatternAndSavedSearch.mockClear();
});

it('renders the title text', async () => {
// prepare
render(
<IntlProvider locale="en">
<SourceSelection onClose={mockOnClose} />
</IntlProvider>
);

// assert
expect(screen.queryByText('New analytics job')).toBeInTheDocument();
expect(mockNavigateToPath).toHaveBeenCalledTimes(0);
expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0);
});

it('shows the error callout when clicking a remote index pattern', async () => {
// prepare
render(
<IntlProvider locale="en">
<SourceSelection onClose={mockOnClose} />
</IntlProvider>
);

// act
fireEvent.click(screen.getByText('RemoteIndexPattern', { selector: 'button' }));
await waitFor(() => screen.getByTestId('analyticsCreateSourceIndexModalCcsErrorCallOut'));

// assert
expect(
screen.queryByText('Index patterns using cross-cluster search are not supported.')
).toBeInTheDocument();
expect(mockNavigateToPath).toHaveBeenCalledTimes(0);
expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0);
});

it('calls navigateToPath for a plain index pattern ', async () => {
// prepare
render(
<IntlProvider locale="en">
<SourceSelection onClose={mockOnClose} />
</IntlProvider>
);

// act
fireEvent.click(screen.getByText('PlainIndexPattern', { selector: 'button' }));

// assert
await waitFor(() => {
expect(
screen.queryByText('Index patterns using cross-cluster search are not supported.')
).not.toBeInTheDocument();
expect(mockNavigateToPath).toHaveBeenCalledWith(
'/data_frame_analytics/new_job?index=the-plain-index-pattern-id'
);
expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledTimes(0);
});
});

it('shows the error callout when clicking a saved search using a remote index pattern', async () => {
// prepare
render(
<IntlProvider locale="en">
<SourceSelection onClose={mockOnClose} />
</IntlProvider>
);

// act
fireEvent.click(screen.getByText('RemoteSavedSearch', { selector: 'button' }));
await waitFor(() => screen.getByTestId('analyticsCreateSourceIndexModalCcsErrorCallOut'));

// assert
expect(
screen.queryByText('Index patterns using cross-cluster search are not supported.')
).toBeInTheDocument();
expect(
screen.queryByText(
`The saved search 'the-remote-saved-search-title' uses the index pattern 'my_remote_cluster:index-pattern-title'.`
)
).toBeInTheDocument();
expect(mockNavigateToPath).toHaveBeenCalledTimes(0);
expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledWith('the-remote-saved-search-id');
});

it('calls navigateToPath for a saved search using a plain index pattern ', async () => {
// prepare
render(
<IntlProvider locale="en">
<SourceSelection onClose={mockOnClose} />
</IntlProvider>
);

// act
fireEvent.click(screen.getByText('PlainSavedSearch', { selector: 'button' }));

// assert
await waitFor(() => {
expect(
screen.queryByText('Index patterns using cross-cluster search are not supported.')
).not.toBeInTheDocument();
expect(mockNavigateToPath).toHaveBeenCalledWith(
'/data_frame_analytics/new_job?savedSearchId=the-plain-saved-search-id'
);
expect(mockGetIndexPatternAndSavedSearch).toHaveBeenCalledWith('the-plain-saved-search-id');
});
});
});
Expand Up @@ -5,15 +5,28 @@
* 2.0.
*/

import React, { FC } from 'react';
import React, { useState, FC } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';

import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui';
import {
EuiCallOut,
EuiModal,
EuiModalBody,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
} from '@elastic/eui';

import type { SimpleSavedObject } from 'src/core/public';

import { SavedObjectFinderUi } from '../../../../../../../../../../src/plugins/saved_objects/public';
import { useMlKibana, useNavigateToPath } from '../../../../../contexts/kibana';

import { getNestedProperty } from '../../../../../util/object_utils';

import { getIndexPatternAndSavedSearch } from '../../../../../util/index_utils';

const fixedPageSize: number = 8;

interface Props {
Expand All @@ -26,7 +39,49 @@ export const SourceSelection: FC<Props> = ({ onClose }) => {
} = useMlKibana();
const navigateToPath = useNavigateToPath();

const onSearchSelected = async (id: string, type: string) => {
const [isCcsCallOut, setIsCcsCallOut] = useState(false);
const [ccsCallOutBodyText, setCcsCallOutBodyText] = useState<string>();

const onSearchSelected = async (
id: string,
type: string,
fullName: string,
savedObject: SimpleSavedObject
) => {
// Kibana index patterns including `:` are cross-cluster search indices
// and are not supported by Data Frame Analytics yet. For saved searches
// and index patterns that use cross-cluster search we intercept
// the selection before redirecting and show an error callout instead.
let indexPatternTitle = '';

if (type === 'index-pattern') {
indexPatternTitle = getNestedProperty(savedObject, 'attributes.title');
} else if (type === 'search') {
const indexPatternAndSavedSearch = await getIndexPatternAndSavedSearch(id);
indexPatternTitle = indexPatternAndSavedSearch.indexPattern?.title ?? '';
}

if (indexPatternTitle.includes(':')) {
setIsCcsCallOut(true);
if (type === 'search') {
setCcsCallOutBodyText(
i18n.translate(
'xpack.ml.dataFrame.analytics.create.searchSelection.CcsErrorCallOutBody',
{
defaultMessage: `The saved search '{savedSearchTitle}' uses the index pattern '{indexPatternTitle}'.`,
values: {
savedSearchTitle: getNestedProperty(savedObject, 'attributes.title'),
indexPatternTitle,
},
}
)
);
} else {
setCcsCallOutBodyText(undefined);
}
return;
}

await navigateToPath(
`/data_frame_analytics/new_job?${
type === 'index-pattern' ? 'index' : 'savedSearchId'
Expand Down Expand Up @@ -54,6 +109,23 @@ export const SourceSelection: FC<Props> = ({ onClose }) => {
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{isCcsCallOut && (
<>
<EuiCallOut
data-test-subj="analyticsCreateSourceIndexModalCcsErrorCallOut"
title={i18n.translate(
'xpack.ml.dataFrame.analytics.create.searchSelection.CcsErrorCallOutTitle',
{
defaultMessage: 'Index patterns using cross-cluster search are not supported.',
}
)}
color="danger"
>
{typeof ccsCallOutBodyText === 'string' && <p>{ccsCallOutBodyText}</p>}
</EuiCallOut>
<EuiSpacer size="m" />
</>
)}
<SavedObjectFinderUi
key="searchSavedObjectFinder"
onChoose={onSearchSelected}
Expand Down