diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/constants.ts
new file mode 100644
index 0000000000000..5ac778c83bd0d
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/constants.ts
@@ -0,0 +1,136 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+
+export const CREATE_ENGINE_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.title',
+ {
+ defaultMessage: 'Create an Engine',
+ }
+);
+
+export const CREATE_ENGINE_FORM_TITLE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.form.title',
+ {
+ defaultMessage: 'Name your Engine',
+ }
+);
+
+export const CREATE_ENGINE_FORM_ENGINE_NAME_LABEL = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.form.engineName.label',
+ {
+ defaultMessage: 'Engine Name',
+ }
+);
+
+export const ALLOWED_CHARS_NOTE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.form.engineName.allowedCharactersNote',
+ {
+ defaultMessage: 'Engine names can only contain lowercase letters, numbers, and hyphens',
+ }
+);
+
+export const SANITIZED_NAME_NOTE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.form.engineName.sanitizedNameNote',
+ {
+ defaultMessage: 'Your engine will be named',
+ }
+);
+
+export const CREATE_ENGINE_FORM_ENGINE_NAME_PLACEHOLDER = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.form.engineName.placeholder',
+ {
+ defaultMessage: 'i.e., my-search-engine',
+ }
+);
+
+export const CREATE_ENGINE_FORM_ENGINE_LANGUAGE_LABEL = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.form.engineLanguage.label',
+ {
+ defaultMessage: 'Engine Language',
+ }
+);
+
+export const CREATE_ENGINE_FORM_SUBMIT_BUTTON_LABEL = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.form.submitButton.label',
+ {
+ defaultMessage: 'Create Engine',
+ }
+);
+
+export const CREATE_ENGINE_SUCCESS_MESSAGE = i18n.translate(
+ 'xpack.enterpriseSearch.appSearch.createEngine.successMessage',
+ {
+ defaultMessage: 'Successfully created engine.',
+ }
+);
+
+export const SUPPORTED_LANGUAGES = [
+ {
+ value: 'Universal',
+ text: 'Universal',
+ },
+ {
+ text: '—',
+ disabled: true,
+ },
+ {
+ value: 'zh',
+ text: 'Chinese',
+ },
+ {
+ value: 'da',
+ text: 'Danish',
+ },
+ {
+ value: 'nl',
+ text: 'Dutch',
+ },
+ {
+ value: 'en',
+ text: 'English',
+ },
+ {
+ value: 'fr',
+ text: 'French',
+ },
+ {
+ value: 'de',
+ text: 'German',
+ },
+ {
+ value: 'it',
+ text: 'Italian',
+ },
+ {
+ value: 'ja',
+ text: 'Japanese',
+ },
+ {
+ value: 'ko',
+ text: 'Korean',
+ },
+ {
+ value: 'pt',
+ text: 'Portuguese',
+ },
+ {
+ value: 'pt-br',
+ text: 'Portuguese (Brazil)',
+ },
+ {
+ value: 'ru',
+ text: 'Russian',
+ },
+ {
+ value: 'es',
+ text: 'Spanish',
+ },
+ {
+ value: 'th',
+ text: 'Thai',
+ },
+];
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine.test.tsx
new file mode 100644
index 0000000000000..11cbedd632234
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine.test.tsx
@@ -0,0 +1,76 @@
+/*
+ * 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 { shallow } from 'enzyme';
+
+import '../../../__mocks__/shallow_useeffect.mock';
+import { setMockValues } from '../../../__mocks__';
+
+import { CreateEngine } from './';
+import { DefaultStringBooleanFalse } from '../../../../../../lists/common/schemas/types/default_string_boolean_false';
+
+describe('CreateEngine', () => {
+ const DEFAULT_VALUES = {
+ name: '',
+ rawName: '',
+ language: 'Universal',
+ };
+
+ describe('default values', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders', () => {
+ setMockValues(DEFAULT_VALUES);
+ const wrapper = shallow();
+ expect(wrapper.find('[data-test-subj="CreateEngine"]')).toHaveLength(1);
+ });
+
+ it('contains a form', () => {
+ setMockValues(DEFAULT_VALUES);
+ const wrapper = shallow();
+ expect(wrapper.find('[data-test-subj="CreateEngineForm"]')).toHaveLength(1);
+ });
+
+ it('contains a name input', () => {
+ setMockValues(DEFAULT_VALUES);
+ const wrapper = shallow();
+ expect(wrapper.find('[data-test-subj="CreateEngineNameInput"]')).toHaveLength(1);
+ });
+
+ it('contains a language input', () => {
+ setMockValues(DEFAULT_VALUES);
+ const wrapper = shallow();
+ expect(wrapper.find('[data-test-subj="CreateEngineLanguageInput"]')).toHaveLength(1);
+ });
+
+ describe('NewEngineSubmitButton', () => {
+ it('renders', () => {
+ setMockValues(DEFAULT_VALUES);
+ const wrapper = shallow();
+ expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]')).toHaveLength(1);
+ });
+
+ it('is disabled when name is empty', () => {
+ setMockValues(DEFAULT_VALUES);
+ 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
+ );
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine.tsx
new file mode 100644
index 0000000000000..a5addc9687ccb
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine.tsx
@@ -0,0 +1,123 @@
+/*
+ * 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 {
+ EuiForm,
+ EuiFlexGroup,
+ EuiFormRow,
+ EuiFlexItem,
+ EuiFieldText,
+ EuiSelect,
+ EuiPageHeader,
+ EuiPageHeaderSection,
+ EuiSpacer,
+ EuiText,
+ EuiTitle,
+ EuiButton,
+ EuiPanel,
+} from '@elastic/eui';
+import { useActions, useValues } from 'kea';
+
+import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
+import { FlashMessages } from '../../../shared/flash_messages';
+import {
+ ALLOWED_CHARS_NOTE,
+ CREATE_ENGINE_FORM_ENGINE_LANGUAGE_LABEL,
+ CREATE_ENGINE_FORM_ENGINE_NAME_LABEL,
+ CREATE_ENGINE_FORM_ENGINE_NAME_PLACEHOLDER,
+ CREATE_ENGINE_FORM_SUBMIT_BUTTON_LABEL,
+ CREATE_ENGINE_FORM_TITLE,
+ CREATE_ENGINE_TITLE,
+ SANITIZED_NAME_NOTE,
+ SUPPORTED_LANGUAGES,
+} from './constants';
+import { CreateEngineLogic } from './create_engine_logic';
+
+export const CreateEngine: React.FC = () => {
+ const { name, rawName, language } = useValues(CreateEngineLogic);
+ const { setLanguage, setRawName, submitEngine } = useActions(CreateEngineLogic);
+
+ return (
+
+
+
+
+
+ {CREATE_ENGINE_TITLE}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine_logic.test.ts
new file mode 100644
index 0000000000000..e135b7912e3db
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine_logic.test.ts
@@ -0,0 +1,135 @@
+/*
+ * 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 { generatePath } from 'react-router-dom';
+
+import { CREATE_ENGINE_SUCCESS_MESSAGE } from './constants';
+import {
+ LogicMounter,
+ mockHttpValues,
+ mockKibanaValues,
+ mockFlashMessageHelpers,
+} from '../../../__mocks__';
+import { ENGINE_PATH } from '../../routes';
+import { formatApiName } from '../../utils/format_api_name';
+import { CreateEngineLogic, DEFAULT_LANGUAGE } from './create_engine_logic';
+
+describe('CreateEngineLogic', () => {
+ const { mount } = new LogicMounter(CreateEngineLogic);
+ const { http } = mockHttpValues;
+ const { navigateToUrl } = mockKibanaValues;
+ const { setQueuedSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers;
+
+ const DEFAULT_VALUES = {
+ name: '',
+ rawName: '',
+ language: DEFAULT_LANGUAGE,
+ };
+
+ it('has expected default values', () => {
+ mount();
+ expect(CreateEngineLogic.values).toEqual(DEFAULT_VALUES);
+ });
+
+ describe('actions', () => {
+ describe('onCreateEngineSuccess', () => {
+ beforeAll(() => {
+ mount();
+ // setup local state
+ CreateEngineLogic.actions.setLanguage('English');
+ CreateEngineLogic.actions.setRawName('test');
+ // call action
+ CreateEngineLogic.actions.onCreateEngineSuccess();
+ });
+
+ it('should set a success message', () => {
+ expect(setQueuedSuccessMessage).toHaveBeenCalledWith(CREATE_ENGINE_SUCCESS_MESSAGE);
+ });
+
+ it('should navigate the user to the engine page', () => {
+ const enginePath = generatePath(ENGINE_PATH, { engineName: CreateEngineLogic.values.name });
+ expect(navigateToUrl).toHaveBeenCalledWith(enginePath);
+ });
+ });
+
+ describe('setLanguage', () => {
+ const newLanguage = 'English';
+
+ beforeAll(() => {
+ mount();
+ CreateEngineLogic.actions.setLanguage(newLanguage);
+ });
+
+ describe('language', () => {
+ it('should be set to provided value', () => {
+ expect(CreateEngineLogic.values.language).toEqual(newLanguage);
+ });
+ });
+ });
+
+ describe('setRawName', () => {
+ const newName = 'Name__With#$&*%Special--Characters';
+ const sanitizedNewName = formatApiName(newName);
+
+ beforeAll(() => {
+ mount();
+ CreateEngineLogic.actions.setRawName(newName);
+ });
+
+ describe('rawName', () => {
+ it('should be set to provided value', () => {
+ expect(CreateEngineLogic.values.rawName).toEqual(newName);
+ });
+ });
+
+ describe('name', () => {
+ it('should be set to a sanitized value', () => {
+ expect(CreateEngineLogic.values.name).toEqual(sanitizedNewName);
+ });
+ });
+ });
+
+ describe('submitEngine', () => {
+ beforeAll(() => {
+ mount();
+ // setup local state
+ CreateEngineLogic.actions.setLanguage('English');
+ CreateEngineLogic.actions.setRawName('test');
+ });
+
+ it('POSTS to /api/app_search/engines', () => {
+ const body = JSON.stringify({
+ name: CreateEngineLogic.values.name,
+ language: CreateEngineLogic.values.language,
+ });
+ CreateEngineLogic.actions.submitEngine();
+ expect(http.post).toHaveBeenCalledWith('/api/app_search/engines', { body });
+ });
+
+ it('calls onCreateEngineSuccess on valid submission', async () => {
+ jest.spyOn(CreateEngineLogic.actions, 'onCreateEngineSuccess'); // .mockImplementation();
+ const promise = (http.post as jest.Mock).mockReturnValueOnce({});
+ await CreateEngineLogic.actions.submitEngine();
+ await promise;
+ expect(CreateEngineLogic.actions.onCreateEngineSuccess).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls flashAPIErrors on API Error', async () => {
+ const promise = (http.post as jest.Mock).mockReturnValueOnce(
+ Promise.reject({
+ body: {
+ statusCode: 400,
+ error: 'Bad Request',
+ message: 'Invalid request payload JSON format',
+ },
+ })
+ );
+ await CreateEngineLogic.actions.submitEngine();
+ await promise;
+ expect(flashAPIErrors).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine_logic.ts
new file mode 100644
index 0000000000000..acdc0f2f8476c
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/create_engine_logic.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { kea, MakeLogicType } from 'kea';
+import { generatePath } from 'react-router-dom';
+
+import { CREATE_ENGINE_SUCCESS_MESSAGE } from './constants';
+import { ENGINE_PATH } from '../../routes';
+import { formatApiName } from '../../utils/format_api_name';
+import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages';
+import { HttpLogic } from '../../../shared/http';
+import { KibanaLogic } from '../../../shared/kibana';
+
+export const DEFAULT_LANGUAGE = 'Universal';
+
+export interface CreateEngineActions {
+ onCreateEngineSuccess(): void;
+ setLanguage(language: string): { language: string };
+ setRawName(rawName: string): { rawName: string };
+ submitEngine(): void;
+}
+
+export interface CreateEngineValues {
+ language: string;
+ name: string;
+ rawName: string;
+}
+
+export const CreateEngineLogic = kea>({
+ path: ['enterprise_search', 'app_search', 'create_engine_logic'],
+ actions: {
+ onCreateEngineSuccess: 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.onCreateEngineSuccess();
+ } catch (e) {
+ flashAPIErrors(e);
+ }
+ },
+ onCreateEngineSuccess: () => {
+ const { name } = values;
+ const { navigateToUrl } = KibanaLogic.values;
+ const enginePath = generatePath(ENGINE_PATH, { engineName: name });
+
+ setQueuedSuccessMessage(CREATE_ENGINE_SUCCESS_MESSAGE);
+ navigateToUrl(enginePath);
+ },
+ }),
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/index.ts
new file mode 100644
index 0000000000000..44fee51db0ba7
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/create_engine/index.ts
@@ -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 { CreateEngine } from './create_engine';
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx
index 1b6acf341c08e..4705b62e676b4 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx
@@ -9,7 +9,7 @@ import { shallow, ShallowWrapper } from 'enzyme';
import { EuiButton } from '@elastic/eui';
import { docLinks } from '../../../shared/doc_links';
-
+import { FlashMessages } from '../../../shared/flash_messages';
import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation';
import { EmptyEngineOverview } from './engine_overview_empty';
@@ -26,6 +26,10 @@ describe('EmptyEngineOverview', () => {
it('renders a documentation link', () => {
expect(wrapper.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`);
+
+ it('contains FlashMessages', () => {
+ expect(wrapper.find(FlashMessages)).toHaveLength(1);
+ });
});
it('renders document creation components', () => {
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 83dd396e5e080..16a35ead9cbf3 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
@@ -15,6 +15,7 @@ import {
EuiButton,
} from '@elastic/eui';
+import { FlashMessages } from '../../../shared/flash_messages';
import { DOCS_PREFIX } from '../../routes';
import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation';
@@ -40,6 +41,7 @@ export const EmptyEngineOverview: React.FC = () => {
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx
index 8250446e231b3..e8590c51f9886 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx
@@ -9,6 +9,7 @@ import { setMockValues } from '../../../__mocks__/kea.mock';
import React from 'react';
import { shallow } from 'enzyme';
+import { FlashMessages } from '../../../shared/flash_messages';
import { UnavailablePrompt, TotalStats, TotalCharts, RecentApiLogs } from './components';
import { EngineOverviewMetrics } from './engine_overview_metrics';
@@ -18,6 +19,11 @@ describe('EngineOverviewMetrics', () => {
expect(wrapper.find('h1').text()).toEqual('Engine overview');
});
+ it('contains FlashMessages', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(FlashMessages)).toHaveLength(1);
+ });
+
it('renders an unavailable prompt if engine data is still indexing', () => {
setMockValues({ apiLogsUnavailable: true });
const wrapper = shallow();
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 9630f6fa2f81d..99a044ee24f26 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
@@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { EuiPageHeader, EuiTitle, EuiSpacer } from '@elastic/eui';
import { EngineOverviewLogic } from './';
-
+import { FlashMessages } from '../../../shared/flash_messages';
import { UnavailablePrompt, TotalStats, TotalCharts, RecentApiLogs } from './components';
export const EngineOverviewMetrics: React.FC = () => {
@@ -28,6 +28,7 @@ export const EngineOverviewMetrics: React.FC = () => {
+
{apiLogsUnavailable ? (
) : (
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 12545c59b40a0..fe86e3f981a01 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
@@ -14,3 +14,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.label',
+ {
+ 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 e2d366e5e5817..ffabe5f435d1b 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
@@ -72,6 +72,7 @@ describe('EnginesOverview', () => {
const wrapper = shallow();
expect(wrapper.find(EnginesTable)).toHaveLength(1);
+ expect(wrapper.find('[data-test-subj="appSearchEnginesCreateEngineButton"]')).toHaveLength(1);
expect(actions.loadEngines).toHaveBeenCalled();
});
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 8a24ee746ed14..c6a3f2c75da5e 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
@@ -7,8 +7,10 @@
import React, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import {
+ EuiButton,
EuiPageContent,
EuiPageContentHeader,
+ EuiPageContentHeaderSection,
EuiPageContentBody,
EuiTitle,
EuiSpacer,
@@ -18,10 +20,12 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chro
import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry';
import { FlashMessages } from '../../../shared/flash_messages';
import { LicensingLogic } from '../../../shared/licensing';
+import { CREATE_ENGINES_PATH } from '../../routes';
+import { KibanaLogic } from '../../../shared/kibana';
import { EngineIcon } from './assets/engine_icon';
import { MetaEngineIcon } from './assets/meta_engine_icon';
-import { ENGINES_TITLE, META_ENGINES_TITLE } from './constants';
+import { CREATE_AN_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE } from './constants';
import { EnginesOverviewHeader, LoadingState, EmptyState } from './components';
import { EnginesTable } from './engines_table';
import { EnginesLogic } from './engines_logic';
@@ -63,11 +67,23 @@ export const EnginesOverview: React.FC = () => {
-
-
- {ENGINES_TITLE}
-
-
+
+
+
+ {ENGINES_TITLE}
+
+
+
+
+ KibanaLogic.values.navigateToUrl(CREATE_ENGINES_PATH)}
+ >
+ {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 CreateEngine when user canManageEngines is true', () => {
+ setMockValues({ myRole: { canManageEngines: true } });
+ const wrapper = shallow();
+
+ expect(wrapper.find(CreateEngine)).toHaveLength(1);
+ });
+
+ it('does not render CreateEngine when user canManageEngines is false', () => {
+ setMockValues({ myRole: { canManageEngines: false } });
+ const wrapper = shallow();
+
+ expect(wrapper.find(CreateEngine)).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 769230ccffd22..2e9cf552f2fce 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
@@ -19,6 +19,7 @@ import { Layout, SideNav, SideNavLink } from '../shared/layout';
import { EngineNav, EngineRouter } from './components/engine';
import {
+ CREATE_ENGINES_PATH,
ROOT_PATH,
SETUP_GUIDE_PATH,
SETTINGS_PATH,
@@ -29,6 +30,7 @@ import {
LIBRARY_PATH,
} from './routes';
+import { CreateEngine } from './components/create_engine';
import { SetupGuide } from './components/setup_guide';
import { ErrorConnecting } from './components/error_connecting';
import { NotFound } from '../shared/not_found';
@@ -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(() => {
@@ -73,6 +78,13 @@ export const AppSearchConfigured: React.FC = (props) => {
)}
+ {canManageEngines && (
+
+ } readOnlyMode={readOnlyMode}>
+
+
+
+ )}
} />} readOnlyMode={readOnlyMode}>
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 7f12f7d29671a..f72b179a27c7e 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
@@ -16,7 +16,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 CREATE_ENGINES_PATH = `/create_engine`;
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 9755fff02f738..6eccbad67638b 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,48 @@ 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',
+ payload: 'body',
+ });
+
+ 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 c0bbc40ff8d2d..30af2f130eaa0 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
@@ -44,6 +44,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(
{