diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts
new file mode 100644
index 0000000000000..0647eeba78786
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts
@@ -0,0 +1,215 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const DEFAULT_LANGUAGE = 'Universal';
+
+export const ENGINE_CREATION_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.title',
+ {
+ defaultMessage: 'Create an engine',
+ }
+);
+
+export const ENGINE_CREATION_FORM_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.form.title',
+ {
+ defaultMessage: 'Name your engine',
+ }
+);
+
+export const ENGINE_CREATION_FORM_ENGINE_NAME_LABEL = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.label',
+ {
+ defaultMessage: 'Engine name',
+ }
+);
+
+export const ALLOWED_CHARS_NOTE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.allowedCharactersHelpText',
+ {
+ defaultMessage: 'Engine names can only contain lowercase letters, numbers, and hyphens',
+ }
+);
+
+export const SANITIZED_NAME_NOTE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.sanitizedNameHelpText',
+ {
+ defaultMessage: 'Your engine will be named',
+ }
+);
+
+export const ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.placeholder',
+ {
+ defaultMessage: 'i.e., my-search-engine',
+ }
+);
+
+export const ENGINE_CREATION_FORM_ENGINE_LANGUAGE_LABEL = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineLanguage.label',
+ {
+ defaultMessage: 'Engine language',
+ }
+);
+
+export const ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.form.submitButton.buttonLabel',
+ {
+ defaultMessage: 'Create engine',
+ }
+);
+
+export const ENGINE_CREATION_SUCCESS_MESSAGE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.successMessage',
+ {
+ defaultMessage: 'Successfully created engine.',
+ }
+);
+
+export const SUPPORTED_LANGUAGES = [
+ {
+ value: 'Universal',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.universalDropDownOptionLabel',
+ {
+ defaultMessage: 'Universal',
+ }
+ ),
+ },
+ {
+ text: '—',
+ disabled: true,
+ },
+ {
+ value: 'zh',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.chineseDropDownOptionLabel',
+ {
+ defaultMessage: 'Chinese',
+ }
+ ),
+ },
+ {
+ value: 'da',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.danishDropDownOptionLabel',
+ {
+ defaultMessage: 'Danish',
+ }
+ ),
+ },
+ {
+ value: 'nl',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.dutchDropDownOptionLabel',
+ {
+ defaultMessage: 'Dutch',
+ }
+ ),
+ },
+ {
+ value: 'en',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.englishDropDownOptionLabel',
+ {
+ defaultMessage: 'English',
+ }
+ ),
+ },
+ {
+ value: 'fr',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.frenchDropDownOptionLabel',
+ {
+ defaultMessage: 'French',
+ }
+ ),
+ },
+ {
+ value: 'de',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.germanDropDownOptionLabel',
+ {
+ defaultMessage: 'German',
+ }
+ ),
+ },
+ {
+ value: 'it',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.italianDropDownOptionLabel',
+ {
+ defaultMessage: 'Italian',
+ }
+ ),
+ },
+ {
+ value: 'ja',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.japaneseDropDownOptionLabel',
+ {
+ defaultMessage: 'Japanese',
+ }
+ ),
+ },
+ {
+ value: 'ko',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.koreanDropDownOptionLabel',
+ {
+ defaultMessage: 'Korean',
+ }
+ ),
+ },
+ {
+ value: 'pt',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.portugueseDropDownOptionLabel',
+ {
+ defaultMessage: 'Portuguese',
+ }
+ ),
+ },
+ {
+ value: 'pt-br',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.portugueseBrazilDropDownOptionLabel',
+ {
+ defaultMessage: 'Portuguese (Brazil)',
+ }
+ ),
+ },
+ {
+ value: 'ru',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.russianDropDownOptionLabel',
+ {
+ defaultMessage: 'Russian',
+ }
+ ),
+ },
+ {
+ value: 'es',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.spanishDropDownOptionLabel',
+ {
+ defaultMessage: 'Spanish',
+ }
+ ),
+ },
+ {
+ value: 'th',
+ text: i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.thaiDropDownOptionLabel',
+ {
+ defaultMessage: 'Thai',
+ }
+ ),
+ },
+];
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx
new file mode 100644
index 0000000000000..cf30fac3c5f49
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx
@@ -0,0 +1,119 @@
+/*
+ * 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 { setMockActions, setMockValues } from '../../../__mocks__';
+
+import React from 'react';
+
+import { shallow } from 'enzyme';
+
+import { EngineCreation } from './';
+
+describe('EngineCreation', () => {
+ const DEFAULT_VALUES = {
+ name: '',
+ rawName: '',
+ language: 'Universal',
+ };
+
+ const MOCK_ACTIONS = {
+ setRawName: jest.fn(),
+ setLanguage: jest.fn(),
+ submitEngine: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setMockValues(DEFAULT_VALUES);
+ setMockActions(MOCK_ACTIONS);
+ });
+
+ it('renders', () => {
+ const wrapper = shallow();
+ expect(wrapper.find('[data-test-subj="EngineCreation"]')).toHaveLength(1);
+ });
+
+ it('EngineCreationForm calls submitEngine on form submit', () => {
+ const wrapper = shallow();
+ const simulatedEvent = {
+ preventDefault: jest.fn(),
+ };
+ wrapper.find('[data-test-subj="EngineCreationForm"]').simulate('submit', simulatedEvent);
+
+ expect(MOCK_ACTIONS.submitEngine).toHaveBeenCalledTimes(1);
+ });
+
+ it('EngineCreationNameInput calls setRawName on change', () => {
+ const wrapper = shallow();
+ const simulatedEvent = {
+ currentTarget: { value: 'new-raw-name' },
+ };
+ wrapper.find('[data-test-subj="EngineCreationNameInput"]').simulate('change', simulatedEvent);
+
+ expect(MOCK_ACTIONS.setRawName).toHaveBeenCalledWith('new-raw-name');
+ });
+
+ it('EngineCreationLanguageInput calls setLanguage on change', () => {
+ const wrapper = shallow();
+ const simulatedEvent = {
+ currentTarget: { value: 'English' },
+ };
+ wrapper
+ .find('[data-test-subj="EngineCreationLanguageInput"]')
+ .simulate('change', simulatedEvent);
+
+ expect(MOCK_ACTIONS.setLanguage).toHaveBeenCalledWith('English');
+ });
+
+ describe('NewEngineSubmitButton', () => {
+ it('is disabled when name is empty', () => {
+ setMockValues({ ...DEFAULT_VALUES, name: '', rawName: '' });
+ const wrapper = shallow();
+
+ expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]').prop('disabled')).toEqual(
+ true
+ );
+ });
+
+ it('is enabled when name has a value', () => {
+ setMockValues({ ...DEFAULT_VALUES, name: 'test', rawName: 'test' });
+ const wrapper = shallow();
+
+ expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]').prop('disabled')).toEqual(
+ false
+ );
+ });
+ });
+
+ describe('EngineCreationNameFormRow', () => {
+ it('renders sanitized name helptext when the raw name is being sanitized', () => {
+ setMockValues({
+ ...DEFAULT_VALUES,
+ name: 'un-sanitized-name',
+ rawName: 'un-----sanitized-------name',
+ });
+ const wrapper = shallow();
+ const formRow = wrapper.find('[data-test-subj="EngineCreationNameFormRow"]').dive();
+
+ expect(formRow.contains('Your engine will be named')).toBeTruthy();
+ });
+
+ it('renders allowed character helptext when rawName and sanitizedName match', () => {
+ setMockValues({
+ ...DEFAULT_VALUES,
+ name: 'pre-sanitized-name',
+ rawName: 'pre-sanitized-name',
+ });
+ const wrapper = shallow();
+ const formRow = wrapper.find('[data-test-subj="EngineCreationNameFormRow"]').dive();
+
+ expect(
+ formRow.contains('Engine names can only contain lowercase letters, numbers, and hyphens')
+ ).toBeTruthy();
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx
new file mode 100644
index 0000000000000..497c00d1f9144
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx
@@ -0,0 +1,130 @@
+/*
+ * 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 { useActions, useValues } from 'kea';
+
+import {
+ EuiForm,
+ EuiFlexGroup,
+ EuiFormRow,
+ EuiFlexItem,
+ EuiFieldText,
+ EuiSelect,
+ EuiPageBody,
+ EuiPageHeader,
+ EuiPageHeaderSection,
+ EuiSpacer,
+ EuiText,
+ EuiTitle,
+ EuiButton,
+ EuiPanel,
+} from '@elastic/eui';
+
+import { FlashMessages } from '../../../shared/flash_messages';
+import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
+
+import {
+ ALLOWED_CHARS_NOTE,
+ ENGINE_CREATION_FORM_ENGINE_LANGUAGE_LABEL,
+ ENGINE_CREATION_FORM_ENGINE_NAME_LABEL,
+ ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER,
+ ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL,
+ ENGINE_CREATION_FORM_TITLE,
+ ENGINE_CREATION_TITLE,
+ SANITIZED_NAME_NOTE,
+ SUPPORTED_LANGUAGES,
+} from './constants';
+import { EngineCreationLogic } from './engine_creation_logic';
+
+export const EngineCreation: React.FC = () => {
+ const { name, rawName, language } = useValues(EngineCreationLogic);
+ const { setLanguage, setRawName, submitEngine } = useActions(EngineCreationLogic);
+
+ return (
+
+
+
+
+
+ {ENGINE_CREATION_TITLE}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts
new file mode 100644
index 0000000000000..272e4fb3a25c0
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts
@@ -0,0 +1,122 @@
+/*
+ * 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 {
+ LogicMounter,
+ mockHttpValues,
+ mockKibanaValues,
+ mockFlashMessageHelpers,
+} from '../../../__mocks__';
+
+import { nextTick } from '@kbn/test/jest';
+
+import { EngineCreationLogic } from './engine_creation_logic';
+
+describe('EngineCreationLogic', () => {
+ const { mount } = new LogicMounter(EngineCreationLogic);
+ const { http } = mockHttpValues;
+ const { navigateToUrl } = mockKibanaValues;
+ const { setQueuedSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers;
+
+ const DEFAULT_VALUES = {
+ name: '',
+ rawName: '',
+ language: 'Universal',
+ };
+
+ it('has expected default values', () => {
+ mount();
+ expect(EngineCreationLogic.values).toEqual(DEFAULT_VALUES);
+ });
+
+ describe('actions', () => {
+ describe('setLanguage', () => {
+ it('sets language to the provided value', () => {
+ mount();
+ EngineCreationLogic.actions.setLanguage('English');
+ expect(EngineCreationLogic.values).toEqual({
+ ...DEFAULT_VALUES,
+ language: 'English',
+ });
+ });
+ });
+
+ describe('setRawName', () => {
+ beforeAll(() => {
+ mount();
+ EngineCreationLogic.actions.setRawName('Name__With#$&*%Special--Characters');
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should set rawName to provided value', () => {
+ expect(EngineCreationLogic.values.rawName).toEqual('Name__With#$&*%Special--Characters');
+ });
+
+ it('should set name to a sanitized value', () => {
+ expect(EngineCreationLogic.values.name).toEqual('name-with-special-characters');
+ });
+ });
+ });
+
+ describe('listeners', () => {
+ describe('onEngineCreationSuccess', () => {
+ beforeAll(() => {
+ mount({ language: 'English', rawName: 'test' });
+ EngineCreationLogic.actions.onEngineCreationSuccess();
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should set a success message', () => {
+ expect(setQueuedSuccessMessage).toHaveBeenCalledWith('Successfully created engine.');
+ });
+
+ it('should navigate the user to the engine page', () => {
+ expect(navigateToUrl).toHaveBeenCalledWith('/engines/test');
+ });
+ });
+
+ describe('submitEngine', () => {
+ beforeAll(() => {
+ mount({ language: 'English', rawName: 'test' });
+ });
+
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ it('POSTS to /api/app_search/engines', () => {
+ const body = JSON.stringify({
+ name: EngineCreationLogic.values.name,
+ language: EngineCreationLogic.values.language,
+ });
+ EngineCreationLogic.actions.submitEngine();
+ expect(http.post).toHaveBeenCalledWith('/api/app_search/engines', { body });
+ });
+
+ it('calls onEngineCreationSuccess on valid submission', async () => {
+ jest.spyOn(EngineCreationLogic.actions, 'onEngineCreationSuccess');
+ http.post.mockReturnValueOnce(Promise.resolve({}));
+ EngineCreationLogic.actions.submitEngine();
+ await nextTick();
+ expect(EngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls flashAPIErrors on API Error', async () => {
+ http.post.mockReturnValueOnce(Promise.reject());
+ EngineCreationLogic.actions.submitEngine();
+ await nextTick();
+ expect(flashAPIErrors).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts
new file mode 100644
index 0000000000000..6cea32f826e7a
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts
@@ -0,0 +1,81 @@
+/*
+ * 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 { generatePath } from 'react-router-dom';
+
+import { kea, MakeLogicType } from 'kea';
+
+import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages';
+import { HttpLogic } from '../../../shared/http';
+import { KibanaLogic } from '../../../shared/kibana';
+import { ENGINE_PATH } from '../../routes';
+import { formatApiName } from '../../utils/format_api_name';
+
+import { DEFAULT_LANGUAGE, ENGINE_CREATION_SUCCESS_MESSAGE } from './constants';
+
+interface EngineCreationActions {
+ onEngineCreationSuccess(): void;
+ setLanguage(language: string): { language: string };
+ setRawName(rawName: string): { rawName: string };
+ submitEngine(): void;
+}
+
+interface EngineCreationValues {
+ language: string;
+ name: string;
+ rawName: string;
+}
+
+export const EngineCreationLogic = kea>({
+ path: ['enterprise_search', 'app_search', 'engine_creation_logic'],
+ actions: {
+ onEngineCreationSuccess: true,
+ setLanguage: (language) => ({ language }),
+ setRawName: (rawName) => ({ rawName }),
+ submitEngine: true,
+ },
+ reducers: {
+ language: [
+ DEFAULT_LANGUAGE,
+ {
+ setLanguage: (_, { language }) => language,
+ },
+ ],
+ rawName: [
+ '',
+ {
+ setRawName: (_, { rawName }) => rawName,
+ },
+ ],
+ },
+ selectors: ({ selectors }) => ({
+ name: [() => [selectors.rawName], (rawName) => formatApiName(rawName)],
+ }),
+ listeners: ({ values, actions }) => ({
+ submitEngine: async () => {
+ const { http } = HttpLogic.values;
+ const { name, language } = values;
+
+ const body = JSON.stringify({ name, language });
+
+ try {
+ await http.post('/api/app_search/engines', { body });
+ actions.onEngineCreationSuccess();
+ } catch (e) {
+ flashAPIErrors(e);
+ }
+ },
+ onEngineCreationSuccess: () => {
+ const { name } = values;
+ const { navigateToUrl } = KibanaLogic.values;
+ const enginePath = generatePath(ENGINE_PATH, { engineName: name });
+
+ setQueuedSuccessMessage(ENGINE_CREATION_SUCCESS_MESSAGE);
+ navigateToUrl(enginePath);
+ },
+ }),
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts
new file mode 100644
index 0000000000000..a1770cc50ea93
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { EngineCreation } from './engine_creation';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx
index 81bf3716edfb8..f505f08a3531a 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx
@@ -16,6 +16,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { FlashMessages } from '../../../shared/flash_messages';
import { DOCS_PREFIX } from '../../routes';
import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation';
@@ -41,6 +42,7 @@ export const EmptyEngineOverview: React.FC = () => {
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx
index 34a154ca83741..c33431354eafc 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx
@@ -12,6 +12,8 @@ import { useValues } from 'kea';
import { EuiPageHeader, EuiTitle, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { FlashMessages } from '../../../shared/flash_messages';
+
import { UnavailablePrompt, TotalStats, TotalCharts, RecentApiLogs } from './components';
import { EngineOverviewLogic } from './';
@@ -30,6 +32,7 @@ export const EngineOverviewMetrics: React.FC = () => {
+
{apiLogsUnavailable ? (
) : (
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx
index ac540eec3ff91..14772375c9bd4 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx
@@ -10,9 +10,9 @@ import { mockTelemetryActions } from '../../../../__mocks__';
import React from 'react';
-import { shallow } from 'enzyme';
+import { shallow, ShallowWrapper } from 'enzyme';
-import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
+import { EuiEmptyPrompt } from '@elastic/eui';
import { EmptyState } from './';
@@ -23,12 +23,24 @@ describe('EmptyState', () => {
expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1);
});
- it('sends telemetry on create first engine click', () => {
- const wrapper = shallow();
- const prompt = wrapper.find(EuiEmptyPrompt).dive();
- const button = prompt.find(EuiButton);
-
- button.simulate('click');
- expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled();
+ describe('CTA Button', () => {
+ let wrapper: ShallowWrapper;
+ let prompt: ShallowWrapper;
+ let button: ShallowWrapper;
+
+ beforeEach(() => {
+ wrapper = shallow();
+ prompt = wrapper.find(EuiEmptyPrompt).dive();
+ button = prompt.find('[data-test-subj="EmptyStateCreateFirstEngineCta"]');
+ });
+
+ it('sends telemetry on create first engine click', () => {
+ button.simulate('click');
+ expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled();
+ });
+
+ it('sends a user to engine creation', () => {
+ expect(button.prop('to')).toEqual('/engine_creation');
+ });
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx
index 5419a175c9eff..d742d68b0c9d6 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx
@@ -9,13 +9,13 @@ import React from 'react';
import { useActions } from 'kea';
-import { EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui';
+import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
-import { getAppSearchUrl } from '../../../../shared/enterprise_search_url';
import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome';
+import { EuiButtonTo } from '../../../../shared/react_router_helpers';
import { TelemetryLogic } from '../../../../shared/telemetry';
-import { CREATE_ENGINES_PATH } from '../../../routes';
+import { ENGINE_CREATION_PATH } from '../../../routes';
import { EnginesOverviewHeader } from './header';
@@ -24,16 +24,6 @@ import './empty_state.scss';
export const EmptyState: React.FC = () => {
const { sendAppSearchTelemetry } = useActions(TelemetryLogic);
- const buttonProps = {
- href: getAppSearchUrl(CREATE_ENGINES_PATH),
- target: '_blank',
- onClick: () =>
- sendAppSearchTelemetry({
- action: 'clicked',
- metric: 'create_first_engine_button',
- }),
- };
-
return (
<>
@@ -60,12 +50,23 @@ export const EmptyState: React.FC = () => {
}
actions={
-
+
+ sendAppSearchTelemetry({
+ action: 'clicked',
+ metric: 'create_first_engine_button',
+ })
+ }
+ >
-
+
}
/>
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts
index 8b387668b89f9..401d4ccd6d117 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts
@@ -15,3 +15,10 @@ export const META_ENGINES_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.metaEngines.title',
{ defaultMessage: 'Meta Engines' }
);
+
+export const CREATE_AN_ENGINE_BUTTON_LABEL = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.engines.createAnEngineButton.ButtonLabel',
+ {
+ defaultMessage: 'Create an engine',
+ }
+);
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx
index cdc06dbbe3921..978538d26e5d6 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx
@@ -77,6 +77,14 @@ describe('EnginesOverview', () => {
expect(actions.loadEngines).toHaveBeenCalled();
});
+ it('renders a create engine button which takes users to the create engine page', () => {
+ const wrapper = shallow();
+
+ expect(
+ wrapper.find('[data-test-subj="appSearchEnginesEngineCreationButton"]').prop('to')
+ ).toEqual('/engine_creation');
+ });
+
describe('when on a platinum license', () => {
it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => {
setMockValues({
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx
index 2835c8b7cb3c4..1a81c1918ad4d 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx
@@ -12,6 +12,7 @@ import { useValues, useActions } from 'kea';
import {
EuiPageContent,
EuiPageContentHeader,
+ EuiPageContentHeaderSection,
EuiPageContentBody,
EuiTitle,
EuiSpacer,
@@ -20,12 +21,14 @@ import {
import { FlashMessages } from '../../../shared/flash_messages';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
import { LicensingLogic } from '../../../shared/licensing';
+import { EuiButtonTo } from '../../../shared/react_router_helpers';
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
+import { ENGINE_CREATION_PATH } from '../../routes';
import { EngineIcon } from './assets/engine_icon';
import { MetaEngineIcon } from './assets/meta_engine_icon';
import { EnginesOverviewHeader, LoadingState, EmptyState } from './components';
-import { ENGINES_TITLE, META_ENGINES_TITLE } from './constants';
+import { CREATE_AN_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE } from './constants';
import { EnginesLogic } from './engines_logic';
import { EnginesTable } from './engines_table';
@@ -65,12 +68,24 @@ export const EnginesOverview: React.FC = () => {
-
-
-
- {ENGINES_TITLE}
-
-
+
+
+
+
+ {ENGINES_TITLE}
+
+
+
+
+
+ {CREATE_AN_ENGINE_BUTTON_LABEL}
+
+
{
});
describe('ability checks', () => {
- // TODO: Use this section for routes wrapped in canViewX conditionals
- // e.g., it('renders settings if a user can view settings')
+ describe('canManageEngines', () => {
+ it('renders EngineCreation when user canManageEngines is true', () => {
+ setMockValues({ myRole: { canManageEngines: true } });
+ const wrapper = shallow();
+
+ expect(wrapper.find(EngineCreation)).toHaveLength(1);
+ });
+
+ it('does not render EngineCreation when user canManageEngines is false', () => {
+ setMockValues({ myRole: { canManageEngines: false } });
+ const wrapper = shallow();
+
+ expect(wrapper.find(EngineCreation)).toHaveLength(0);
+ });
+ });
});
});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
index 36ac3fb4dbc5b..40dfc1426e402 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx
@@ -21,6 +21,7 @@ import { NotFound } from '../shared/not_found';
import { AppLogic } from './app_logic';
import { Credentials, CREDENTIALS_TITLE } from './components/credentials';
import { EngineNav, EngineRouter } from './components/engine';
+import { EngineCreation } from './components/engine_creation';
import { EnginesOverview, ENGINES_TITLE } from './components/engines';
import { ErrorConnecting } from './components/error_connecting';
import { Library } from './components/library';
@@ -28,6 +29,7 @@ import { ROLE_MAPPINGS_TITLE } from './components/role_mappings';
import { Settings, SETTINGS_TITLE } from './components/settings';
import { SetupGuide } from './components/setup_guide';
import {
+ ENGINE_CREATION_PATH,
ROOT_PATH,
SETUP_GUIDE_PATH,
SETTINGS_PATH,
@@ -56,7 +58,10 @@ export const AppSearchUnconfigured: React.FC = () => (
export const AppSearchConfigured: React.FC = (props) => {
const { initializeAppData } = useActions(AppLogic);
- const { hasInitialized } = useValues(AppLogic);
+ const {
+ hasInitialized,
+ myRole: { canManageEngines },
+ } = useValues(AppLogic);
const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
useEffect(() => {
@@ -96,6 +101,11 @@ export const AppSearchConfigured: React.FC = (props) => {
+ {canManageEngines && (
+
+
+
+ )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts
index 962efbb7ece3a..dee8858fada8b 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts
@@ -17,7 +17,7 @@ export const CREDENTIALS_PATH = '/credentials';
export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 if the # isn't included
export const ENGINES_PATH = '/engines';
-export const CREATE_ENGINES_PATH = `${ENGINES_PATH}/new`;
+export const ENGINE_CREATION_PATH = '/engine_creation';
export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`;
export const SAMPLE_ENGINE_PATH = `${ENGINES_PATH}/national-parks-demo`;
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts
index abd26e18c7b9d..6fbc9f5bd2fc4 100644
--- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts
@@ -110,6 +110,47 @@ describe('engine routes', () => {
});
});
+ describe('POST /api/app_search/engines', () => {
+ let mockRouter: MockRouter;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockRouter = new MockRouter({
+ method: 'post',
+ path: '/api/app_search/engines',
+ });
+
+ registerEnginesRoutes({
+ ...mockDependencies,
+ router: mockRouter.router,
+ });
+ });
+
+ it('creates a request handler', () => {
+ mockRouter.callRoute({ body: { name: 'some-engine', language: 'en' } });
+ expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
+ path: '/as/engines/collection',
+ });
+ });
+
+ describe('validates', () => {
+ it('correctly', () => {
+ const request = { body: { name: 'some-engine', language: 'en' } };
+ mockRouter.shouldValidate(request);
+ });
+
+ it('missing name', () => {
+ const request = { body: { language: 'en' } };
+ mockRouter.shouldThrow(request);
+ });
+
+ it('optional language', () => {
+ const request = { body: { name: 'some-engine' } };
+ mockRouter.shouldValidate(request);
+ });
+ });
+ });
+
describe('GET /api/app_search/engines/{name}', () => {
let mockRouter: MockRouter;
diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts
index 0070680985a34..7d537e5dc0df3 100644
--- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts
+++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts
@@ -45,6 +45,21 @@ export function registerEnginesRoutes({
}
);
+ router.post(
+ {
+ path: '/api/app_search/engines',
+ validate: {
+ body: schema.object({
+ name: schema.string(),
+ language: schema.maybe(schema.string()),
+ }),
+ },
+ },
+ enterpriseSearchRequestHandler.createRequest({
+ path: '/as/engines/collection',
+ })
+ );
+
// Single engine endpoints
router.get(
{