): string =>
+ option.label;
+
+export const MetaEngineCreation: React.FC = () => {
+ const {
+ configuredLimits: {
+ engine: { maxEnginesPerMetaEngine } = { maxEnginesPerMetaEngine: Infinity },
+ },
+ } = useValues(AppLogic);
+
+ const {
+ fetchIndexedEngineNames,
+ setRawName,
+ setSelectedIndexedEngineNames,
+ submitEngine,
+ } = useActions(MetaEngineCreationLogic);
+
+ const { rawName, name, indexedEngineNames, selectedIndexedEngineNames } = useValues(
+ MetaEngineCreationLogic
+ );
+
+ useEffect(() => {
+ fetchIndexedEngineNames();
+ }, []);
+
+ return (
+
+
+
+
+
+ {META_ENGINE_CREATION_TITLE}
+
+ {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION}
+ {META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION}
+
+
+
+
+ {
+ e.preventDefault();
+ submitEngine();
+ }}
+ >
+
+ {META_ENGINE_CREATION_FORM_TITLE}
+
+
+
+
+ 0 && rawName !== name ? (
+ <>
+ {SANITIZED_NAME_NOTE} {name}
+ >
+ ) : (
+ ALLOWED_CHARS_NOTE
+ )
+ }
+ fullWidth
+ >
+ setRawName(event.currentTarget.value)}
+ fullWidth
+ data-test-subj="MetaEngineCreationNameInput"
+ placeholder={META_ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER}
+ autoFocus
+ />
+
+
+
+
+
+ {
+ setSelectedIndexedEngineNames(options.map(comboBoxOptionToEngineName));
+ }}
+ />
+
+
+ {selectedIndexedEngineNames.length > maxEnginesPerMetaEngine && (
+
+ )}
+
+ maxEnginesPerMetaEngine
+ }
+ type="submit"
+ data-test-subj="NewMetaEngineSubmitButton"
+ fill
+ color="secondary"
+ >
+ {META_ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL}
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts
new file mode 100644
index 0000000000000..6ffe7034584a1
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts
@@ -0,0 +1,175 @@
+/*
+ * 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,
+ mockFlashMessageHelpers,
+ mockKibanaValues,
+} from '../../../__mocks__';
+
+import { nextTick } from '@kbn/test/jest';
+
+import { MetaEngineCreationLogic } from './meta_engine_creation_logic';
+
+describe('MetaEngineCreationLogic', () => {
+ const { mount } = new LogicMounter(MetaEngineCreationLogic);
+ const { http } = mockHttpValues;
+ const { navigateToUrl } = mockKibanaValues;
+ const { setQueuedSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers;
+
+ const DEFAULT_VALUES = {
+ indexedEngineNames: [],
+ name: '',
+ rawName: '',
+ selectedIndexedEngineNames: [],
+ };
+
+ it('has expected default values', () => {
+ mount();
+ expect(MetaEngineCreationLogic.values).toEqual(DEFAULT_VALUES);
+ });
+
+ describe('actions', () => {
+ describe('setRawName', () => {
+ beforeAll(() => {
+ jest.clearAllMocks();
+ mount();
+ MetaEngineCreationLogic.actions.setRawName('Name__With#$&*%Special--Characters');
+ });
+
+ it('should set rawName to provided value', () => {
+ expect(MetaEngineCreationLogic.values.rawName).toEqual(
+ 'Name__With#$&*%Special--Characters'
+ );
+ });
+
+ it('should set name to a sanitized value', () => {
+ expect(MetaEngineCreationLogic.values.name).toEqual('name-with-special-characters');
+ });
+ });
+
+ describe('setIndexedEngineNames', () => {
+ it('should set indexedEngineNames to provided value', () => {
+ mount();
+ MetaEngineCreationLogic.actions.setIndexedEngineNames(['first', 'middle', 'last']);
+ expect(MetaEngineCreationLogic.values.indexedEngineNames).toEqual([
+ 'first',
+ 'middle',
+ 'last',
+ ]);
+ });
+ });
+
+ describe('setSelectedIndexedEngineNames', () => {
+ it('should set selectedIndexedEngineNames to provided value', () => {
+ mount();
+ MetaEngineCreationLogic.actions.setSelectedIndexedEngineNames(['one', 'two', 'three']);
+ expect(MetaEngineCreationLogic.values.selectedIndexedEngineNames).toEqual([
+ 'one',
+ 'two',
+ 'three',
+ ]);
+ });
+ });
+ });
+
+ describe('listeners', () => {
+ describe('fetchIndexedEngineNames', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('calls flashAPIErrors on API Error', async () => {
+ http.get.mockReturnValueOnce(Promise.reject());
+ MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
+ await nextTick();
+ expect(flashAPIErrors).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onEngineCreationSuccess on valid submission', async () => {
+ jest.spyOn(MetaEngineCreationLogic.actions, 'setIndexedEngineNames');
+ http.get.mockReturnValueOnce(
+ Promise.resolve({ results: [{ name: 'foo' }], meta: { page: { total_pages: 1 } } })
+ );
+ MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
+ await nextTick();
+ expect(MetaEngineCreationLogic.actions.setIndexedEngineNames).toHaveBeenCalledWith(['foo']);
+ });
+
+ it('if there are remaining pages it should call fetchIndexedEngineNames recursively with an incremented page', async () => {
+ jest.spyOn(MetaEngineCreationLogic.actions, 'fetchIndexedEngineNames');
+ http.get.mockReturnValueOnce(
+ Promise.resolve({ results: [{ name: 'foo' }], meta: { page: { total_pages: 2 } } })
+ );
+ MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
+ await nextTick();
+ expect(MetaEngineCreationLogic.actions.fetchIndexedEngineNames).toHaveBeenCalledWith(2);
+ });
+
+ it('if there are no remaining pages it should end without calling recursively', async () => {
+ jest.spyOn(MetaEngineCreationLogic.actions, 'fetchIndexedEngineNames');
+ http.get.mockReturnValueOnce(
+ Promise.resolve({ results: [{ name: 'foo' }], meta: { page: { total_pages: 1 } } })
+ );
+ MetaEngineCreationLogic.actions.fetchIndexedEngineNames();
+ await nextTick();
+ expect(MetaEngineCreationLogic.actions.fetchIndexedEngineNames).toHaveBeenCalledTimes(1); // it's one time cause we called it two lines above
+ });
+ });
+
+ describe('onEngineCreationSuccess', () => {
+ beforeAll(() => {
+ jest.clearAllMocks();
+ mount({ language: 'English', rawName: 'test' });
+ MetaEngineCreationLogic.actions.onEngineCreationSuccess();
+ });
+
+ it('should set a success message', () => {
+ expect(setQueuedSuccessMessage).toHaveBeenCalledWith('Successfully created meta engine.');
+ });
+
+ it('should navigate the user to the engine page', () => {
+ expect(navigateToUrl).toHaveBeenCalledWith('/engines/test');
+ });
+ });
+
+ describe('submitEngine', () => {
+ beforeAll(() => {
+ jest.clearAllMocks();
+ mount({ rawName: 'test', selectedIndexedEngineNames: ['foo'] });
+ });
+
+ it('POSTS to /api/app_search/engines', () => {
+ const body = JSON.stringify({
+ name: 'test',
+ type: 'meta',
+ source_engines: ['foo'],
+ });
+ MetaEngineCreationLogic.actions.submitEngine();
+ expect(http.post).toHaveBeenCalledWith('/api/app_search/engines', { body });
+ });
+
+ it('calls onEngineCreationSuccess on valid submission', async () => {
+ jest.spyOn(MetaEngineCreationLogic.actions, 'onEngineCreationSuccess');
+ http.post.mockReturnValueOnce(Promise.resolve({}));
+ MetaEngineCreationLogic.actions.submitEngine();
+ await nextTick();
+ expect(MetaEngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls flashAPIErrors on API Error', async () => {
+ jest.spyOn(MetaEngineCreationLogic.actions, 'setIndexedEngineNames');
+ http.post.mockReturnValueOnce(Promise.reject());
+ MetaEngineCreationLogic.actions.submitEngine();
+ await nextTick();
+ expect(flashAPIErrors).toHaveBeenCalledTimes(1);
+ expect(MetaEngineCreationLogic.actions.setIndexedEngineNames).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts
new file mode 100644
index 0000000000000..d94eb82a49c0e
--- /dev/null
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts
@@ -0,0 +1,127 @@
+/*
+ * 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 { Meta } from '../../../../../common/types';
+import { DEFAULT_META } from '../../../shared/constants';
+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 { EngineDetails } from '../engine/types';
+
+import { META_ENGINE_CREATION_SUCCESS_MESSAGE } from './constants';
+
+interface MetaEngineCreationValues {
+ indexedEngineNames: string[];
+ name: string;
+ rawName: string;
+ selectedIndexedEngineNames: string[];
+}
+
+interface MetaEngineCreationActions {
+ fetchIndexedEngineNames(page?: number): { page: number };
+ onEngineCreationSuccess(): void;
+ setIndexedEngineNames(
+ indexedEngineNames: MetaEngineCreationValues['indexedEngineNames']
+ ): { indexedEngineNames: MetaEngineCreationValues['indexedEngineNames'] };
+ setRawName(rawName: string): { rawName: string };
+ setSelectedIndexedEngineNames(
+ selectedIndexedEngineNames: MetaEngineCreationValues['selectedIndexedEngineNames']
+ ): { selectedIndexedEngineNames: MetaEngineCreationValues['selectedIndexedEngineNames'] };
+ submitEngine(): void;
+}
+
+export const MetaEngineCreationLogic = kea<
+ MakeLogicType
+>({
+ path: ['enterprise_search', 'app_search', 'meta_engine_creation_logic'],
+ actions: {
+ fetchIndexedEngineNames: (page = DEFAULT_META.page.current) => ({ page }),
+ onEngineCreationSuccess: true,
+ setIndexedEngineNames: (indexedEngineNames) => ({ indexedEngineNames }),
+ setRawName: (rawName) => ({ rawName }),
+ setSelectedIndexedEngineNames: (selectedIndexedEngineNames) => ({ selectedIndexedEngineNames }),
+ submitEngine: () => null,
+ },
+ reducers: {
+ indexedEngineNames: [
+ [],
+ {
+ setIndexedEngineNames: (_, { indexedEngineNames }) => indexedEngineNames,
+ },
+ ],
+ rawName: [
+ '',
+ {
+ setRawName: (_, { rawName }) => rawName,
+ },
+ ],
+ selectedIndexedEngineNames: [
+ [],
+ {
+ setSelectedIndexedEngineNames: (_, { selectedIndexedEngineNames }) =>
+ selectedIndexedEngineNames,
+ },
+ ],
+ },
+ selectors: ({ selectors }) => ({
+ name: [() => [selectors.rawName], (rawName: string) => formatApiName(rawName)],
+ }),
+ listeners: ({ values, actions }) => ({
+ fetchIndexedEngineNames: async ({ page }) => {
+ const { http } = HttpLogic.values;
+ let response: { results: EngineDetails[]; meta: Meta } | undefined;
+
+ try {
+ response = await http.get('/api/app_search/engines', {
+ query: { type: 'indexed', 'page[current]': page, 'page[size]': DEFAULT_META.page.size },
+ });
+ } catch (e) {
+ flashAPIErrors(e);
+ }
+
+ if (response) {
+ const engineNames = response.results.map((result) => result.name);
+ actions.setIndexedEngineNames([...values.indexedEngineNames, ...engineNames]);
+
+ if (page < response.meta.page.total_pages) {
+ actions.fetchIndexedEngineNames(page + 1);
+ }
+ }
+ },
+ onEngineCreationSuccess: () => {
+ const { name } = values;
+ const { navigateToUrl } = KibanaLogic.values;
+ const enginePath = generatePath(ENGINE_PATH, { engineName: name });
+
+ setQueuedSuccessMessage(META_ENGINE_CREATION_SUCCESS_MESSAGE);
+ navigateToUrl(enginePath);
+ },
+ submitEngine: async () => {
+ const { http } = HttpLogic.values;
+ const { name, selectedIndexedEngineNames } = values;
+
+ const body = JSON.stringify({
+ name,
+ type: 'meta',
+ source_engines: selectedIndexedEngineNames,
+ });
+
+ try {
+ await http.post('/api/app_search/engines', { body });
+ actions.onEngineCreationSuccess();
+ } catch (e) {
+ flashAPIErrors(e);
+ }
+ },
+ }),
+});
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
index 5150a4996eb84..a1c845a10a47c 100644
--- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
+++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx
@@ -21,6 +21,7 @@ import { EngineRouter } from './components/engine';
import { EngineCreation } from './components/engine_creation';
import { EnginesOverview } from './components/engines';
import { ErrorConnecting } from './components/error_connecting';
+import { MetaEngineCreation } from './components/meta_engine_creation';
import { SetupGuide } from './components/setup_guide';
import { AppSearch, AppSearchUnconfigured, AppSearchConfigured, AppSearchNav } from './';
@@ -117,6 +118,22 @@ describe('AppSearchConfigured', () => {
expect(wrapper.find(EngineCreation)).toHaveLength(0);
});
});
+
+ describe('canManageMetaEngines', () => {
+ it('renders MetaEngineCreation when user canManageMetaEngines is true', () => {
+ setMockValues({ myRole: { canManageMetaEngines: true } });
+ const wrapper = shallow();
+
+ expect(wrapper.find(MetaEngineCreation)).toHaveLength(1);
+ });
+
+ it('does not render MetaEngineCreation when user canManageMetaEngines is false', () => {
+ setMockValues({ myRole: { canManageMetaEngines: false } });
+ const wrapper = shallow();
+
+ expect(wrapper.find(MetaEngineCreation)).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 40dfc1426e402..ec64cb2f10eb0 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
@@ -25,6 +25,7 @@ import { EngineCreation } from './components/engine_creation';
import { EnginesOverview, ENGINES_TITLE } from './components/engines';
import { ErrorConnecting } from './components/error_connecting';
import { Library } from './components/library';
+import { MetaEngineCreation } from './components/meta_engine_creation';
import { ROLE_MAPPINGS_TITLE } from './components/role_mappings';
import { Settings, SETTINGS_TITLE } from './components/settings';
import { SetupGuide } from './components/setup_guide';
@@ -38,6 +39,7 @@ import {
ENGINES_PATH,
ENGINE_PATH,
LIBRARY_PATH,
+ META_ENGINE_CREATION_PATH,
} from './routes';
export const AppSearch: React.FC = (props) => {
@@ -60,7 +62,7 @@ export const AppSearchConfigured: React.FC = (props) => {
const { initializeAppData } = useActions(AppLogic);
const {
hasInitialized,
- myRole: { canManageEngines },
+ myRole: { canManageEngines, canManageMetaEngines },
} = useValues(AppLogic);
const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
@@ -106,6 +108,11 @@ export const AppSearchConfigured: React.FC = (props) => {
)}
+ {canManageMetaEngines && (
+
+
+
+ )}
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 6fe9be083405e..907a27c8660d2 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
@@ -40,6 +40,7 @@ export const ENGINE_REINDEX_JOB_PATH = `${ENGINE_PATH}/reindex-job/:activeReinde
export const ENGINE_CRAWLER_PATH = `${ENGINE_PATH}/crawler`;
// TODO: Crawler sub-pages
+export const META_ENGINE_CREATION_PATH = '/meta_engine_creation';
export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`;
export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`;
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 779c51131b472..6b78f29a6b731 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
@@ -122,19 +122,56 @@ describe('engine routes', () => {
});
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('indexed engines', () => {
+ 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('meta engines', () => {
+ it('all properties', () => {
+ const request = {
+ body: { name: 'some-meta-engine', type: 'any', language: 'en', source_engines: [] },
+ };
+ mockRouter.shouldValidate(request);
+ });
+
+ it('missing name', () => {
+ const request = {
+ body: { type: 'any', language: 'en', source_engines: [] },
+ };
+ mockRouter.shouldThrow(request);
+ });
+
+ it('optional language', () => {
+ const request = {
+ body: { name: 'some-meta-engine', type: 'any', source_engines: [] },
+ };
+ mockRouter.shouldValidate(request);
+ });
+
+ it('optional source_engines', () => {
+ const request = {
+ body: { name: 'some-meta-engine', type: 'any', language: 'en' },
+ };
+ mockRouter.shouldValidate(request);
+ });
+
+ it('optional type', () => {
+ const request = { body: { name: 'some-engine' } };
+ mockRouter.shouldValidate(request);
+ });
});
});
});
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 9bff6cf127dd3..766be196e70e7 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,8 @@ export function registerEnginesRoutes({
body: schema.object({
name: schema.string(),
language: schema.maybe(schema.string()),
+ source_engines: schema.maybe(schema.arrayOf(schema.string())),
+ type: schema.maybe(schema.string()),
}),
},
},