diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/constants.ts index d5e7035348b45..717cdc952fd67 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/constants.ts @@ -9,6 +9,8 @@ import { i18n } from '@kbn/i18n'; import { FieldResultSetting } from './types'; +export const DEFAULT_SNIPPET_SIZE = 100; + export const RESULT_SETTINGS_TITLE = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.resultSettings.title', { defaultMessage: 'Result Settings' } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts index 9147940374645..1b7eb6cc00792 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.test.ts @@ -5,7 +5,13 @@ * 2.0. */ -import { LogicMounter } from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; + +import { mockEngineValues } from '../../__mocks__'; + +import { omit } from 'lodash'; + +import { nextTick } from '@kbn/test/jest'; import { Schema, SchemaConflicts, SchemaTypes } from '../../../shared/types'; @@ -29,13 +35,27 @@ describe('ResultSettingsLogic', () => { schemaConflicts: {}, }; + const SELECTORS = { + reducedServerResultFields: {}, + resultFieldsAtDefaultSettings: true, + resultFieldsEmpty: true, + stagedUpdates: false, + }; + + // Values without selectors + const resultSettingLogicValues = () => omit(ResultSettingsLogic.values, Object.keys(SELECTORS)); + beforeEach(() => { jest.clearAllMocks(); + mockEngineValues.engineName = 'test-engine'; }); it('has expected default values', () => { mount(); - expect(ResultSettingsLogic.values).toEqual(DEFAULT_VALUES); + expect(ResultSettingsLogic.values).toEqual({ + ...DEFAULT_VALUES, + ...SELECTORS, + }); }); describe('actions', () => { @@ -71,7 +91,7 @@ describe('ResultSettingsLogic', () => { schemaConflicts ); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, dataLoading: false, saving: false, @@ -173,7 +193,7 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.openConfirmSaveModal(); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, openModal: OpenModal.ConfirmSaveModal, }); @@ -186,7 +206,7 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.openConfirmResetModal(); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, openModal: OpenModal.ConfirmResetModal, }); @@ -200,7 +220,7 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.closeModals(); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, openModal: OpenModal.None, }); @@ -230,7 +250,7 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.clearAllFields(); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, nonTextResultFields: { foo: {}, @@ -275,7 +295,7 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.resetAllFields(); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, nonTextResultFields: { bar: { raw: true, snippet: false, snippetFallback: false }, @@ -303,7 +323,7 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.resetAllFields(); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, openModal: OpenModal.None, }); @@ -339,7 +359,7 @@ describe('ResultSettingsLogic', () => { snippetFallback: false, }); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, // the settings for foo are updated below for any *ResultFields state in which they appear nonTextResultFields: { @@ -372,7 +392,7 @@ describe('ResultSettingsLogic', () => { }); // 'baz' does not exist in state, so nothing is updated - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, ...initialValues, }); @@ -387,7 +407,7 @@ describe('ResultSettingsLogic', () => { ResultSettingsLogic.actions.saving(); - expect(ResultSettingsLogic.values).toEqual({ + expect(resultSettingLogicValues()).toEqual({ ...DEFAULT_VALUES, saving: true, openModal: OpenModal.None, @@ -395,4 +415,453 @@ describe('ResultSettingsLogic', () => { }); }); }); + + describe('selectors', () => { + describe('resultFieldsAtDefaultSettings', () => { + it('should return true if all fields are at their default settings', () => { + mount({ + resultFields: { + foo: { raw: true, snippet: false, snippetFallback: false }, + bar: { raw: true, snippet: false, snippetFallback: false }, + }, + }); + + expect(ResultSettingsLogic.values.resultFieldsAtDefaultSettings).toEqual(true); + }); + + it('should return false otherwise', () => { + mount({ + resultFields: { + foo: { raw: true, snippet: false, snippetFallback: false }, + bar: { raw: true, snippet: true, snippetFallback: false }, + }, + }); + + expect(ResultSettingsLogic.values.resultFieldsAtDefaultSettings).toEqual(false); + }); + }); + + describe('resultFieldsEmpty', () => { + it('should return true if all fields are empty', () => { + mount({ + resultFields: { + foo: {}, + bar: {}, + }, + }); + + expect(ResultSettingsLogic.values.resultFieldsEmpty).toEqual(true); + }); + + it('should return false otherwise', () => { + mount({ + resultFields: { + foo: {}, + bar: { raw: true, snippet: true, snippetFallback: false }, + }, + }); + + expect(ResultSettingsLogic.values.resultFieldsEmpty).toEqual(false); + }); + }); + + describe('stagedUpdates', () => { + it('should return true if changes have been made since the last save', () => { + mount({ + lastSavedResultFields: { + foo: {}, + bar: { raw: true, snippet: true, snippetFallback: false }, + }, + resultFields: { + foo: { raw: false, snippet: true, snippetFallback: true }, + bar: { raw: true, snippet: true, snippetFallback: false }, + }, + }); + + // resultFields is different than lastSavedResultsFields, which happens if changes + // have been made since the last save, which is represented by lastSavedResultFields + expect(ResultSettingsLogic.values.stagedUpdates).toEqual(true); + }); + + it('should return false otherwise', () => { + mount({ + lastSavedResultFields: { + foo: { raw: false, snippet: true, snippetFallback: true }, + bar: { raw: true, snippet: true, snippetFallback: false }, + }, + resultFields: { + foo: { raw: false, snippet: true, snippetFallback: true }, + bar: { raw: true, snippet: true, snippetFallback: false }, + }, + }); + + expect(ResultSettingsLogic.values.stagedUpdates).toEqual(false); + }); + }); + + describe('reducedServerResultFields', () => { + it('filters out fields that do not have any settings', () => { + mount({ + serverResultFields: { + foo: { raw: { size: 5 } }, + bar: {}, + }, + }); + + expect(ResultSettingsLogic.values.reducedServerResultFields).toEqual({ + // bar was filtered out because it has neither raw nor snippet data set + foo: { raw: { size: 5 } }, + }); + }); + }); + }); + + describe('listeners', () => { + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + const serverFieldResultSettings = { + foo: { + raw: {}, + }, + bar: { + raw: {}, + }, + }; + const schema = { + foo: 'text', + bar: 'number', + }; + const schemaConflicts = { + baz: { + text: ['test'], + number: ['test2'], + }, + }; + + describe('clearRawSizeForField', () => { + it('should remove the raw size set on a field', () => { + mount({ + resultFields: { + foo: { raw: true, rawSize: 5, snippet: false }, + bar: { raw: true, rawSize: 5, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.clearRawSizeForField('foo'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { + raw: true, + snippet: false, + }); + }); + }); + + describe('clearSnippetSizeForField', () => { + it('should remove the snippet size set on a field', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: { raw: true, rawSize: 5, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.clearSnippetSizeForField('foo'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { + raw: false, + snippet: true, + }); + }); + }); + + describe('toggleRawForField', () => { + it('should toggle the raw value on for a field', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: { raw: false, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleRawForField('bar'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + raw: true, + snippet: false, + }); + }); + + it('should maintain rawSize if it was set prior', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: { raw: false, rawSize: 10, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleRawForField('bar'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + raw: true, + rawSize: 10, + snippet: false, + }); + }); + + it('should remove rawSize value when toggling off', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: { raw: true, rawSize: 5, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleRawForField('bar'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + raw: false, + snippet: false, + }); + }); + + it('should still work if the object is empty', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: {}, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleRawForField('bar'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + raw: true, + }); + }); + }); + + describe('toggleSnippetForField', () => { + it('should toggle the raw value on for a field, always setting the snippet size to 100', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: { raw: false, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleSnippetForField('bar'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + raw: false, + snippet: true, + snippetSize: 100, + }); + }); + + it('should remove rawSize value when toggling off', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: { raw: false, snippet: true, snippetSize: 5 }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleSnippetForField('bar'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + raw: false, + snippet: false, + }); + }); + + it('should still work if the object is empty', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: {}, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleSnippetForField('bar'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + snippet: true, + snippetSize: 100, + }); + }); + }); + + describe('toggleSnippetFallbackForField', () => { + it('should toggle the snippetFallback value for a field', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, + bar: { raw: false, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.toggleSnippetFallbackForField('foo'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { + raw: false, + snippet: true, + snippetSize: 5, + snippetFallback: false, + }); + }); + }); + + describe('updateRawSizeForField', () => { + it('should update the rawSize value for a field', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, + bar: { raw: true, rawSize: 5, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.updateRawSizeForField('bar', 7); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('bar', { + raw: true, + rawSize: 7, + snippet: false, + }); + }); + }); + + describe('updateSnippetSizeForField', () => { + it('should update the snippetSize value for a field', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5, snippetFallback: true }, + bar: { raw: true, rawSize: 5, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.updateSnippetSizeForField('foo', 7); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { + raw: false, + snippet: true, + snippetSize: 7, + snippetFallback: true, + }); + }); + }); + + describe('initializeResultSettingsData', () => { + it('should remove the snippet size set on a field', () => { + mount({ + resultFields: { + foo: { raw: false, snippet: true, snippetSize: 5 }, + bar: { raw: true, rawSize: 5, snippet: false }, + }, + }); + jest.spyOn(ResultSettingsLogic.actions, 'updateField'); + + ResultSettingsLogic.actions.clearSnippetSizeForField('foo'); + + expect(ResultSettingsLogic.actions.updateField).toHaveBeenCalledWith('foo', { + raw: false, + snippet: true, + }); + }); + }); + + describe('initializeResultFields', () => { + it('should make an API call and set state based on the response', async () => { + mount(); + http.get.mockReturnValueOnce( + Promise.resolve({ + searchSettings: { + result_fields: serverFieldResultSettings, + }, + schema, + schemaConflicts, + }) + ); + jest.spyOn(ResultSettingsLogic.actions, 'initializeResultFields'); + + ResultSettingsLogic.actions.initializeResultSettingsData(); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/result_settings/details' + ); + expect(ResultSettingsLogic.actions.initializeResultFields).toHaveBeenCalledWith( + serverFieldResultSettings, + schema, + schemaConflicts + ); + }); + + it('handles errors', async () => { + mount(); + http.get.mockReturnValueOnce(Promise.reject('error')); + + ResultSettingsLogic.actions.initializeResultSettingsData(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('saveResultSettings', () => { + it('should make an API call to update result settings and update state accordingly', async () => { + mount({ + schema, + }); + http.put.mockReturnValueOnce( + Promise.resolve({ + result_fields: serverFieldResultSettings, + }) + ); + jest.spyOn(ResultSettingsLogic.actions, 'saving'); + jest.spyOn(ResultSettingsLogic.actions, 'initializeResultFields'); + + ResultSettingsLogic.actions.saveResultSettings(serverFieldResultSettings); + + expect(ResultSettingsLogic.actions.saving).toHaveBeenCalled(); + + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/api/app_search/engines/test-engine/result_settings', + { + body: JSON.stringify({ + result_fields: serverFieldResultSettings, + }), + } + ); + expect(ResultSettingsLogic.actions.initializeResultFields).toHaveBeenCalledWith( + serverFieldResultSettings, + schema + ); + }); + + it('handles errors', async () => { + mount(); + http.put.mockReturnValueOnce(Promise.reject('error')); + + ResultSettingsLogic.actions.saveResultSettings(serverFieldResultSettings); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts index b2ffd3de19f04..9969a950528ab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_logic.ts @@ -6,9 +6,16 @@ */ import { kea, MakeLogicType } from 'kea'; +import { omit, isEqual } from 'lodash'; +import { i18n } from '@kbn/i18n'; + +import { flashAPIErrors, setSuccessMessage } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; import { Schema, SchemaConflicts } from '../../../shared/types'; +import { EngineLogic } from '../engine'; +import { DEFAULT_SNIPPET_SIZE } from './constants'; import { FieldResultSetting, FieldResultSettingObject, @@ -17,6 +24,8 @@ import { } from './types'; import { + areFieldsAtDefaultSettings, + areFieldsEmpty, clearAllFields, clearAllServerFields, convertServerResultFieldsToResultFields, @@ -46,9 +55,21 @@ interface ResultSettingsActions { resetAllFields(): void; updateField( fieldName: string, - settings: FieldResultSetting + settings: FieldResultSetting | {} ): { fieldName: string; settings: FieldResultSetting }; saving(): void; + // Listeners + clearRawSizeForField(fieldName: string): { fieldName: string }; + clearSnippetSizeForField(fieldName: string): { fieldName: string }; + toggleRawForField(fieldName: string): { fieldName: string }; + toggleSnippetForField(fieldName: string): { fieldName: string }; + toggleSnippetFallbackForField(fieldName: string): { fieldName: string }; + updateRawSizeForField(fieldName: string, size: number): { fieldName: string; size: number }; + updateSnippetSizeForField(fieldName: string, size: number): { fieldName: string; size: number }; + initializeResultSettingsData(): void; + saveResultSettings( + resultFields: ServerFieldResultSettingObject + ): { resultFields: ServerFieldResultSettingObject }; } interface ResultSettingsValues { @@ -62,6 +83,11 @@ interface ResultSettingsValues { lastSavedResultFields: FieldResultSettingObject; schema: Schema; schemaConflicts: SchemaConflicts; + // Selectors + resultFieldsAtDefaultSettings: boolean; + resultFieldsEmpty: boolean; + stagedUpdates: true; + reducedServerResultFields: ServerFieldResultSettingObject; } export const ResultSettingsLogic = kea>({ @@ -90,6 +116,15 @@ export const ResultSettingsLogic = kea true, updateField: (fieldName, settings) => ({ fieldName, settings }), saving: () => true, + clearRawSizeForField: (fieldName) => ({ fieldName }), + clearSnippetSizeForField: (fieldName) => ({ fieldName }), + toggleRawForField: (fieldName) => ({ fieldName }), + toggleSnippetForField: (fieldName) => ({ fieldName }), + toggleSnippetFallbackForField: (fieldName) => ({ fieldName }), + updateRawSizeForField: (fieldName, size) => ({ fieldName, size }), + updateSnippetSizeForField: (fieldName, size) => ({ fieldName, size }), + initializeResultSettingsData: () => true, + saveResultSettings: (resultFields) => ({ resultFields }), }), reducers: () => ({ dataLoading: [ @@ -187,4 +222,122 @@ export const ResultSettingsLogic = kea ({ + resultFieldsAtDefaultSettings: [ + () => [selectors.resultFields], + (resultFields) => areFieldsAtDefaultSettings(resultFields), + ], + resultFieldsEmpty: [ + () => [selectors.resultFields], + (resultFields) => areFieldsEmpty(resultFields), + ], + stagedUpdates: [ + () => [selectors.lastSavedResultFields, selectors.resultFields], + (lastSavedResultFields, resultFields) => !isEqual(lastSavedResultFields, resultFields), + ], + reducedServerResultFields: [ + () => [selectors.serverResultFields], + (serverResultFields: ServerFieldResultSettingObject) => + Object.entries(serverResultFields).reduce( + (acc: ServerFieldResultSettingObject, [fieldName, resultSetting]) => { + if (resultSetting.raw || resultSetting.snippet) { + acc[fieldName] = resultSetting; + } + return acc; + }, + {} + ), + ], + }), + listeners: ({ actions, values }) => ({ + clearRawSizeForField: ({ fieldName }) => { + actions.updateField(fieldName, omit(values.resultFields[fieldName], ['rawSize'])); + }, + clearSnippetSizeForField: ({ fieldName }) => { + actions.updateField(fieldName, omit(values.resultFields[fieldName], ['snippetSize'])); + }, + toggleRawForField: ({ fieldName }) => { + // We cast this because it could be an empty object, which we can still treat as a FieldResultSetting safely + const field = values.resultFields[fieldName] as FieldResultSetting; + const raw = !field.raw; + actions.updateField(fieldName, { + ...omit(field, ['rawSize']), + raw, + ...(raw ? { rawSize: field.rawSize } : {}), + }); + }, + toggleSnippetForField: ({ fieldName }) => { + // We cast this because it could be an empty object, which we can still treat as a FieldResultSetting safely + const field = values.resultFields[fieldName] as FieldResultSetting; + const snippet = !field.snippet; + + actions.updateField(fieldName, { + ...omit(field, ['snippetSize']), + snippet, + ...(snippet ? { snippetSize: DEFAULT_SNIPPET_SIZE } : {}), + }); + }, + toggleSnippetFallbackForField: ({ fieldName }) => { + // We cast this because it could be an empty object, which we can still treat as a FieldResultSetting safely + const field = values.resultFields[fieldName] as FieldResultSetting; + actions.updateField(fieldName, { + ...field, + snippetFallback: !field.snippetFallback, + }); + }, + updateRawSizeForField: ({ fieldName, size }) => { + actions.updateField(fieldName, { ...values.resultFields[fieldName], rawSize: size }); + }, + updateSnippetSizeForField: ({ fieldName, size }) => { + actions.updateField(fieldName, { ...values.resultFields[fieldName], snippetSize: size }); + }, + initializeResultSettingsData: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const url = `/api/app_search/engines/${engineName}/result_settings/details`; + + try { + const { + schema, + schemaConflicts, + searchSettings: { result_fields: serverFieldResultSettings }, + } = await http.get(url); + + actions.initializeResultFields(serverFieldResultSettings, schema, schemaConflicts); + } catch (e) { + flashAPIErrors(e); + } + }, + saveResultSettings: async ({ resultFields }) => { + actions.saving(); + + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + const url = `/api/app_search/engines/${engineName}/result_settings`; + + actions.saving(); + + let response; + try { + response = await http.put(url, { + body: JSON.stringify({ + result_fields: resultFields, + }), + }); + } catch (e) { + flashAPIErrors(e); + } + + actions.initializeResultFields(response.result_fields, values.schema); + setSuccessMessage( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.resultSettings.saveSuccessMessage', + { + defaultMessage: 'Result settings have been saved successfully.', + } + ) + ); + }, + }), }); 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 da763dfe7cdc4..96bf277314a7b 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 @@ -34,4 +34,4 @@ export interface FieldResultSetting { snippetFallback: boolean; } -export type FieldResultSettingObject = Record; +export type FieldResultSettingObject = Record; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts index 2482ecab5892c..0ed0353790a77 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.test.ts @@ -8,6 +8,8 @@ import { SchemaTypes } from '../../../shared/types'; import { + areFieldsAtDefaultSettings, + areFieldsEmpty, convertServerResultFieldsToResultFields, convertToServerFieldResultSetting, clearAllServerFields, @@ -172,3 +174,68 @@ describe('splitResultFields', () => { }); }); }); + +describe('areFieldsEmpty', () => { + it('should return true if all fields are empty objects', () => { + expect( + areFieldsEmpty({ + foo: {}, + bar: {}, + }) + ).toBe(true); + }); + it('should return false otherwise', () => { + expect( + areFieldsEmpty({ + foo: { + raw: true, + rawSize: 5, + snippet: false, + snippetFallback: false, + }, + bar: { + raw: true, + rawSize: 5, + snippet: false, + snippetFallback: false, + }, + }) + ).toBe(false); + }); +}); + +describe('areFieldsAtDefaultSettings', () => { + it('will return true if all settings for all fields are at their defaults', () => { + expect( + areFieldsAtDefaultSettings({ + foo: { + raw: true, + snippet: false, + snippetFallback: false, + }, + bar: { + raw: true, + snippet: false, + snippetFallback: false, + }, + }) + ).toEqual(true); + }); + + it('will return false otherwise', () => { + expect( + areFieldsAtDefaultSettings({ + foo: { + raw: true, + snippet: false, + snippetFallback: false, + }, + bar: { + raw: false, + snippet: true, + snippetFallback: true, + }, + }) + ).toEqual(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts index 0311132542d99..a44a18bef2810 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/utils.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { isEqual, isEmpty } from 'lodash'; + import { Schema } from '../../../shared/types'; import { DEFAULT_FIELD_SETTINGS, DISABLED_FIELD_SETTINGS } from './constants'; @@ -115,3 +117,17 @@ export const splitResultFields = (resultFields: FieldResultSettingObject, schema return { textResultFields, nonTextResultFields }; }; + +export const areFieldsEmpty = (fields: FieldResultSettingObject) => { + const anyNonEmptyField = Object.values(fields).find((resultSettings) => { + return !isEmpty(resultSettings); + }); + return !anyNonEmptyField; +}; + +export const areFieldsAtDefaultSettings = (fields: FieldResultSettingObject) => { + const anyNonDefaultSettingsValue = Object.values(fields).find((resultSettings) => { + return !isEqual(resultSettings, DEFAULT_FIELD_SETTINGS); + }); + return !anyNonDefaultSettingsValue; +}; 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 74f13a05aa7e6..1bd88c111f79f 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 { registerResultSettingsRoutes } from './result_settings'; import { registerRoleMappingsRoutes } from './role_mappings'; import { registerSearchSettingsRoutes } from './search_settings'; import { registerSettingsRoutes } from './settings'; @@ -26,4 +27,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerCurationsRoutes(dependencies); registerSearchSettingsRoutes(dependencies); registerRoleMappingsRoutes(dependencies); + registerResultSettingsRoutes(dependencies); }; 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 new file mode 100644 index 0000000000000..8d1a7e3ead37b --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { mockDependencies, mockRequestHandler, MockRouter } from '../../__mocks__'; + +import { registerResultSettingsRoutes } from './result_settings'; + +const resultFields = { + id: { + raw: {}, + }, + hp: { + raw: {}, + }, + name: { + raw: {}, + }, +}; + +describe('result settings routes', () => { + describe('GET /api/app_search/engines/{name}/result_settings/details', () => { + const mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/result_settings/details', + }); + + beforeEach(() => { + registerResultSettingsRoutes({ + ...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/result_settings/details', + }); + }); + }); + + describe('PUT /api/app_search/engines/{name}/result_settings', () => { + const mockRouter = new MockRouter({ + method: 'put', + path: '/api/app_search/engines/{engineName}/result_settings', + }); + + beforeEach(() => { + registerResultSettingsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + body: { + result_settings: resultFields, + }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/result_settings', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + 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 new file mode 100644 index 0000000000000..38cb4aa922738 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/result_settings.ts @@ -0,0 +1,48 @@ +/* + * 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'; + +const resultFields = schema.recordOf(schema.string(), schema.object({}, { unknowns: 'allow' })); + +export function registerResultSettingsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/result_settings/details', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/result_settings/details', + }) + ); + + router.put( + { + path: '/api/app_search/engines/{engineName}/result_settings', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + result_fields: resultFields, + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/result_settings', + }) + ); +}