From ec2ae6ad8416f31e43452f2e78c91b220089af79 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Mon, 5 Apr 2021 13:49:54 -0400 Subject: [PATCH] [App Search] Added Sample Response section to Result Settings (#95971) --- .../result_settings/result_settings.tsx | 4 +- .../non_text_fields_body.tsx | 5 + .../text_fields_body.tsx | 13 ++ .../result_settings/sample_response/index.ts | 8 + .../sample_response/sample_response.test.tsx | 75 ++++++ .../sample_response/sample_response.tsx | 72 ++++++ .../sample_response_logic.test.ts | 214 ++++++++++++++++++ .../sample_response/sample_response_logic.ts | 100 ++++++++ .../components/result_settings/types.ts | 4 + .../routes/app_search/result_settings.test.ts | 44 ++++ .../routes/app_search/result_settings.ts | 18 ++ 11 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx index 38db5c60e98a9..7f4373835f8d5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings.tsx @@ -17,6 +17,8 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chro import { RESULT_SETTINGS_TITLE } from './constants'; import { ResultSettingsTable } from './result_settings_table'; +import { SampleResponse } from './sample_response'; + import { ResultSettingsLogic } from '.'; interface Props { @@ -40,7 +42,7 @@ export const ResultSettings: React.FC = ({ engineBreadcrumb }) => { -
TODO
+
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/non_text_fields_body.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/non_text_fields_body.tsx index 145654be20461..dc91b5039a3c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/non_text_fields_body.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/non_text_fields_body.tsx @@ -10,6 +10,7 @@ import React, { useMemo } from 'react'; import { useValues, useActions } from 'kea'; import { EuiTableRow, EuiTableRowCell, EuiCheckbox, EuiTableRowCellCheckbox } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { ResultSettingsLogic } from '..'; import { FieldResultSetting } from '../types'; @@ -33,6 +34,10 @@ export const NonTextFieldsBody: React.FC = () => { { { { { + const actions = { + queryChanged: jest.fn(), + getSearchResults: jest.fn(), + }; + + const values = { + reducedServerResultFields: {}, + query: 'foo', + response: { + bar: 'baz', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(actions); + setMockValues(values); + }); + + it('renders a text box with the current user "query" value from state', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('foo'); + }); + + it('updates the "query" value in state when a user updates the text in the text box', () => { + const wrapper = shallow(); + wrapper.find(EuiFieldSearch).simulate('change', { target: { value: 'bar' } }); + expect(actions.queryChanged).toHaveBeenCalledWith('bar'); + }); + + it('will call getSearchResults with the current value of query and reducedServerResultFields in a useEffect, which updates the displayed response', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('foo'); + }); + + it('renders the response from the given user "query" in a code block', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiCodeBlock).prop('children')).toEqual('{\n "bar": "baz"\n}'); + }); + + it('renders a plain old string in the code block if the response is a string', () => { + setMockValues({ + response: 'No results.', + }); + const wrapper = shallow(); + expect(wrapper.find(EuiCodeBlock).prop('children')).toEqual('No results.'); + }); + + it('will not render a code block at all if there is no response yet', () => { + setMockValues({ + response: null, + }); + const wrapper = shallow(); + expect(wrapper.find(EuiCodeBlock).exists()).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response.tsx new file mode 100644 index 0000000000000..ae91b9648356c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response.tsx @@ -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 React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiCodeBlock, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ResultSettingsLogic } from '../result_settings_logic'; + +import { SampleResponseLogic } from './sample_response_logic'; + +export const SampleResponse: React.FC = () => { + const { reducedServerResultFields } = useValues(ResultSettingsLogic); + + const { query, response } = useValues(SampleResponseLogic); + const { queryChanged, getSearchResults } = useActions(SampleResponseLogic); + + useEffect(() => { + getSearchResults(query, reducedServerResultFields); + }, [query, reducedServerResultFields]); + + return ( + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.sampleResponseTitle', + { defaultMessage: 'Sample response' } + )} +

+
+
+ + {/* TODO */} + +
+ + queryChanged(e.target.value)} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.sampleResponse.inputPlaceholder', + { defaultMessage: 'Type a search query to test a response...' } + )} + data-test-subj="ResultSettingsQuerySampleResponse" + /> + + {!!response && ( + + {typeof response === 'string' ? response : JSON.stringify(response, null, 2)} + + )} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.test.ts new file mode 100644 index 0000000000000..79379306c1618 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.test.ts @@ -0,0 +1,214 @@ +/* + * 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 } from '../../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { flashAPIErrors } from '../../../../shared/flash_messages'; + +import { SampleResponseLogic } from './sample_response_logic'; + +describe('SampleResponseLogic', () => { + const { mount } = new LogicMounter(SampleResponseLogic); + const { http } = mockHttpValues; + + const DEFAULT_VALUES = { + query: '', + response: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(SampleResponseLogic.values).toEqual({ + ...DEFAULT_VALUES, + }); + }); + + describe('actions', () => { + describe('queryChanged', () => { + it('updates the query', () => { + mount({ + query: '', + }); + + SampleResponseLogic.actions.queryChanged('foo'); + + expect(SampleResponseLogic.values).toEqual({ + ...DEFAULT_VALUES, + query: 'foo', + }); + }); + }); + + describe('getSearchResultsSuccess', () => { + it('sets the response from a search API request', () => { + mount({ + response: null, + }); + + SampleResponseLogic.actions.getSearchResultsSuccess({}); + + expect(SampleResponseLogic.values).toEqual({ + ...DEFAULT_VALUES, + response: {}, + }); + }); + }); + + describe('getSearchResultsFailure', () => { + it('sets a string response from a search API request', () => { + mount({ + response: null, + }); + + SampleResponseLogic.actions.getSearchResultsFailure('An error occured.'); + + expect(SampleResponseLogic.values).toEqual({ + ...DEFAULT_VALUES, + response: 'An error occured.', + }); + }); + }); + }); + + describe('listeners', () => { + describe('getSearchResults', () => { + beforeAll(() => jest.useFakeTimers()); + afterAll(() => jest.useRealTimers()); + + it('makes a search API request and calls getSearchResultsSuccess with the first result of the response', async () => { + mount(); + jest.spyOn(SampleResponseLogic.actions, 'getSearchResultsSuccess'); + + http.post.mockReturnValue( + Promise.resolve({ + results: [ + { id: { raw: 'foo' }, _meta: {} }, + { id: { raw: 'bar' }, _meta: {} }, + { id: { raw: 'baz' }, _meta: {} }, + ], + }) + ); + + SampleResponseLogic.actions.getSearchResults('foo', { foo: { raw: true } }); + jest.runAllTimers(); + await nextTick(); + + expect(SampleResponseLogic.actions.getSearchResultsSuccess).toHaveBeenCalledWith({ + // Note that the _meta field was stripped from the result + id: { raw: 'foo' }, + }); + }); + + it('calls getSearchResultsSuccess with a "No Results." message if there are no results', async () => { + mount(); + jest.spyOn(SampleResponseLogic.actions, 'getSearchResultsSuccess'); + + http.post.mockReturnValue( + Promise.resolve({ + results: [], + }) + ); + + SampleResponseLogic.actions.getSearchResults('foo', { foo: { raw: true } }); + jest.runAllTimers(); + await nextTick(); + + expect(SampleResponseLogic.actions.getSearchResultsSuccess).toHaveBeenCalledWith( + 'No results.' + ); + }); + + it('handles 500 errors by setting a generic error response and showing a flash message error', async () => { + mount(); + jest.spyOn(SampleResponseLogic.actions, 'getSearchResultsFailure'); + + const error = { + response: { + status: 500, + }, + }; + + http.post.mockReturnValueOnce(Promise.reject(error)); + + SampleResponseLogic.actions.getSearchResults('foo', { foo: { raw: true } }); + jest.runAllTimers(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith(error); + expect(SampleResponseLogic.actions.getSearchResultsFailure).toHaveBeenCalledWith( + 'An error occured.' + ); + }); + + it('handles 400 errors by setting the response, but does not show a flash error message', async () => { + mount(); + jest.spyOn(SampleResponseLogic.actions, 'getSearchResultsFailure'); + + http.post.mockReturnValueOnce( + Promise.reject({ + response: { + status: 400, + }, + body: { + attributes: { + errors: ['A validation error occurred.'], + }, + }, + }) + ); + + SampleResponseLogic.actions.getSearchResults('foo', { foo: { raw: true } }); + jest.runAllTimers(); + await nextTick(); + + expect(SampleResponseLogic.actions.getSearchResultsFailure).toHaveBeenCalledWith({ + errors: ['A validation error occurred.'], + }); + }); + + it('sets a generic message on a 400 error if no custom message is provided in the response', async () => { + mount(); + jest.spyOn(SampleResponseLogic.actions, 'getSearchResultsFailure'); + + http.post.mockReturnValueOnce( + Promise.reject({ + response: { + status: 400, + }, + }) + ); + + SampleResponseLogic.actions.getSearchResults('foo', { foo: { raw: true } }); + jest.runAllTimers(); + await nextTick(); + + expect(SampleResponseLogic.actions.getSearchResultsFailure).toHaveBeenCalledWith( + 'An error occured.' + ); + }); + + it('does nothing if an empty object is passed for the resultFields parameter', async () => { + mount(); + jest.spyOn(SampleResponseLogic.actions, 'getSearchResultsSuccess'); + + SampleResponseLogic.actions.getSearchResults('foo', {}); + + jest.runAllTimers(); + await nextTick(); + + expect(SampleResponseLogic.actions.getSearchResultsSuccess).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts new file mode 100644 index 0000000000000..808a7ec9c65dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/sample_response/sample_response_logic.ts @@ -0,0 +1,100 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { flashAPIErrors } from '../../../../shared/flash_messages'; + +import { HttpLogic } from '../../../../shared/http'; +import { EngineLogic } from '../../engine'; + +import { SampleSearchResponse, ServerFieldResultSettingObject } from '../types'; + +const NO_RESULTS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.sampleResponse.noResultsMessage', + { defaultMessage: 'No results.' } +); + +const ERROR_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.sampleResponse.errorMessage', + { defaultMessage: 'An error occured.' } +); + +interface SampleResponseValues { + query: string; + response: SampleSearchResponse | string | null; +} + +interface SampleResponseActions { + queryChanged: (query: string) => { query: string }; + getSearchResultsSuccess: ( + response: SampleSearchResponse | string + ) => { response: SampleSearchResponse | string }; + getSearchResultsFailure: (response: string) => { response: string }; + getSearchResults: ( + query: string, + resultFields: ServerFieldResultSettingObject + ) => { query: string; resultFields: ServerFieldResultSettingObject }; +} + +export const SampleResponseLogic = kea>({ + path: ['enterprise_search', 'app_search', 'sample_response_logic'], + actions: { + queryChanged: (query) => ({ query }), + getSearchResultsSuccess: (response) => ({ response }), + getSearchResultsFailure: (response) => ({ response }), + getSearchResults: (query, resultFields) => ({ query, resultFields }), + }, + reducers: { + query: ['', { queryChanged: (_, { query }) => query }], + response: [ + null, + { + getSearchResultsSuccess: (_, { response }) => response, + getSearchResultsFailure: (_, { response }) => response, + }, + ], + }, + listeners: ({ actions }) => ({ + getSearchResults: async ({ query, resultFields }, breakpoint) => { + if (Object.keys(resultFields).length < 1) return; + await breakpoint(250); + + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/sample_response_search`; + + try { + const response = await http.post(url, { + body: JSON.stringify({ + query, + result_fields: resultFields, + }), + }); + + const result = response.results?.[0]; + actions.getSearchResultsSuccess( + result ? { ...result, _meta: undefined } : NO_RESULTS_MESSAGE + ); + } catch (e) { + if (e.response.status >= 500) { + // 4XX Validation errors are expected, as a user could enter something like 2 as a size, which is out of valid range. + // In this case, we simply render the message from the server as the response. + // + // 5xx Server errors are unexpected, and need to be reported in a flash message. + flashAPIErrors(e); + actions.getSearchResultsFailure(ERROR_MESSAGE); + } else { + actions.getSearchResultsFailure(e.body?.attributes || ERROR_MESSAGE); + } + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts index 96bf277314a7b..18843112f46bf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { FieldValue } from '../result/types'; + export enum OpenModal { None, ConfirmResetModal, @@ -35,3 +37,5 @@ export interface FieldResultSetting { } export type FieldResultSettingObject = Record; + +export type SampleSearchResponse = Record; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.test.ts index 8d1a7e3ead37b..e38380d60c6e9 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.test.ts @@ -88,4 +88,48 @@ describe('result settings routes', () => { }); }); }); + + describe('POST /api/app_search/engines/{name}/sample_response_search', () => { + const mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{engineName}/sample_response_search', + }); + + beforeEach(() => { + registerResultSettingsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: { + query: 'test', + result_fields: resultFields, + }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/sample_response_search', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + query: 'test', + result_fields: resultFields, + }, + }; + mockRouter.shouldValidate(request); + }); + it('missing required fields', () => { + const request = { body: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.ts index 38cb4aa922738..b091ae7a539c2 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.ts @@ -45,4 +45,22 @@ export function registerResultSettingsRoutes({ path: '/as/engines/:engineName/result_settings', }) ); + + router.post( + { + path: '/api/app_search/engines/{engineName}/sample_response_search', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + query: schema.string(), + result_fields: schema.recordOf(schema.string(), schema.object({}, { unknowns: 'allow' })), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/sample_response_search', + }) + ); }