From 6ef7e1358a535aece79fffd89ddace54fc3860ed Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Thu, 4 Feb 2021 14:40:19 -0500 Subject: [PATCH] [App Search] Relevance Tuning logic - actions and selectors only, no listeners (#89313) --- .../components/relevance_tuning/index.ts | 1 + .../relevance_tuning_logic.test.ts | 297 ++++++++++++++++++ .../relevance_tuning_logic.ts | 158 ++++++++++ .../components/relevance_tuning/types.ts | 23 ++ 4 files changed, 479 insertions(+) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts 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 909d10aae6823..07e53d0d29282 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 @@ -7,3 +7,4 @@ export { RELEVANCE_TUNING_TITLE } from './constants'; export { RelevanceTuning } from './relevance_tuning'; +export { RelevanceTuningLogic } from './relevance_tuning_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts new file mode 100644 index 0000000000000..586a845ce382a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.test.ts @@ -0,0 +1,297 @@ +/* + * 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 } from '../../../__mocks__'; + +import { BoostType } from './types'; + +import { RelevanceTuningLogic } from './relevance_tuning_logic'; + +describe('RelevanceTuningLogic', () => { + const { mount } = new LogicMounter(RelevanceTuningLogic); + + const searchSettings = { + boosts: { + foo: [ + { + type: 'value' as BoostType, + factor: 5, + }, + ], + }, + search_fields: {}, + }; + const schema = {}; + const schemaConflicts = {}; + const relevanceTuningProps = { + searchSettings, + schema, + schemaConflicts, + }; + const searchResults = [{}, {}]; + + const DEFAULT_VALUES = { + dataLoading: true, + schema: {}, + schemaConflicts: {}, + searchSettings: {}, + unsavedChanges: false, + filterInputValue: '', + query: '', + resultsLoading: false, + searchResults: null, + showSchemaConflictCallout: true, + engineHasSchemaFields: false, + schemaFields: [], + schemaFieldsWithConflicts: [], + filteredSchemaFields: [], + filteredSchemaFieldsWithConflicts: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(RelevanceTuningLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onInitializeRelevanceTuning', () => { + it('should set searchSettings, schema, & schemaConflicts from the API response, and set dataLoading to false', () => { + mount({ + dataLoading: true, + }); + RelevanceTuningLogic.actions.onInitializeRelevanceTuning(relevanceTuningProps); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchSettings, + schema, + dataLoading: false, + schemaConflicts, + }); + }); + }); + + describe('setSearchSettings', () => { + it('should set setSearchSettings and set unsavedChanges to true', () => { + mount({ + unsavedChanges: false, + }); + RelevanceTuningLogic.actions.setSearchSettings(searchSettings); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchSettings, + unsavedChanges: true, + }); + }); + }); + + describe('setFilterValue', () => { + it('should set filterInputValue', () => { + mount(); + RelevanceTuningLogic.actions.setFilterValue('foo'); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + filterInputValue: 'foo', + }); + }); + }); + + describe('setSearchQuery', () => { + it('should set query', () => { + mount(); + RelevanceTuningLogic.actions.setSearchQuery('foo'); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + query: 'foo', + }); + }); + }); + + describe('setSearchResults', () => { + it('should set searchResults and set resultLoading to false', () => { + mount({ + resultsLoading: true, + }); + RelevanceTuningLogic.actions.setSearchResults(searchResults); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults, + resultsLoading: false, + }); + }); + }); + + describe('setResultsLoading', () => { + it('should set resultsLoading', () => { + mount({ + resultsLoading: false, + }); + RelevanceTuningLogic.actions.setResultsLoading(true); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + resultsLoading: true, + }); + }); + }); + + describe('clearSearchResults', () => { + it('should set searchResults', () => { + mount({ + searchResults: [{}], + }); + RelevanceTuningLogic.actions.clearSearchResults(); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + searchResults: null, + }); + }); + }); + + describe('resetSearchSettingsState', () => { + it('should set dataLoading', () => { + mount({ + dataLoading: false, + }); + RelevanceTuningLogic.actions.resetSearchSettingsState(); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + }); + + describe('dismissSchemaConflictCallout', () => { + it('should set showSchemaConflictCallout to false', () => { + mount({ + showSchemaConflictCallout: true, + }); + RelevanceTuningLogic.actions.dismissSchemaConflictCallout(); + + expect(RelevanceTuningLogic.values).toEqual({ + ...DEFAULT_VALUES, + showSchemaConflictCallout: false, + }); + }); + }); + }); + + describe('selectors', () => { + describe('engineHasSchemaFields', () => { + it('should return false if there is only a single field in a schema', () => { + // This is because if a schema only has a single field, it is "id", which we do not + // consider a tunable field. + mount({ + schema: { + id: 'foo', + }, + }); + expect(RelevanceTuningLogic.values.engineHasSchemaFields).toEqual(false); + }); + + it('should return true otherwise', () => { + mount({ + schema: { + id: 'foo', + bar: 'bar', + }, + }); + expect(RelevanceTuningLogic.values.engineHasSchemaFields).toEqual(true); + }); + }); + + describe('schemaFields', () => { + it('should return the list of field names from the schema', () => { + mount({ + schema: { + id: 'foo', + bar: 'bar', + }, + }); + expect(RelevanceTuningLogic.values.schemaFields).toEqual(['id', 'bar']); + }); + }); + + describe('schemaFieldsWithConflicts', () => { + it('should return the list of field names that have schema conflicts', () => { + mount({ + schemaConflicts: { + foo: { + text: ['source_engine_1'], + number: ['source_engine_2'], + }, + }, + }); + expect(RelevanceTuningLogic.values.schemaFieldsWithConflicts).toEqual(['foo']); + }); + }); + + describe('filteredSchemaFields', () => { + it('should return a list of schema field names that contain the text from filterInputValue ', () => { + mount({ + filterInputValue: 'ba', + schema: { + id: 'string', + foo: 'string', + bar: 'string', + baz: 'string', + }, + }); + expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual(['bar', 'baz']); + }); + + it('should return all schema fields if there is no filter applied', () => { + mount({ + filterTerm: '', + schema: { + id: 'string', + foo: 'string', + bar: 'string', + baz: 'string', + }, + }); + expect(RelevanceTuningLogic.values.filteredSchemaFields).toEqual([ + 'id', + 'foo', + 'bar', + 'baz', + ]); + }); + }); + + describe('filteredSchemaFieldsWithConflicts', () => { + it('should return a list of schema field names that contain the text from filterInputValue, and if that field has a schema conflict', () => { + mount({ + filterInputValue: 'ba', + schema: { + id: 'string', + foo: 'string', + bar: 'string', + baz: 'string', + }, + schemaConflicts: { + bar: { + text: ['source_engine_1'], + number: ['source_engine_2'], + }, + }, + }); + expect(RelevanceTuningLogic.values.filteredSchemaFieldsWithConflicts).toEqual(['bar']); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts new file mode 100644 index 0000000000000..d4ec5e37f6ce5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/relevance_tuning_logic.ts @@ -0,0 +1,158 @@ +/* + * 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 { Schema, SchemaConflicts } from '../../../shared/types'; + +import { SearchSettings } from './types'; + +interface RelevanceTuningProps { + searchSettings: SearchSettings; + schema: Schema; + schemaConflicts: SchemaConflicts; +} + +interface RelevanceTuningActions { + onInitializeRelevanceTuning(props: RelevanceTuningProps): RelevanceTuningProps; + setSearchSettings(searchSettings: SearchSettings): { searchSettings: SearchSettings }; + setFilterValue(value: string): string; + setSearchQuery(value: string): string; + setSearchResults(searchResults: object[]): object[]; + setResultsLoading(resultsLoading: boolean): boolean; + clearSearchResults(): void; + resetSearchSettingsState(): void; + dismissSchemaConflictCallout(): void; +} + +interface RelevanceTuningValues { + searchSettings: Partial; + schema: Schema; + schemaFields: string[]; + schemaFieldsWithConflicts: string[]; + filteredSchemaFields: string[]; + filteredSchemaFieldsWithConflicts: string[]; + schemaConflicts: SchemaConflicts; + showSchemaConflictCallout: boolean; + engineHasSchemaFields: boolean; + filterInputValue: string; + query: string; + unsavedChanges: boolean; + dataLoading: boolean; + searchResults: object[] | null; + resultsLoading: boolean; +} + +// If the user hasn't entered a filter, then we can skip filtering the array entirely +const filterIfTerm = (array: string[], filterTerm: string): string[] => { + return filterTerm === '' ? array : array.filter((item) => item.includes(filterTerm)); +}; + +export const RelevanceTuningLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'app_search', 'relevance_tuning_logic'], + actions: () => ({ + onInitializeRelevanceTuning: (props) => props, + setSearchSettings: (searchSettings) => ({ searchSettings }), + setFilterValue: (value) => value, + setSearchQuery: (query) => query, + setSearchResults: (searchResults) => searchResults, + setResultsLoading: (resultsLoading) => resultsLoading, + clearSearchResults: true, + resetSearchSettingsState: true, + dismissSchemaConflictCallout: true, + }), + reducers: () => ({ + searchSettings: [ + {}, + { + onInitializeRelevanceTuning: (_, { searchSettings }) => searchSettings, + setSearchSettings: (_, { searchSettings }) => searchSettings, + }, + ], + schema: [ + {}, + { + onInitializeRelevanceTuning: (_, { schema }) => schema, + }, + ], + schemaConflicts: [ + {}, + { + onInitializeRelevanceTuning: (_, { schemaConflicts }) => schemaConflicts, + }, + ], + showSchemaConflictCallout: [ + true, + { + dismissSchemaConflictCallout: () => false, + }, + ], + filterInputValue: [ + '', + { + setFilterValue: (_, filterInputValue) => filterInputValue, + }, + ], + query: [ + '', + { + setSearchQuery: (_, query) => query, + }, + ], + unsavedChanges: [ + false, + { + setSearchSettings: () => true, + }, + ], + + dataLoading: [ + true, + { + onInitializeRelevanceTuning: () => false, + resetSearchSettingsState: () => true, + }, + ], + searchResults: [ + null, + { + clearSearchResults: () => null, + setSearchResults: (_, searchResults) => searchResults, + }, + ], + resultsLoading: [ + false, + { + setResultsLoading: (_, resultsLoading) => resultsLoading, + setSearchResults: () => false, + }, + ], + }), + selectors: ({ selectors }) => ({ + schemaFields: [() => [selectors.schema], (schema: Schema) => Object.keys(schema)], + schemaFieldsWithConflicts: [ + () => [selectors.schemaConflicts], + (schemaConflicts: SchemaConflicts) => Object.keys(schemaConflicts), + ], + filteredSchemaFields: [ + () => [selectors.schemaFields, selectors.filterInputValue], + (schemaFields: string[], filterInputValue: string): string[] => + filterIfTerm(schemaFields, filterInputValue), + ], + filteredSchemaFieldsWithConflicts: [ + () => [selectors.schemaFieldsWithConflicts, selectors.filterInputValue], + (schemaFieldsWithConflicts: string[], filterInputValue: string): string[] => + filterIfTerm(schemaFieldsWithConflicts, filterInputValue), + ], + engineHasSchemaFields: [ + () => [selectors.schema], + (schema: Schema): boolean => Object.keys(schema).length >= 2, + ], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts new file mode 100644 index 0000000000000..25187df89d64c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/relevance_tuning/types.ts @@ -0,0 +1,23 @@ +/* + * 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 type BoostType = 'value' | 'functional' | 'proximity'; + +export interface Boost { + type: BoostType; + operation?: string; + function?: string; + newBoost?: boolean; + center?: string | number; + value?: string | number | string[] | number[]; + factor: number; +} + +export interface SearchSettings { + boosts: Record; + search_fields: object; +}