diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx index 0e5a7d56e9065..6f0d8571ab281 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_nav.tsx @@ -174,8 +174,7 @@ export const EngineNav: React.FC = () => { )} {canManageEngineRelevanceTuning && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index aa8b406cf7774..f4fabc29a6b59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -16,6 +16,7 @@ import { Switch, Redirect, useParams } from 'react-router-dom'; import { Loading } from '../../../shared/loading'; import { EngineOverview } from '../engine_overview'; import { AnalyticsRouter } from '../analytics'; +import { RelevanceTuning } from '../relevance_tuning'; import { EngineRouter } from './engine_router'; @@ -93,4 +94,11 @@ describe('EngineRouter', () => { expect(wrapper.find(AnalyticsRouter)).toHaveLength(1); }); + + it('renders an relevance tuning view', () => { + setMockValues({ ...values, myRole: { canManageEngineRelevanceTuning: true } }); + const wrapper = shallow(); + + expect(wrapper.find(RelevanceTuning)).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index fd21507a427d5..ba00792237971 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -23,7 +23,7 @@ import { // ENGINE_SCHEMA_PATH, // ENGINE_CRAWLER_PATH, // META_ENGINE_SOURCE_ENGINES_PATH, - // ENGINE_RELEVANCE_TUNING_PATH, + ENGINE_RELEVANCE_TUNING_PATH, // ENGINE_SYNONYMS_PATH, // ENGINE_CURATIONS_PATH, // ENGINE_RESULT_SETTINGS_PATH, @@ -37,6 +37,7 @@ import { Loading } from '../../../shared/loading'; import { EngineOverview } from '../engine_overview'; import { AnalyticsRouter } from '../analytics'; import { DocumentDetail, Documents } from '../documents'; +import { RelevanceTuning } from '../relevance_tuning'; import { EngineLogic } from './'; @@ -44,13 +45,13 @@ export const EngineRouter: React.FC = () => { const { myRole: { canViewEngineAnalytics, + canManageEngineRelevanceTuning, // canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, // canViewMetaEngineSourceEngines, // canManageEngineSynonyms, // canManageEngineCurations, - // canManageEngineRelevanceTuning, // canManageEngineResultSettings, // canManageEngineSearchUi, // canViewEngineApiLogs, @@ -95,6 +96,11 @@ export const EngineRouter: React.FC = () => { + {canManageEngineRelevanceTuning && ( + + + + )} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts index 40f3ddbf2899b..55070255ac81b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/index.ts @@ -5,3 +5,4 @@ */ export { RELEVANCE_TUNING_TITLE } from './constants'; +export { RelevanceTuning } from './relevance_tuning'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx new file mode 100644 index 0000000000000..5934aca6be5f2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { RelevanceTuning } from './relevance_tuning'; + +describe('RelevanceTuning', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.isEmptyRender()).toBe(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx new file mode 100644 index 0000000000000..cca352904930b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, + EuiPageContentBody, + EuiPageContent, +} from '@elastic/eui'; + +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { FlashMessages } from '../../../shared/flash_messages'; + +import { RELEVANCE_TUNING_TITLE } from './constants'; + +interface Props { + engineBreadcrumb: string[]; +} + +export const RelevanceTuning: React.FC = ({ engineBreadcrumb }) => { + return ( + <> + + + + +

{RELEVANCE_TUNING_TITLE}

+
+
+
+ + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 7f12f7d29671a..080f6efcc71fb 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 @@ -41,7 +41,7 @@ export const ENGINE_CRAWLER_PATH = `${ENGINE_PATH}/crawler`; export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; -export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/search-settings`; +export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`; export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`; export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; // TODO: Curations sub-pages diff --git a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts index c02d5cf0ff130..819cabec44f07 100644 --- a/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts +++ b/x-pack/plugins/enterprise_search/server/__mocks__/router.mock.ts @@ -50,12 +50,7 @@ export class MockRouter { }; public callRoute = async (request: MockRouterRequest) => { - const routerCalls = this.router[this.method].mock.calls as any[]; - if (!routerCalls.length) throw new Error('No routes registered.'); - - const route = routerCalls.find(([router]: any) => router.path === this.path); - if (!route) throw new Error('No matching registered routes found - check method/path keys'); - + const route = this.findRouteRegistration(); const [, handler] = route; const context = {} as jest.Mocked; await handler(context, httpServerMock.createKibanaRequest(request as any), this.response); @@ -68,7 +63,8 @@ export class MockRouter { public validateRoute = (request: MockRouterRequest) => { if (!this.payload) throw new Error('Cannot validate wihout a payload type specified.'); - const [config] = this.router[this.method].mock.calls[0]; + const route = this.findRouteRegistration(); + const [config] = route; const validate = config.validate as RouteValidatorConfig<{}, {}, {}>; const payloadValidation = validate[this.payload] as { validate(request: KibanaRequest): void }; @@ -84,6 +80,16 @@ export class MockRouter { public shouldThrow = (request: MockRouterRequest) => { expect(() => this.validateRoute(request)).toThrow(); }; + + private findRouteRegistration = () => { + const routerCalls = this.router[this.method].mock.calls as any[]; + if (!routerCalls.length) throw new Error('No routes registered.'); + + const route = routerCalls.find(([router]: any) => router.path === this.path); + if (!route) throw new Error('No matching registered routes found - check method/path keys'); + + return route; + }; } /** 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 a20e7854db171..c384826f469f8 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 @@ -11,6 +11,7 @@ import { registerCredentialsRoutes } from './credentials'; import { registerSettingsRoutes } from './settings'; import { registerAnalyticsRoutes } from './analytics'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; +import { registerSearchSettingsRoutes } from './search_settings'; export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerEnginesRoutes(dependencies); @@ -19,4 +20,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerAnalyticsRoutes(dependencies); registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); + registerSearchSettingsRoutes(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts new file mode 100644 index 0000000000000..56a6e6297d1f8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSearchSettingsRoutes } from './search_settings'; + +describe('search settings routes', () => { + const boosts = { + types: [ + { + type: 'value', + factor: 6.2, + value: ['1313'], + }, + ], + hp: [ + { + function: 'exponential', + type: 'functional', + factor: 1, + operation: 'add', + }, + ], + }; + const resultFields = { + id: { + raw: {}, + }, + hp: { + raw: {}, + }, + name: { + raw: {}, + }, + }; + const searchFields = { + hp: { + weight: 1, + }, + name: { + weight: 1, + }, + id: { + weight: 1, + }, + }; + const searchSettings = { + boosts, + result_fields: resultFields, + search_fields: searchFields, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /api/app_search/engines/{name}/search_settings/details', () => { + const mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/search_settings/details', + }); + + beforeEach(() => { + registerSearchSettingsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/search_settings/details', + }); + }); + }); + + describe('PUT /api/app_search/engines/{name}/search_settings', () => { + const mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/engines/{engineName}/search_settings', + payload: 'body', + }); + + beforeEach(() => { + registerSearchSettingsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: searchSettings, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/search_settings', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { body: searchSettings }; + mockRouter.shouldValidate(request); + }); + + it('missing required fields', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); + + describe('POST /api/app_search/engines/{name}/search_settings/reset', () => { + const mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/search_settings/reset', + }); + + beforeEach(() => { + registerSearchSettingsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/search_settings/reset', + }); + }); + }); + + describe('POST /api/app_search/engines/{name}/search_settings_search', () => { + const mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/search_settings_search', + payload: 'body', + }); + + beforeEach(() => { + registerSearchSettingsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: searchSettings, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/search_settings_search', + }); + }); + + describe('validates body', () => { + it('correctly', () => { + const request = { + body: { + boosts, + search_fields: searchFields, + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing required fields', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + + describe('validates query', () => { + const queryRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/search_settings_search', + payload: 'query', + }); + + it('correctly', () => { + registerSearchSettingsRoutes({ + ...mockDependencies, + router: queryRouter.router, + }); + + const request = { + query: { + query: 'foo', + }, + }; + queryRouter.shouldValidate(request); + }); + + it('missing required fields', () => { + const request = { query: {} }; + queryRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts new file mode 100644 index 0000000000000..eb50d736ac975 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_settings.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +// We only do a very light type check here, and allow unknowns, because the request is validated +// on the ent-search server, so it would be redundant to check it here as well. +const boosts = schema.recordOf( + schema.string(), + schema.arrayOf(schema.object({}, { unknowns: 'allow' })) +); +const resultFields = schema.recordOf(schema.string(), schema.object({}, { unknowns: 'allow' })); +const searchFields = schema.recordOf(schema.string(), schema.object({}, { unknowns: 'allow' })); + +const searchSettingsSchema = schema.object({ + boosts, + result_fields: resultFields, + search_fields: searchFields, +}); + +export function registerSearchSettingsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/search_settings/details', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/:engineName/search_settings/details`, + }) + ); + + router.post( + { + path: '/api/app_search/engines/{engineName}/search_settings/reset', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/:engineName/search_settings/reset`, + }) + ); + + router.put( + { + path: '/api/app_search/engines/{engineName}/search_settings', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: searchSettingsSchema, + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/:engineName/search_settings`, + }) + ); + + router.post( + { + path: '/api/app_search/engines/{engineName}/search_settings_search', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + boosts, + search_fields: searchFields, + }), + query: schema.object({ + query: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: `/as/engines/:engineName/search_settings_search`, + }) + ); +}