diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts new file mode 100644 index 0000000000000..0647eeba78786 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/constants.ts @@ -0,0 +1,215 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const DEFAULT_LANGUAGE = 'Universal'; + +export const ENGINE_CREATION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.title', + { + defaultMessage: 'Create an engine', + } +); + +export const ENGINE_CREATION_FORM_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.title', + { + defaultMessage: 'Name your engine', + } +); + +export const ENGINE_CREATION_FORM_ENGINE_NAME_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.label', + { + defaultMessage: 'Engine name', + } +); + +export const ALLOWED_CHARS_NOTE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.allowedCharactersHelpText', + { + defaultMessage: 'Engine names can only contain lowercase letters, numbers, and hyphens', + } +); + +export const SANITIZED_NAME_NOTE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.sanitizedNameHelpText', + { + defaultMessage: 'Your engine will be named', + } +); + +export const ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineName.placeholder', + { + defaultMessage: 'i.e., my-search-engine', + } +); + +export const ENGINE_CREATION_FORM_ENGINE_LANGUAGE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.engineLanguage.label', + { + defaultMessage: 'Engine language', + } +); + +export const ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.form.submitButton.buttonLabel', + { + defaultMessage: 'Create engine', + } +); + +export const ENGINE_CREATION_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.successMessage', + { + defaultMessage: 'Successfully created engine.', + } +); + +export const SUPPORTED_LANGUAGES = [ + { + value: 'Universal', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.universalDropDownOptionLabel', + { + defaultMessage: 'Universal', + } + ), + }, + { + text: '—', + disabled: true, + }, + { + value: 'zh', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.chineseDropDownOptionLabel', + { + defaultMessage: 'Chinese', + } + ), + }, + { + value: 'da', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.danishDropDownOptionLabel', + { + defaultMessage: 'Danish', + } + ), + }, + { + value: 'nl', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.dutchDropDownOptionLabel', + { + defaultMessage: 'Dutch', + } + ), + }, + { + value: 'en', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.englishDropDownOptionLabel', + { + defaultMessage: 'English', + } + ), + }, + { + value: 'fr', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.frenchDropDownOptionLabel', + { + defaultMessage: 'French', + } + ), + }, + { + value: 'de', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.germanDropDownOptionLabel', + { + defaultMessage: 'German', + } + ), + }, + { + value: 'it', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.italianDropDownOptionLabel', + { + defaultMessage: 'Italian', + } + ), + }, + { + value: 'ja', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.japaneseDropDownOptionLabel', + { + defaultMessage: 'Japanese', + } + ), + }, + { + value: 'ko', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.koreanDropDownOptionLabel', + { + defaultMessage: 'Korean', + } + ), + }, + { + value: 'pt', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.portugueseDropDownOptionLabel', + { + defaultMessage: 'Portuguese', + } + ), + }, + { + value: 'pt-br', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.portugueseBrazilDropDownOptionLabel', + { + defaultMessage: 'Portuguese (Brazil)', + } + ), + }, + { + value: 'ru', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.russianDropDownOptionLabel', + { + defaultMessage: 'Russian', + } + ), + }, + { + value: 'es', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.spanishDropDownOptionLabel', + { + defaultMessage: 'Spanish', + } + ), + }, + { + value: 'th', + text: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engineCreation.supportedLanguages.thaiDropDownOptionLabel', + { + defaultMessage: 'Thai', + } + ), + }, +]; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx new file mode 100644 index 0000000000000..cf30fac3c5f49 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setMockActions, setMockValues } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EngineCreation } from './'; + +describe('EngineCreation', () => { + const DEFAULT_VALUES = { + name: '', + rawName: '', + language: 'Universal', + }; + + const MOCK_ACTIONS = { + setRawName: jest.fn(), + setLanguage: jest.fn(), + submitEngine: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(DEFAULT_VALUES); + setMockActions(MOCK_ACTIONS); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find('[data-test-subj="EngineCreation"]')).toHaveLength(1); + }); + + it('EngineCreationForm calls submitEngine on form submit', () => { + const wrapper = shallow(); + const simulatedEvent = { + preventDefault: jest.fn(), + }; + wrapper.find('[data-test-subj="EngineCreationForm"]').simulate('submit', simulatedEvent); + + expect(MOCK_ACTIONS.submitEngine).toHaveBeenCalledTimes(1); + }); + + it('EngineCreationNameInput calls setRawName on change', () => { + const wrapper = shallow(); + const simulatedEvent = { + currentTarget: { value: 'new-raw-name' }, + }; + wrapper.find('[data-test-subj="EngineCreationNameInput"]').simulate('change', simulatedEvent); + + expect(MOCK_ACTIONS.setRawName).toHaveBeenCalledWith('new-raw-name'); + }); + + it('EngineCreationLanguageInput calls setLanguage on change', () => { + const wrapper = shallow(); + const simulatedEvent = { + currentTarget: { value: 'English' }, + }; + wrapper + .find('[data-test-subj="EngineCreationLanguageInput"]') + .simulate('change', simulatedEvent); + + expect(MOCK_ACTIONS.setLanguage).toHaveBeenCalledWith('English'); + }); + + describe('NewEngineSubmitButton', () => { + it('is disabled when name is empty', () => { + setMockValues({ ...DEFAULT_VALUES, name: '', rawName: '' }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]').prop('disabled')).toEqual( + true + ); + }); + + it('is enabled when name has a value', () => { + setMockValues({ ...DEFAULT_VALUES, name: 'test', rawName: 'test' }); + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="NewEngineSubmitButton"]').prop('disabled')).toEqual( + false + ); + }); + }); + + describe('EngineCreationNameFormRow', () => { + it('renders sanitized name helptext when the raw name is being sanitized', () => { + setMockValues({ + ...DEFAULT_VALUES, + name: 'un-sanitized-name', + rawName: 'un-----sanitized-------name', + }); + const wrapper = shallow(); + const formRow = wrapper.find('[data-test-subj="EngineCreationNameFormRow"]').dive(); + + expect(formRow.contains('Your engine will be named')).toBeTruthy(); + }); + + it('renders allowed character helptext when rawName and sanitizedName match', () => { + setMockValues({ + ...DEFAULT_VALUES, + name: 'pre-sanitized-name', + rawName: 'pre-sanitized-name', + }); + const wrapper = shallow(); + const formRow = wrapper.find('[data-test-subj="EngineCreationNameFormRow"]').dive(); + + expect( + formRow.contains('Engine names can only contain lowercase letters, numbers, and hyphens') + ).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx new file mode 100644 index 0000000000000..497c00d1f9144 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiForm, + EuiFlexGroup, + EuiFormRow, + EuiFlexItem, + EuiFieldText, + EuiSelect, + EuiPageBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, + EuiButton, + EuiPanel, +} from '@elastic/eui'; + +import { FlashMessages } from '../../../shared/flash_messages'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; + +import { + ALLOWED_CHARS_NOTE, + ENGINE_CREATION_FORM_ENGINE_LANGUAGE_LABEL, + ENGINE_CREATION_FORM_ENGINE_NAME_LABEL, + ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER, + ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL, + ENGINE_CREATION_FORM_TITLE, + ENGINE_CREATION_TITLE, + SANITIZED_NAME_NOTE, + SUPPORTED_LANGUAGES, +} from './constants'; +import { EngineCreationLogic } from './engine_creation_logic'; + +export const EngineCreation: React.FC = () => { + const { name, rawName, language } = useValues(EngineCreationLogic); + const { setLanguage, setRawName, submitEngine } = useActions(EngineCreationLogic); + + return ( +
+ + + + +

{ENGINE_CREATION_TITLE}

+
+
+
+ + + + +
{ + e.preventDefault(); + submitEngine(); + }} + > + + {ENGINE_CREATION_FORM_TITLE} + + + + + 0 && rawName !== name ? ( + <> + {SANITIZED_NAME_NOTE} {name} + + ) : ( + ALLOWED_CHARS_NOTE + ) + } + fullWidth + > + setRawName(event.currentTarget.value)} + autoComplete="off" + fullWidth + data-test-subj="EngineCreationNameInput" + placeholder={ENGINE_CREATION_FORM_ENGINE_NAME_PLACEHOLDER} + autoFocus + /> + + + + + setLanguage(event.currentTarget.value)} + /> + + + + + + {ENGINE_CREATION_FORM_SUBMIT_BUTTON_LABEL} + + +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts new file mode 100644 index 0000000000000..272e4fb3a25c0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + LogicMounter, + mockHttpValues, + mockKibanaValues, + mockFlashMessageHelpers, +} from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; + +import { EngineCreationLogic } from './engine_creation_logic'; + +describe('EngineCreationLogic', () => { + const { mount } = new LogicMounter(EngineCreationLogic); + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { setQueuedSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers; + + const DEFAULT_VALUES = { + name: '', + rawName: '', + language: 'Universal', + }; + + it('has expected default values', () => { + mount(); + expect(EngineCreationLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('setLanguage', () => { + it('sets language to the provided value', () => { + mount(); + EngineCreationLogic.actions.setLanguage('English'); + expect(EngineCreationLogic.values).toEqual({ + ...DEFAULT_VALUES, + language: 'English', + }); + }); + }); + + describe('setRawName', () => { + beforeAll(() => { + mount(); + EngineCreationLogic.actions.setRawName('Name__With#$&*%Special--Characters'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should set rawName to provided value', () => { + expect(EngineCreationLogic.values.rawName).toEqual('Name__With#$&*%Special--Characters'); + }); + + it('should set name to a sanitized value', () => { + expect(EngineCreationLogic.values.name).toEqual('name-with-special-characters'); + }); + }); + }); + + describe('listeners', () => { + describe('onEngineCreationSuccess', () => { + beforeAll(() => { + mount({ language: 'English', rawName: 'test' }); + EngineCreationLogic.actions.onEngineCreationSuccess(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should set a success message', () => { + expect(setQueuedSuccessMessage).toHaveBeenCalledWith('Successfully created engine.'); + }); + + it('should navigate the user to the engine page', () => { + expect(navigateToUrl).toHaveBeenCalledWith('/engines/test'); + }); + }); + + describe('submitEngine', () => { + beforeAll(() => { + mount({ language: 'English', rawName: 'test' }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('POSTS to /api/app_search/engines', () => { + const body = JSON.stringify({ + name: EngineCreationLogic.values.name, + language: EngineCreationLogic.values.language, + }); + EngineCreationLogic.actions.submitEngine(); + expect(http.post).toHaveBeenCalledWith('/api/app_search/engines', { body }); + }); + + it('calls onEngineCreationSuccess on valid submission', async () => { + jest.spyOn(EngineCreationLogic.actions, 'onEngineCreationSuccess'); + http.post.mockReturnValueOnce(Promise.resolve({})); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(EngineCreationLogic.actions.onEngineCreationSuccess).toHaveBeenCalledTimes(1); + }); + + it('calls flashAPIErrors on API Error', async () => { + http.post.mockReturnValueOnce(Promise.reject()); + EngineCreationLogic.actions.submitEngine(); + await nextTick(); + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts new file mode 100644 index 0000000000000..6cea32f826e7a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation_logic.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { generatePath } from 'react-router-dom'; + +import { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors, setQueuedSuccessMessage } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { KibanaLogic } from '../../../shared/kibana'; +import { ENGINE_PATH } from '../../routes'; +import { formatApiName } from '../../utils/format_api_name'; + +import { DEFAULT_LANGUAGE, ENGINE_CREATION_SUCCESS_MESSAGE } from './constants'; + +interface EngineCreationActions { + onEngineCreationSuccess(): void; + setLanguage(language: string): { language: string }; + setRawName(rawName: string): { rawName: string }; + submitEngine(): void; +} + +interface EngineCreationValues { + language: string; + name: string; + rawName: string; +} + +export const EngineCreationLogic = kea>({ + path: ['enterprise_search', 'app_search', 'engine_creation_logic'], + actions: { + onEngineCreationSuccess: true, + setLanguage: (language) => ({ language }), + setRawName: (rawName) => ({ rawName }), + submitEngine: true, + }, + reducers: { + language: [ + DEFAULT_LANGUAGE, + { + setLanguage: (_, { language }) => language, + }, + ], + rawName: [ + '', + { + setRawName: (_, { rawName }) => rawName, + }, + ], + }, + selectors: ({ selectors }) => ({ + name: [() => [selectors.rawName], (rawName) => formatApiName(rawName)], + }), + listeners: ({ values, actions }) => ({ + submitEngine: async () => { + const { http } = HttpLogic.values; + const { name, language } = values; + + const body = JSON.stringify({ name, language }); + + try { + await http.post('/api/app_search/engines', { body }); + actions.onEngineCreationSuccess(); + } catch (e) { + flashAPIErrors(e); + } + }, + onEngineCreationSuccess: () => { + const { name } = values; + const { navigateToUrl } = KibanaLogic.values; + const enginePath = generatePath(ENGINE_PATH, { engineName: name }); + + setQueuedSuccessMessage(ENGINE_CREATION_SUCCESS_MESSAGE); + navigateToUrl(enginePath); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts new file mode 100644 index 0000000000000..a1770cc50ea93 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EngineCreation } from './engine_creation'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx index 81bf3716edfb8..f505f08a3531a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FlashMessages } from '../../../shared/flash_messages'; import { DOCS_PREFIX } from '../../routes'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; @@ -41,6 +42,7 @@ export const EmptyEngineOverview: React.FC = () => { + diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx index 34a154ca83741..c33431354eafc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx @@ -12,6 +12,8 @@ import { useValues } from 'kea'; import { EuiPageHeader, EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FlashMessages } from '../../../shared/flash_messages'; + import { UnavailablePrompt, TotalStats, TotalCharts, RecentApiLogs } from './components'; import { EngineOverviewLogic } from './'; @@ -30,6 +32,7 @@ export const EngineOverviewMetrics: React.FC = () => { + {apiLogsUnavailable ? ( ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx index ac540eec3ff91..14772375c9bd4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.test.tsx @@ -10,9 +10,9 @@ import { mockTelemetryActions } from '../../../../__mocks__'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { EmptyState } from './'; @@ -23,12 +23,24 @@ describe('EmptyState', () => { expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); }); - it('sends telemetry on create first engine click', () => { - const wrapper = shallow(); - const prompt = wrapper.find(EuiEmptyPrompt).dive(); - const button = prompt.find(EuiButton); - - button.simulate('click'); - expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); + describe('CTA Button', () => { + let wrapper: ShallowWrapper; + let prompt: ShallowWrapper; + let button: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + prompt = wrapper.find(EuiEmptyPrompt).dive(); + button = prompt.find('[data-test-subj="EmptyStateCreateFirstEngineCta"]'); + }); + + it('sends telemetry on create first engine click', () => { + button.simulate('click'); + expect(mockTelemetryActions.sendAppSearchTelemetry).toHaveBeenCalled(); + }); + + it('sends a user to engine creation', () => { + expect(button.prop('to')).toEqual('/engine_creation'); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx index 5419a175c9eff..d742d68b0c9d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/empty_state.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { useActions } from 'kea'; -import { EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../../shared/telemetry'; -import { CREATE_ENGINES_PATH } from '../../../routes'; +import { ENGINE_CREATION_PATH } from '../../../routes'; import { EnginesOverviewHeader } from './header'; @@ -24,16 +24,6 @@ import './empty_state.scss'; export const EmptyState: React.FC = () => { const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - const buttonProps = { - href: getAppSearchUrl(CREATE_ENGINES_PATH), - target: '_blank', - onClick: () => - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'create_first_engine_button', - }), - }; - return ( <> @@ -60,12 +50,23 @@ export const EmptyState: React.FC = () => {

} actions={ - + + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'create_first_engine_button', + }) + } + > - + } /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts index 8b387668b89f9..401d4ccd6d117 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/constants.ts @@ -15,3 +15,10 @@ export const META_ENGINES_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.metaEngines.title', { defaultMessage: 'Meta Engines' } ); + +export const CREATE_AN_ENGINE_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engines.createAnEngineButton.ButtonLabel', + { + defaultMessage: 'Create an engine', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx index cdc06dbbe3921..978538d26e5d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.test.tsx @@ -77,6 +77,14 @@ describe('EnginesOverview', () => { expect(actions.loadEngines).toHaveBeenCalled(); }); + it('renders a create engine button which takes users to the create engine page', () => { + const wrapper = shallow(); + + expect( + wrapper.find('[data-test-subj="appSearchEnginesEngineCreationButton"]').prop('to') + ).toEqual('/engine_creation'); + }); + describe('when on a platinum license', () => { it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { setMockValues({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx index 2835c8b7cb3c4..1a81c1918ad4d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_overview.tsx @@ -12,6 +12,7 @@ import { useValues, useActions } from 'kea'; import { EuiPageContent, EuiPageContentHeader, + EuiPageContentHeaderSection, EuiPageContentBody, EuiTitle, EuiSpacer, @@ -20,12 +21,14 @@ import { import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { LicensingLogic } from '../../../shared/licensing'; +import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { ENGINE_CREATION_PATH } from '../../routes'; import { EngineIcon } from './assets/engine_icon'; import { MetaEngineIcon } from './assets/meta_engine_icon'; import { EnginesOverviewHeader, LoadingState, EmptyState } from './components'; -import { ENGINES_TITLE, META_ENGINES_TITLE } from './constants'; +import { CREATE_AN_ENGINE_BUTTON_LABEL, ENGINES_TITLE, META_ENGINES_TITLE } from './constants'; import { EnginesLogic } from './engines_logic'; import { EnginesTable } from './engines_table'; @@ -65,12 +68,24 @@ export const EnginesOverview: React.FC = () => { - - -

- {ENGINES_TITLE} -

-
+ + + +

+ {ENGINES_TITLE} +

+
+
+ + + {CREATE_AN_ENGINE_BUTTON_LABEL} + +
{ }); describe('ability checks', () => { - // TODO: Use this section for routes wrapped in canViewX conditionals - // e.g., it('renders settings if a user can view settings') + describe('canManageEngines', () => { + it('renders EngineCreation when user canManageEngines is true', () => { + setMockValues({ myRole: { canManageEngines: true } }); + const wrapper = shallow(); + + expect(wrapper.find(EngineCreation)).toHaveLength(1); + }); + + it('does not render EngineCreation when user canManageEngines is false', () => { + setMockValues({ myRole: { canManageEngines: false } }); + const wrapper = shallow(); + + expect(wrapper.find(EngineCreation)).toHaveLength(0); + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 36ac3fb4dbc5b..40dfc1426e402 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -21,6 +21,7 @@ import { NotFound } from '../shared/not_found'; import { AppLogic } from './app_logic'; import { Credentials, CREDENTIALS_TITLE } from './components/credentials'; import { EngineNav, EngineRouter } from './components/engine'; +import { EngineCreation } from './components/engine_creation'; import { EnginesOverview, ENGINES_TITLE } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; import { Library } from './components/library'; @@ -28,6 +29,7 @@ import { ROLE_MAPPINGS_TITLE } from './components/role_mappings'; import { Settings, SETTINGS_TITLE } from './components/settings'; import { SetupGuide } from './components/setup_guide'; import { + ENGINE_CREATION_PATH, ROOT_PATH, SETUP_GUIDE_PATH, SETTINGS_PATH, @@ -56,7 +58,10 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC = (props) => { const { initializeAppData } = useActions(AppLogic); - const { hasInitialized } = useValues(AppLogic); + const { + hasInitialized, + myRole: { canManageEngines }, + } = useValues(AppLogic); const { errorConnecting, readOnlyMode } = useValues(HttpLogic); useEffect(() => { @@ -96,6 +101,11 @@ export const AppSearchConfigured: React.FC = (props) => { + {canManageEngines && ( + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 962efbb7ece3a..dee8858fada8b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -17,7 +17,7 @@ export const CREDENTIALS_PATH = '/credentials'; export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 if the # isn't included export const ENGINES_PATH = '/engines'; -export const CREATE_ENGINES_PATH = `${ENGINES_PATH}/new`; +export const ENGINE_CREATION_PATH = '/engine_creation'; export const ENGINE_PATH = `${ENGINES_PATH}/:engineName`; export const SAMPLE_ENGINE_PATH = `${ENGINES_PATH}/national-parks-demo`; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts index abd26e18c7b9d..6fbc9f5bd2fc4 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.test.ts @@ -110,6 +110,47 @@ describe('engine routes', () => { }); }); + describe('POST /api/app_search/engines', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines', + }); + + registerEnginesRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({ body: { name: 'some-engine', language: 'en' } }); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/collection', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: { name: 'some-engine', language: 'en' } }; + mockRouter.shouldValidate(request); + }); + + it('missing name', () => { + const request = { body: { language: 'en' } }; + mockRouter.shouldThrow(request); + }); + + it('optional language', () => { + const request = { body: { name: 'some-engine' } }; + mockRouter.shouldValidate(request); + }); + }); + }); + describe('GET /api/app_search/engines/{name}', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts index 0070680985a34..7d537e5dc0df3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/engines.ts @@ -45,6 +45,21 @@ export function registerEnginesRoutes({ } ); + router.post( + { + path: '/api/app_search/engines', + validate: { + body: schema.object({ + name: schema.string(), + language: schema.maybe(schema.string()), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/collection', + }) + ); + // Single engine endpoints router.get( {