[App Search] Added Sample Response section to Result Settings (#95971)

This commit is contained in:
Jason Stoltzfus 2021-04-05 13:49:54 -04:00 committed by GitHub
parent 1fad3175f9
commit ad5f83a362
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 556 additions and 1 deletions

View file

@ -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<Props> = ({ engineBreadcrumb }) => {
<ResultSettingsTable />
</EuiFlexItem>
<EuiFlexItem grow={3}>
<div>TODO</div>
<SampleResponse />
</EuiFlexItem>
</EuiFlexGroup>
</>

View file

@ -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 = () => {
</EuiTableRowCell>
<EuiTableRowCellCheckbox>
<EuiCheckbox
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.resultSettings.table.rawAriaLabel',
{ defaultMessage: 'Toggle raw field' }
)}
data-test-subj="ResultSettingRawCheckBox"
id={`${fieldName}-raw}`}
checked={!!fieldSettings.raw}

View file

@ -10,6 +10,7 @@ import React, { useMemo } from 'react';
import { useValues, useActions } from 'kea';
import { EuiTableRow, EuiTableRowCell, EuiTableRowCellCheckbox, EuiCheckbox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ResultSettingsLogic } from '../result_settings_logic';
import { FieldResultSetting } from '../types';
@ -43,6 +44,10 @@ export const TextFieldsBody: React.FC = () => {
</EuiTableRowCell>
<EuiTableRowCellCheckbox>
<EuiCheckbox
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.resultSettings.table.rawAriaLabel',
{ defaultMessage: 'Toggle raw field' }
)}
data-test-subj="ResultSettingRawCheckBox"
id={`${fieldName}-raw}`}
checked={!!fieldSettings.raw}
@ -63,6 +68,10 @@ export const TextFieldsBody: React.FC = () => {
</EuiTableRowCell>
<EuiTableRowCellCheckbox>
<EuiCheckbox
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.resultSettings.table.snippetAriaLabel',
{ defaultMessage: 'Toggle text snippet' }
)}
data-test-subj="ResultSettingSnippetTextBox"
id={`${fieldName}-snippet}`}
checked={!!fieldSettings.snippet}
@ -73,6 +82,10 @@ export const TextFieldsBody: React.FC = () => {
</EuiTableRowCellCheckbox>
<EuiTableRowCellCheckbox>
<EuiCheckbox
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.resultSettings.table.snippetFallbackAriaLabel',
{ defaultMessage: 'Toggle snippet fallback' }
)}
data-test-subj="ResultSettingFallbackTextBox"
id={`${fieldName}-snippetFallback}`}
checked={fieldSettings.snippetFallback}

View file

@ -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 { SampleResponse } from './sample_response';

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../../__mocks__/shallow_useeffect.mock';
import { setMockActions, setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiCodeBlock, EuiFieldSearch } from '@elastic/eui';
import { SampleResponse } from './sample_response';
describe('SampleResponse', () => {
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(<SampleResponse />);
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(<SampleResponse />);
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(<SampleResponse />);
expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('foo');
});
it('renders the response from the given user "query" in a code block', () => {
const wrapper = shallow(<SampleResponse />);
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(<SampleResponse />);
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(<SampleResponse />);
expect(wrapper.find(EuiCodeBlock).exists()).toEqual(false);
});
});

View file

@ -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 (
<EuiPanel hasBorder>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiTitle size="s">
<h2>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.resultSettings.sampleResponseTitle',
{ defaultMessage: 'Sample response' }
)}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{/* TODO <QueryPerformance queryPerformanceRating={queryPerformanceRating} /> */}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFieldSearch
value={query}
onChange={(e) => 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"
/>
<EuiSpacer />
{!!response && (
<EuiCodeBlock language="json" whiteSpace="pre-wrap">
{typeof response === 'string' ? response : JSON.stringify(response, null, 2)}
</EuiCodeBlock>
)}
</EuiPanel>
);
};

View file

@ -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();
});
});
});
});

View file

@ -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<MakeLogicType<SampleResponseValues, SampleResponseActions>>({
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);
}
}
},
}),
});

View file

@ -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<string, FieldResultSetting | {}>;
export type SampleSearchResponse = Record<string, FieldValue>;

View file

@ -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);
});
});
});
});

View file

@ -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',
})
);
}