From bdb9052dc50f2b746e8b90c8d57bd905eab428f7 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 2 Mar 2021 13:42:07 -0500 Subject: [PATCH] [App Search] Migrate Create Meta Engine View (#92127) (#93256) * New empty MetaEngineCreation component * Added MetaEngineCreation to AppSearchConfigured router * Empty MetaEngineCreationLogic * Add rawName value and setRawName action to MetaEngineCreationLogic * Add indexedEngineNames value and setIndexedEngineNames action to MEtaEngineCreationLogic * Add selectedIndexedEngineNames value and setIndexedEngineNames action to MetaEngineCreationLogic * Add description to MetaEngineCreation * Add name selector to MetaEngineCreationLogic * Added MetaEngineCreationNameInput to MetaEngineCreation * Add fetchIndexedEngineNames listener to MetaEngineCreationLogic * Call fetchIndexedEngineNames when MetaEngineCreation first renders * Add EuiComboBox for selectedEngineNames to MetaEngineCreation * WIP Add meta engine source engine limit warning to MetaEngineCreation * Add submitEngine listener to MetaEngineCreationLogic * Add onEngineCreationSuccess to MetaEngineCreationLogic * Fixing tests for MetaEngineCreationLogic * Fix tests for MetaEngineCreation * Add Create a meta engine button to EnginesOverview * Use DEFAULT_META for fetching indexed engine names in MetaEngineCreationLogic * Copy fixes * Updating POST /api/engines tests * Add noItemsMessage prop to EnginesTable * Add empty prompt to Meta Engines table in EnginesOverview * Apply suggestions from code review Co-authored-by: Constance * Better form functionality in MetaEngineCreation * Fix errors from github * More MetaEngineCreation coverage * Meta MetaEngineCreationLogic coverage * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts Co-authored-by: Constance * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts Co-authored-by: Constance * Update x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts Co-authored-by: Constance Co-authored-by: Constance Co-authored-by: Byron Hulcher Co-authored-by: Constance --- .../components/engines/constants.ts | 22 +++ .../engines/engines_overview.test.tsx | 46 ++++- .../components/engines/engines_overview.tsx | 53 ++++- .../components/engines/engines_table.test.tsx | 7 + .../components/engines/engines_table.tsx | 5 +- .../meta_engine_creation/constants.tsx | 119 +++++++++++ .../components/meta_engine_creation/index.ts | 8 + .../meta_engine_creation.test.tsx | 187 ++++++++++++++++++ .../meta_engine_creation.tsx | 170 ++++++++++++++++ .../meta_engine_creation_logic.test.ts | 175 ++++++++++++++++ .../meta_engine_creation_logic.ts | 127 ++++++++++++ .../applications/app_search/index.test.tsx | 17 ++ .../public/applications/app_search/index.tsx | 9 +- .../public/applications/app_search/routes.ts | 1 + .../server/routes/app_search/engines.test.ts | 63 ++++-- .../server/routes/app_search/engines.ts | 2 + 16 files changed, 981 insertions(+), 30 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation_logic.ts 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 401d4ccd6d117..e0e36afa8e0c4 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 @@ -22,3 +22,25 @@ export const CREATE_AN_ENGINE_BUTTON_LABEL = i18n.translate( defaultMessage: 'Create an engine', } ); + +export const CREATE_A_META_ENGINE_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engines.createAMetaEngineButton.ButtonLabel', + { + defaultMessage: 'Create a meta engine', + } +); + +export const META_ENGINE_EMPTY_PROMPT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engines.metaEngines.emptyPrompTitle', + { + defaultMessage: 'No meta engines yet', + } +); + +export const META_ENGINE_EMPTY_PROMPT_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engines.metaEngines.emptyPromptDescription', + { + defaultMessage: + 'Meta engines allow you to combine multiple engines into one searchable 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 c25f27c81ff48..5a3f730940760 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 @@ -12,6 +12,8 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; +import { EuiEmptyPrompt } from '@elastic/eui'; + import { LoadingState, EmptyState } from './components'; import { EnginesTable } from './engines_table'; @@ -19,7 +21,6 @@ import { EnginesOverview } from './'; describe('EnginesOverview', () => { const values = { - hasPlatinumLicense: false, dataLoading: false, engines: [], enginesMeta: { @@ -39,6 +40,7 @@ describe('EnginesOverview', () => { }, }, metaEnginesLoading: false, + hasPlatinumLicense: false, }; const actions = { loadEngines: jest.fn(), @@ -73,7 +75,7 @@ describe('EnginesOverview', () => { const valuesWithEngines = { ...values, dataLoading: false, - engines: ['dummy-engine'], + engines: ['test-engine'], enginesMeta: { page: { current: 1, @@ -84,6 +86,7 @@ describe('EnginesOverview', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockValues(valuesWithEngines); }); @@ -102,18 +105,47 @@ describe('EnginesOverview', () => { ).toEqual('/engine_creation'); }); - describe('when on a platinum license', () => { - it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { + describe('when user has a platinum license', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { setMockValues({ ...valuesWithEngines, hasPlatinumLicense: true, - metaEngines: ['dummy-meta-engine'], }); - const wrapper = shallow(); + wrapper = shallow(); + }); + it('renders a 2nd meta engines table ', async () => { expect(wrapper.find(EnginesTable)).toHaveLength(2); + }); + + it('makes a 2nd meta engines call', () => { expect(actions.loadMetaEngines).toHaveBeenCalled(); }); + + it('renders a create engine button which takes users to the create meta engine page', () => { + expect( + wrapper.find('[data-test-subj="appSearchEnginesMetaEngineCreationButton"]').prop('to') + ).toEqual('/meta_engine_creation'); + }); + + it('contains an EuiEmptyPrompt that takes users to the create meta when metaEngines is empty', () => { + setMockValues({ + ...valuesWithEngines, + hasPlatinumLicense: true, + metaEngines: [], + }); + wrapper = shallow(); + const metaEnginesTable = wrapper.find(EnginesTable).last().dive(); + const emptyPrompt = metaEnginesTable.dive().find(EuiEmptyPrompt).dive(); + + expect( + emptyPrompt + .find('[data-test-subj="appSearchMetaEnginesEmptyStateCreationButton"]') + .prop('to') + ).toEqual('/meta_engine_creation'); + }); }); describe('pagination', () => { @@ -150,7 +182,7 @@ describe('EnginesOverview', () => { setMockValues({ ...valuesWithEngines, hasPlatinumLicense: true, - metaEngines: ['dummy-meta-engine'], + metaEngines: ['test-meta-engine'], }); const wrapper = shallow(); const pageEvent = { page: { index: 0 } }; 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 a26fe87365536..4cfa5d9078162 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 @@ -16,6 +16,7 @@ import { EuiPageContentBody, EuiTitle, EuiSpacer, + EuiEmptyPrompt, } from '@elastic/eui'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -24,12 +25,19 @@ import { LicensingLogic } from '../../../shared/licensing'; import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { convertMetaToPagination, handlePageChange } from '../../../shared/table_pagination'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { ENGINE_CREATION_PATH } from '../../routes'; +import { ENGINE_CREATION_PATH, META_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 { CREATE_AN_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE } from './constants'; +import { + CREATE_AN_ENGINE_BUTTON_LABEL, + CREATE_A_META_ENGINE_BUTTON_LABEL, + ENGINES_TITLE, + META_ENGINE_EMPTY_PROMPT_DESCRIPTION, + META_ENGINE_EMPTY_PROMPT_TITLE, + META_ENGINES_TITLE, +} from './constants'; import { EnginesLogic } from './engines_logic'; import { EnginesTable } from './engines_table'; @@ -37,6 +45,7 @@ import './engines_overview.scss'; export const EnginesOverview: React.FC = () => { const { hasPlatinumLicense } = useValues(LicensingLogic); + const { dataLoading, engines, @@ -46,6 +55,7 @@ export const EnginesOverview: React.FC = () => { metaEnginesMeta, metaEnginesLoading, } = useValues(EnginesLogic); + const { loadEngines, loadMetaEngines, onEnginesPagination, onMetaEnginesPagination } = useActions( EnginesLogic ); @@ -100,15 +110,27 @@ export const EnginesOverview: React.FC = () => { /> - {metaEngines.length > 0 && ( + {hasPlatinumLicense && ( <> - -

- {META_ENGINES_TITLE} -

-
+ + +

+ {META_ENGINES_TITLE} +

+
+
+ + + {CREATE_A_META_ENGINE_BUTTON_LABEL} + +
{ ...convertMetaToPagination(metaEnginesMeta), hidePerPageOptions: true, }} + noItemsMessage={ + {META_ENGINE_EMPTY_PROMPT_TITLE}} + body={

{META_ENGINE_EMPTY_PROMPT_DESCRIPTION}

} + actions={ + + {CREATE_A_META_ENGINE_BUTTON_LABEL} + + } + /> + } onChange={handlePageChange(onMetaEnginesPagination)} />
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx index f8ad9bc6b2bc3..51c65e1478d13 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.test.tsx @@ -88,6 +88,13 @@ describe('EnginesTable', () => { }); }); + describe('noItemsMessage', () => { + it('passes the noItemsMessage prop', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiBasicTable).prop('noItemsMessage')).toEqual('No items.'); + }); + }); + describe('language field', () => { it('renders language when available', () => { const wrapper = mountWithIntl( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx index fe61ba8cbcc43..f542d12318244 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { useActions } from 'kea'; @@ -24,6 +24,7 @@ import { EngineDetails } from '../engine/types'; interface EnginesTableProps { items: EngineDetails[]; loading: boolean; + noItemsMessage?: ReactNode; pagination: { pageIndex: number; pageSize: number; @@ -36,6 +37,7 @@ interface EnginesTableProps { export const EnginesTable: React.FC = ({ items, loading, + noItemsMessage, pagination, onChange, }) => { @@ -148,6 +150,7 @@ export const EnginesTable: React.FC = ({ loading={loading} pagination={pagination} onChange={onChange} + noItemsMessage={noItemsMessage} /> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.tsx new file mode 100644 index 0000000000000..aff5942d497a8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/constants.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 React from 'react'; + +import { EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DOCS_PREFIX } from '../../routes'; + +export const DEFAULT_LANGUAGE = 'Universal'; + +export const META_ENGINE_CREATION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.title', + { + defaultMessage: 'Create a meta engine', + } +); + +export const META_ENGINE_CREATION_FORM_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.title', + { + defaultMessage: 'Name your meta engine', + } +); + +export const META_ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.submitButton.buttonLabel', + { + defaultMessage: 'Create meta engine', + } +); + +export const META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.metaEngineDescription', + { + defaultMessage: + 'Meta engines allow you to combine multiple engines into one searchable engine.', + } +); + +export const META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.documentationLink', + { + defaultMessage: 'Read the documentation', + } +); + +export const META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION = ( + + {META_ENGINE_CREATION_FORM_DOCUMENTATION_LINK} + + ), + }} + /> +); + +export const META_ENGINE_CREATION_FORM_ENGINE_NAME_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.label', + { + defaultMessage: 'Meta engine name', + } +); + +export const ALLOWED_CHARS_NOTE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.allowedCharactersHelpText', + { + defaultMessage: 'Meta engine names can only contain lowercase letters, numbers, and hyphens', + } +); + +export const SANITIZED_NAME_NOTE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.sanitizedNameHelpText', + { + defaultMessage: 'Your meta engine will be named', + } +); + +export const META_ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.engineName.placeholder', + { + defaultMessage: 'i.e., my-meta-engine', + } +); + +export const META_ENGINE_CREATION_FORM_ENGINE_SOURCE_ENGINES_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.sourceEngines.label', + { + defaultMessage: 'Add source engines to this meta engine', + } +); + +export const META_ENGINE_CREATION_FORM_MAX_SOURCE_ENGINES_WARNING_TITLE = ( + maxEnginesPerMetaEngine: number +) => + i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.form.sourceEngines.maxSourceEnginesWarningTitle', + { + defaultMessage: 'Meta engines have a limit of {maxEnginesPerMetaEngine} source engines', + values: { maxEnginesPerMetaEngine }, + } + ); + +export const META_ENGINE_CREATION_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.metaEngineCreation.successMessage', + { + defaultMessage: 'Successfully created meta engine.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/index.ts new file mode 100644 index 0000000000000..2d33322a84fc6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_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 { MetaEngineCreation } from './meta_engine_creation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.test.tsx new file mode 100644 index 0000000000000..51ccc7b83b1fc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.test.tsx @@ -0,0 +1,187 @@ +/* + * 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 '../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut } from '@elastic/eui'; + +import { MetaEngineCreation } from './'; + +const DEFAULT_VALUES = { + // MetaEngineLogic + name: 'test-meta-engine', + rawName: 'test-meta-engine', + indexedEngineNames: [], + selectedIndexedEngineNames: ['one'], + // AppLogic + configuredLimits: { engine: { maxEnginesPerMetaEngine: 10 } }, +}; + +const MOCK_ACTIONS = { + setRawName: jest.fn(), + setSelectedIndexedEngineNames: jest.fn(), + fetchIndexedEngineNames: jest.fn(), + submitEngine: jest.fn(), +}; + +describe('MetaEngineCreation', () => { + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(DEFAULT_VALUES); + setMockActions(MOCK_ACTIONS); + }); + + it('renders and calls fetchIndexedEngineNames', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="MetaEngineCreation"]')).toHaveLength(1); + expect(MOCK_ACTIONS.fetchIndexedEngineNames).toHaveBeenCalledTimes(1); + }); + + describe('MetaEngineCreationNameInput', () => { + it('uses rawName as its value', () => { + const wrapper = shallow(); + expect( + wrapper + .find('[data-test-subj="MetaEngineCreationNameInput"]') + .render() + .find('input') // as far as I can tell I can't include this input in the .find() two lines above + .attr('value') + ).toEqual('test-meta-engine'); + }); + + it('EngineCreationForm calls submitEngine on form submit', () => { + const wrapper = shallow(); + const simulatedEvent = { + preventDefault: jest.fn(), + }; + wrapper.find('[data-test-subj="MetaEngineCreationForm"]').simulate('submit', simulatedEvent); + + expect(MOCK_ACTIONS.submitEngine).toHaveBeenCalledTimes(1); + }); + + it('MetaEngineCreationNameInput calls setRawName on change', () => { + const wrapper = shallow(); + const simulatedEvent = { + currentTarget: { value: 'new-raw-name' }, + }; + wrapper + .find('[data-test-subj="MetaEngineCreationNameInput"]') + .simulate('change', simulatedEvent); + expect(MOCK_ACTIONS.setRawName).toHaveBeenCalledWith('new-raw-name'); + }); + }); + + describe('EngineCreationNameFormRow', () => { + it('renders sanitized name helptext when the raw name is being sanitized', () => { + setMockValues({ + ...DEFAULT_VALUES, + name: 'name-with-special-characters', + rawName: 'Name__With#$&*%Special--Characters', + }); + + const wrapper = shallow(); + const formRow = wrapper.find('[data-test-subj="MetaEngineCreationNameFormRow"]').dive(); + + expect(formRow.contains('Your meta engine will be named')).toBeTruthy(); + }); + + it('renders allowed character helptext when rawName and sanitizedName match', () => { + setMockValues({ + ...DEFAULT_VALUES, + name: 'name-without-special-characters', + rawName: 'name-without-special-characters', + }); + + const wrapper = shallow(); + const formRow = wrapper.find('[data-test-subj="MetaEngineCreationNameFormRow"]').dive(); + + expect( + formRow.contains( + 'Meta engine names can only contain lowercase letters, numbers, and hyphens' + ) + ).toBeTruthy(); + }); + }); + + it('MetaEngineCreationSourceEnginesInput calls calls setSelectedIndexedEngines on change', () => { + const wrapper = shallow(); + + wrapper + .find('[data-test-subj="MetaEngineCreationSourceEnginesInput"]') + .simulate('change', [{ label: 'foo', value: 'foo' }]); + + expect(MOCK_ACTIONS.setSelectedIndexedEngineNames).toHaveBeenCalledWith(['foo']); + }); + + it('renders a warning callout when user has selected too many engines', () => { + setMockValues({ + ...DEFAULT_VALUES, + ...{ + selectedIndexedEngineNames: ['one', 'two', 'three'], + configuredLimits: { engine: { maxEnginesPerMetaEngine: 2 } }, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut).prop('title')).toContain('Meta engines have a limit of'); + }); + + describe('NewMetaEngineSubmitButton', () => { + it('is enabled for a valid submission', () => { + const wrapper = shallow(); + const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]'); + + expect(submitButton.prop('disabled')).toEqual(false); + }); + + it('is disabled when name is empty', () => { + setMockValues({ + ...DEFAULT_VALUES, + ...{ + name: '', + rawName: '', + }, + }); + const wrapper = shallow(); + const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]'); + + expect(submitButton.prop('disabled')).toEqual(true); + }); + + it('is disabled when user has selected no engines', () => { + setMockValues({ + ...DEFAULT_VALUES, + ...{ + selectedIndexedEngineNames: [], + }, + }); + const wrapper = shallow(); + const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]'); + + expect(submitButton.prop('disabled')).toEqual(true); + }); + + it('is disabled when user has selected too many engines', () => { + setMockValues({ + ...DEFAULT_VALUES, + ...{ + selectedIndexedEngineNames: ['one', 'two', 'three'], + configuredLimits: { engine: { maxEnginesPerMetaEngine: 2 } }, + }, + }); + const wrapper = shallow(); + const submitButton = wrapper.find('[data-test-subj="NewMetaEngineSubmitButton"]'); + + expect(submitButton.prop('disabled')).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx new file mode 100644 index 0000000000000..3757bbb533e57 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx @@ -0,0 +1,170 @@ +/* + * 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, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiCallOut, + EuiComboBox, + EuiComboBoxOptionOption, + EuiForm, + EuiFlexGroup, + EuiFormRow, + EuiFlexItem, + EuiFieldText, + EuiPageContent, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, + EuiButton, +} from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { AppLogic } from '../../app_logic'; + +import { + ALLOWED_CHARS_NOTE, + META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION, + META_ENGINE_CREATION_FORM_ENGINE_NAME_LABEL, + META_ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER, + META_ENGINE_CREATION_FORM_ENGINE_SOURCE_ENGINES_LABEL, + META_ENGINE_CREATION_FORM_MAX_SOURCE_ENGINES_WARNING_TITLE, + META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION, + META_ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL, + META_ENGINE_CREATION_FORM_TITLE, + META_ENGINE_CREATION_TITLE, + SANITIZED_NAME_NOTE, +} from './constants'; +import { MetaEngineCreationLogic } from './meta_engine_creation_logic'; + +const engineNameToComboBoxOption = (engineName: string): EuiComboBoxOptionOption => ({ + label: engineName, +}); + +const comboBoxOptionToEngineName = (option: EuiComboBoxOptionOption): 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()), }), }, },