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 a737174477177..222041c9f85a3 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 @@ -14,27 +14,37 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { EuiEmptyPrompt } from '@elastic/eui'; +import { SampleEngineCreationCta } from '../../sample_engine_creation_cta'; + import { EmptyState } from './'; describe('EmptyState', () => { describe('when the user can manage/create engines', () => { let wrapper: ShallowWrapper; + let prompt: ShallowWrapper; beforeAll(() => { setMockValues({ myRole: { canManageEngines: true } }); wrapper = shallow(); + prompt = wrapper.find(EuiEmptyPrompt).dive(); + }); + + afterAll(() => { + jest.clearAllMocks(); }); it('renders a prompt to create an engine', () => { expect(wrapper.find('[data-test-subj="AdminEmptyEnginesPrompt"]')).toHaveLength(1); }); + it('contains a sample engine CTA', () => { + expect(prompt.find(SampleEngineCreationCta)).toHaveLength(1); + }); + describe('create engine button', () => { - let prompt: ShallowWrapper; let button: ShallowWrapper; beforeAll(() => { - prompt = wrapper.find(EuiEmptyPrompt).dive(); button = prompt.find('[data-test-subj="EmptyStateCreateFirstEngineCta"]'); }); @@ -50,13 +60,18 @@ describe('EmptyState', () => { }); describe('when the user cannot manage/create engines', () => { + let wrapper: ShallowWrapper; + beforeAll(() => { setMockValues({ myRole: { canManageEngines: false } }); + wrapper = shallow(); }); - it('renders a prompt to contact the App Search admin', () => { - const wrapper = shallow(); + afterAll(() => { + jest.clearAllMocks(); + }); + it('renders a prompt to contact the App Search admin', () => { expect(wrapper.find('[data-test-subj="NonAdminEmptyEnginesPrompt"]')).toHaveLength(1); }); }); 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 df5a057e5d9c6..37c67c1f8d6f2 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,7 +9,7 @@ import React from 'react'; import { useValues, useActions } from 'kea'; -import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiPageContent, EuiEmptyPrompt, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; @@ -18,6 +18,8 @@ import { TelemetryLogic } from '../../../../shared/telemetry'; import { AppLogic } from '../../../app_logic'; import { ENGINE_CREATION_PATH } from '../../../routes'; +import { SampleEngineCreationCta } from '../../sample_engine_creation_cta/sample_engine_creation_cta'; + import { EnginesOverviewHeader } from './header'; import './empty_state.scss'; @@ -55,22 +57,26 @@ export const EmptyState: React.FC = () => {

} actions={ - - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'create_first_engine_button', - }) - } - > - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta', - { defaultMessage: 'Create an engine' } - )} - + <> + + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'create_first_engine_button', + }) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.emptyState.createFirstEngineCta', + { defaultMessage: 'Create an engine' } + )} + + + + } /> ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/i18n.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/i18n.ts new file mode 100644 index 0000000000000..229a26b0fb360 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/i18n.ts @@ -0,0 +1,29 @@ +/* + * 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 SAMPLE_ENGINE_CREATION_CTA_TITLE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.title', + { + defaultMessage: 'Just kicking the tires?', + } +); + +export const SAMPLE_ENGINE_CREATION_CTA_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.description', + { + defaultMessage: 'Test an engine with sample data.', + } +); + +export const SAMPLE_ENGINE_CREATION_CTA_BUTTON_LABEL = i18n.translate( + 'xpack.enterpriseSearch.appSearch.sampleEngineCreationCta.buttonLabel', + { + defaultMessage: 'Try a sample engine', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/index.ts new file mode 100644 index 0000000000000..fa11abd19a2db --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/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 { SampleEngineCreationCta } from './sample_engine_creation_cta'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta.test.tsx new file mode 100644 index 0000000000000..992afe4356a07 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta.test.tsx @@ -0,0 +1,57 @@ +/* + * 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__/enterprise_search_url.mock'; +import { setMockActions, setMockValues } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton } from '@elastic/eui'; + +import { SampleEngineCreationCta } from './sample_engine_creation_cta'; + +describe('SampleEngineCTA', () => { + describe('CTA button', () => { + const MOCK_VALUES = { + isLoading: false, + }; + + const MOCK_ACTIONS = { + createSampleEngine: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(MOCK_ACTIONS); + setMockValues(MOCK_VALUES); + }); + + it('calls createSampleEngine on click', () => { + const wrapper = shallow(); + const ctaButton = wrapper.find(EuiButton); + + expect(ctaButton.props().onClick).toEqual(MOCK_ACTIONS.createSampleEngine); + }); + + it('is enabled by default', () => { + const wrapper = shallow(); + const ctaButton = wrapper.find(EuiButton); + + expect(ctaButton.props().isLoading).toEqual(false); + }); + + it('is disabled while loading', () => { + setMockValues({ ...MOCK_VALUES, isLoading: true }); + const wrapper = shallow(); + const ctaButton = wrapper.find(EuiButton); + + expect(ctaButton.props().isLoading).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta.tsx new file mode 100644 index 0000000000000..8de6b6030ef66 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta.tsx @@ -0,0 +1,44 @@ +/* + * 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 { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText, EuiButton } from '@elastic/eui'; + +import { + SAMPLE_ENGINE_CREATION_CTA_TITLE, + SAMPLE_ENGINE_CREATION_CTA_DESCRIPTION, + SAMPLE_ENGINE_CREATION_CTA_BUTTON_LABEL, +} from './i18n'; +import { SampleEngineCreationCtaLogic } from './sample_engine_creation_cta_logic'; + +export const SampleEngineCreationCta: React.FC = () => { + const { isLoading } = useValues(SampleEngineCreationCtaLogic); + const { createSampleEngine } = useActions(SampleEngineCreationCtaLogic); + + return ( + + + + +

{SAMPLE_ENGINE_CREATION_CTA_TITLE}

+
+ +

{SAMPLE_ENGINE_CREATION_CTA_DESCRIPTION}

+
+
+ + + {SAMPLE_ENGINE_CREATION_CTA_BUTTON_LABEL} + + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta_logic.test.ts new file mode 100644 index 0000000000000..740c4df697d68 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta_logic.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { SampleEngineCreationCtaLogic } from './sample_engine_creation_cta_logic'; + +describe('SampleEngineCreationCtaLogic', () => { + const { mount } = new LogicMounter(SampleEngineCreationCtaLogic); + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { setQueuedSuccessMessage, flashAPIErrors } = mockFlashMessageHelpers; + + const DEFAULT_VALUES = { + isLoading: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(SampleEngineCreationCtaLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + it('onSampleEngineCreationFailure sets isLoading to false', () => { + mount({ isLoading: true }); + + SampleEngineCreationCtaLogic.actions.onSampleEngineCreationFailure(); + + expect(SampleEngineCreationCtaLogic.values.isLoading).toEqual(false); + }); + }); + + describe('listeners', () => { + describe('createSampleEngine', () => { + it('POSTS to /api/app_search/engines', () => { + const body = JSON.stringify({ + seed_sample_engine: true, + }); + SampleEngineCreationCtaLogic.actions.createSampleEngine(); + + expect(http.post).toHaveBeenCalledWith('/api/app_search/onboarding_complete', { body }); + }); + + it('calls onSampleEngineCreationSuccess on valid submission', async () => { + jest.spyOn(SampleEngineCreationCtaLogic.actions, 'onSampleEngineCreationSuccess'); + http.post.mockReturnValueOnce(Promise.resolve({})); + + SampleEngineCreationCtaLogic.actions.createSampleEngine(); + await nextTick(); + + expect( + SampleEngineCreationCtaLogic.actions.onSampleEngineCreationSuccess + ).toHaveBeenCalledTimes(1); + }); + + it('calls onSampleEngineCreationFailure and flashAPIErrors on API Error', async () => { + jest.spyOn(SampleEngineCreationCtaLogic.actions, 'onSampleEngineCreationFailure'); + http.post.mockReturnValueOnce(Promise.reject()); + + SampleEngineCreationCtaLogic.actions.createSampleEngine(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledTimes(1); + expect( + SampleEngineCreationCtaLogic.actions.onSampleEngineCreationFailure + ).toHaveBeenCalledTimes(1); + }); + }); + + it('onSampleEngineCreationSuccess should set a success message and navigate the user to the engine page', () => { + SampleEngineCreationCtaLogic.actions.onSampleEngineCreationSuccess(); + + expect(setQueuedSuccessMessage).toHaveBeenCalledWith('Successfully created engine.'); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/national-parks-demo'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta_logic.ts new file mode 100644 index 0000000000000..37570d4e3cfe7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/sample_engine_creation_cta/sample_engine_creation_cta_logic.ts @@ -0,0 +1,72 @@ +/* + * 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 { ENGINE_CREATION_SUCCESS_MESSAGE } from '../engine_creation/constants'; + +interface SampleEngineCreationCtaActions { + createSampleEngine(): void; + onSampleEngineCreationSuccess(): void; + onSampleEngineCreationFailure(): void; + setIsLoading(isLoading: boolean): { isLoading: boolean }; +} + +interface SampleEngineCreationCtaValues { + isLoading: boolean; +} + +export const SampleEngineCreationCtaLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'sample_engine_cta_logic'], + actions: { + createSampleEngine: true, + onSampleEngineCreationSuccess: true, + onSampleEngineCreationFailure: true, + }, + reducers: { + isLoading: [ + false, + { + createSampleEngine: () => true, + onSampleEngineCreationSuccess: () => false, + onSampleEngineCreationFailure: () => false, + }, + ], + }, + listeners: ({ actions }) => ({ + createSampleEngine: async () => { + const { http } = HttpLogic.values; + + const body = JSON.stringify({ seed_sample_engine: true }); + + try { + await http.post('/api/app_search/onboarding_complete', { + body, + }); + actions.onSampleEngineCreationSuccess(); + } catch (e) { + actions.onSampleEngineCreationFailure(); + flashAPIErrors(e); + } + }, + onSampleEngineCreationSuccess: () => { + const { navigateToUrl } = KibanaLogic.values; + const enginePath = generatePath(ENGINE_PATH, { engineName: 'national-parks-demo' }); + + setQueuedSuccessMessage(ENGINE_CREATION_SUCCESS_MESSAGE); + navigateToUrl(enginePath); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index d6a891e3f6241..abe5272fe3263 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -198,6 +198,18 @@ describe('EnterpriseSearchRequestHandler', () => { }); }); }); + + it('works if response contains no json data', async () => { + EnterpriseSearchAPI.mockReturn(); + + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/prep' }); + await makeAPICall(requestHandler); + + expect(responseMock.custom).toHaveBeenCalledWith({ + statusCode: 200, + headers: mockExpectedResponseHeaders, + }); + }); }); describe('error responses', () => { @@ -456,10 +468,12 @@ const EnterpriseSearchAPI = { ...expectedParams, }); }, - mockReturn(response: object, options?: any) { + mockReturn(response?: object, options?: any) { fetchMock.mockImplementation(() => { const headers = Object.assign({}, mockExpectedResponseHeaders, options?.headers); - return Promise.resolve(new Response(JSON.stringify(response), { ...options, headers })); + return Promise.resolve( + new Response(response ? JSON.stringify(response) : undefined, { ...options, headers }) + ); }); }, mockReturnError() { diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index fb525740dd55b..e5394fd580efc 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -113,22 +113,32 @@ export class EnterpriseSearchRequestHandler { } // Check returned data - const json = await apiResponse.json(); - if (!hasValidData(json)) { - return this.handleInvalidDataError(response, url, json); - } + let responseBody; - // Intercept data that is meant for the server side session - const { _sessionData, ...responseJson } = json; - if (_sessionData) { - this.setSessionData(_sessionData); + try { + const json = await apiResponse.json(); + + if (!hasValidData(json)) { + return this.handleInvalidDataError(response, url, json); + } + + // Intercept data that is meant for the server side session + const { _sessionData, ...responseJson } = json; + if (_sessionData) { + this.setSessionData(_sessionData); + responseBody = responseJson; + } else { + responseBody = json; + } + } catch (e) { + responseBody = undefined; } // Pass successful responses back to the front-end return response.custom({ statusCode: status, headers: this.headers, - body: _sessionData ? responseJson : json, + body: responseBody, }); } catch (e) { // Catch connection/auth errors diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 1bd88c111f79f..3c8501ec15b3d 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -12,6 +12,7 @@ import { registerCredentialsRoutes } from './credentials'; import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; import { registerEnginesRoutes } from './engines'; +import { registerOnboardingRoutes } from './onboarding'; import { registerResultSettingsRoutes } from './result_settings'; import { registerRoleMappingsRoutes } from './role_mappings'; import { registerSearchSettingsRoutes } from './search_settings'; @@ -28,4 +29,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerSearchSettingsRoutes(dependencies); registerRoleMappingsRoutes(dependencies); registerResultSettingsRoutes(dependencies); + registerOnboardingRoutes(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.test.ts new file mode 100644 index 0000000000000..c26f8dbaf5213 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerOnboardingRoutes } from './onboarding'; + +describe('engine routes', () => { + describe('POST /api/app_search/onboarding_complete', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/onboarding_complete', + }); + + registerOnboardingRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({ body: {} }); + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/onboarding/complete', + }); + }); + + it('validates seed_sample_engine ', () => { + const request = { body: { seed_sample_engine: true } }; + mockRouter.shouldValidate(request); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.ts new file mode 100644 index 0000000000000..9a46c75555969 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.ts @@ -0,0 +1,29 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerOnboardingRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/api/app_search/onboarding_complete', + validate: { + body: schema.object({ + seed_sample_engine: schema.maybe(schema.boolean()), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/onboarding/complete', + }) + ); +}